Compare commits

..
Author SHA1 Message Date
François-X. T. 8697b58a43 Add refresh button in files tab behind feature flag 2026-02-25 21:01:05 -05:00
45387e5fb6 Fix fields for pix payout (#5428)
Co-authored-by: Creeperkatze <178587183+Creeperkatze@users.noreply.github.com>
2026-02-25 21:30:31 +00:00
317 changed files with 4478 additions and 19236 deletions
+1 -11
View File
@@ -21,14 +21,6 @@ on:
type: boolean
default: false
required: false
environment:
description: Environment
type: choice
options:
- prod
- staging
default: prod
required: false
jobs:
build:
@@ -102,14 +94,12 @@ 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.${BUILD_ENVIRONMENT}" packages/app-lib/.env
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8
Generated
-58
View File
@@ -640,19 +640,6 @@ 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"
@@ -2180,16 +2167,6 @@ 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"
@@ -2218,19 +2195,6 @@ 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"
@@ -2253,17 +2217,6 @@ 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"
@@ -4777,7 +4730,6 @@ dependencies = [
"arc-swap",
"argon2",
"ariadne",
"async-minecraft-ping",
"async-stripe",
"async-trait",
"base64 0.22.1",
@@ -4859,16 +4811,6 @@ dependencies = [
"zxcvbn",
]
[[package]]
name = "labrinth-derive"
version = "0.0.0"
dependencies = [
"darling 0.23.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "language-tags"
version = "0.3.2"
-6
View File
@@ -8,7 +8,6 @@ members = [
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
"packages/labrinth-derive",
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
@@ -33,7 +32,6 @@ 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",
@@ -60,7 +58,6 @@ 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" }
@@ -124,11 +121,9 @@ 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"
@@ -171,7 +166,6 @@ 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"
+68 -152
View File
@@ -36,16 +36,14 @@ import {
NewsArticleCard,
NotificationPanel,
OverflowMenu,
PopupNotificationPanel,
ProgressSpinner,
provideModrinthClient,
provideNotificationManager,
providePageContext,
providePopupNotificationManager,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { formatBytes, renderString } from '@modrinth/utils'
import { renderString } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { getVersion } from '@tauri-apps/api/app'
import { invoke } from '@tauri-apps/api/core'
@@ -63,7 +61,6 @@ 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'
@@ -71,13 +68,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'
@@ -104,14 +101,13 @@ import {
subscribeToDownloadProgress,
} from '@/providers/download-progress.ts'
import { useError } from '@/store/error.js'
import { playServerProject, useInstall } from '@/store/install.js'
import { 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()
@@ -119,10 +115,6 @@ 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: [
@@ -401,11 +393,8 @@ 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()
@@ -484,10 +473,6 @@ 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)
@@ -505,9 +490,6 @@ 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)
@@ -526,60 +508,14 @@ 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
}
@@ -599,6 +535,7 @@ async function checkUpdates() {
appUpdateDownload.progress.value = 0
finishedDownloading.value = false
updateToastDismissed.value = false
console.log(`Update ${update.version} is available.`)
@@ -608,28 +545,6 @@ 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))
@@ -646,26 +561,8 @@ async function checkUpdates() {
)
}
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 showUpdateToast() {
updateToastDismissed.value = false
}
async function downloadAvailableUpdate() {
@@ -691,26 +588,6 @@ 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,
@@ -884,6 +761,25 @@ 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"
@@ -960,7 +856,14 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</NavButton>
<div class="flex flex-grow"></div>
<Transition name="nav-button-animated">
<div v-if="availableUpdate && !restarting && (finishedDownloading || metered)">
<div
v-if="
availableUpdate &&
updateToastDismissed &&
!restarting &&
(finishedDownloading || metered)
"
>
<NavButton
v-tooltip.right="
formatMessage(
@@ -974,7 +877,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
},
)
"
:to="finishedDownloading ? installUpdate : downloadAvailableUpdate"
:to="
finishedDownloading
? installUpdate
: downloadProgress > 0 && downloadProgress < 1
? showUpdateToast
: downloadAvailableUpdate
"
>
<ProgressSpinner
v-if="downloadProgress > 0 && downloadProgress < 1"
@@ -1009,25 +918,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
]"
placement="right-end"
>
<Avatar
v-if="credentials.user?.avatar_url"
: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
v-if="credentials.user?.avatar_url"
:src="credentials.user.avatar_url"
alt=""
size="20px"
circle
/>
<Avatar :src="credentials.user.avatar_url" alt="" size="20px" circle />
{{ credentials.user.username }}
</span>
</span>
@@ -1236,15 +1133,11 @@ 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>
@@ -1473,15 +1366,38 @@ 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;
}
@@ -1545,7 +1461,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
}
.info-card {
right: 22rem;
right: 8rem;
}
.profile-card {
+39 -63
View File
@@ -1,7 +1,5 @@
<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
@@ -16,17 +14,13 @@
<span class="text-nowrap">{{ link.label }}</span>
</RouterLink>
<div
: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 },
]"
: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'}`"
:style="{
left: sliderLeftPx,
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderReady && activeIndex !== -1 ? 1 : 0,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
@@ -34,7 +28,7 @@
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
@@ -53,16 +47,13 @@ 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)),
@@ -72,18 +63,13 @@ const sliderTopPx = computed(() => `${sliderTop.value}px`)
const sliderRightPx = computed(() => `${sliderRight.value}px`)
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
const leftDelay = computed(() => sliderDelays.value.left)
const rightDelay = computed(() => sliderDelays.value.right)
const topDelay = computed(() => sliderDelays.value.top)
const bottomDelay = computed(() => sliderDelays.value.bottom)
function pickLink() {
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (route.path === (typeof link.href === 'string' ? link.href : (link.href as any).path)) {
if (route.path === (typeof link.href === 'string' ? link.href : link.href.path)) {
index = i
break
} else if (link.subpages && link.subpages.some((subpage) => route.path.includes(subpage))) {
@@ -97,54 +83,57 @@ function pickLink() {
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderLeft.value = 0
sliderRight.value = 0
}
}
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
}
const tabLinkElements = ref()
function startAnimation() {
const el = getTabElement(activeIndex.value)
if (!el?.offsetParent) return
const el = tabLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
const parent = el.offsetParent as HTMLElement
const newValues = {
left: el.offsetLeft,
top: el.offsetTop,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
}
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
if (sliderLeft.value === 4 && sliderRight.value === 4) {
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 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',
const delay = 200
if (newValues.left < sliderLeft.value) {
sliderLeft.value = newValues.left
setTimeout(() => {
sliderRight.value = newValues.right
}, delay)
} else {
sliderRight.value = newValues.right
setTimeout(() => {
sliderLeft.value = newValues.left
}, delay)
}
if (newValues.top < sliderTop.value) {
sliderTop.value = newValues.top
setTimeout(() => {
sliderBottom.value = newValues.bottom
}, delay)
} else {
sliderBottom.value = newValues.bottom
setTimeout(() => {
sliderTop.value = newValues.top
}, delay)
}
sliderLeft.value = newValues.left
sliderRight.value = newValues.right
sliderTop.value = newValues.top
sliderBottom.value = newValues.bottom
}
}
@@ -157,17 +146,7 @@ onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(
filteredLinks,
async () => {
await nextTick()
pickLink()
},
{ deep: true },
)
watch(route, async () => {
await nextTick()
watch(route, () => {
pickLink()
})
</script>
@@ -175,10 +154,7 @@ watch(route, async () => {
.navtabs-transition {
/* Delay on opacity is to hide any jankiness as the page loads */
transition:
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),
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>
@@ -69,10 +69,7 @@ onUnmounted(() => {
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
<div
v-if="instances && recentInstances.length > 0"
class="h-px w-6 mx-auto my-2 bg-divider"
></div>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
</template>
<style scoped lang="scss"></style>
@@ -52,12 +52,10 @@
<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>
<ProgressBar :progress="Math.floor((100 * loadingBar.current) / loadingBar.total)" />
<div class="row">
{{ Math.floor((100 * loadingBar.current) / loadingBar.total) }}%
{{ loadingBar.message }}
</div>
</div>
</Card>
@@ -104,12 +102,7 @@ import {
TerminalSquareIcon,
UnplugIcon,
} from '@modrinth/assets'
import {
Button,
ButtonStyled,
Card,
injectNotificationManager,
} from '@modrinth/ui'
import { Button, ButtonStyled, Card, injectNotificationManager } from '@modrinth/ui'
import { onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
@@ -183,7 +176,7 @@ const goToTerminal = (path) => {
const currentLoadingBars = ref([])
const refreshInfo = async () => {
const previousBars = [...currentLoadingBars.value]
const currentLoadingBarCount = currentLoadingBars.value.length
currentLoadingBars.value = Object.values(await progress_bars_list().catch(handleError))
.map((x) => {
if (x.bar_type.type === 'java_download') {
@@ -212,7 +205,7 @@ const refreshInfo = async () => {
if (currentLoadingBars.value.length === 0) {
showCard.value = false
} else if (previousBars.length < currentLoadingBars.value.length) {
} else if (currentLoadingBarCount < currentLoadingBars.value.length) {
showCard.value = true
}
}
@@ -353,7 +346,7 @@ onBeforeUnmount(() => {
.info-card {
position: absolute;
top: 3.5rem;
right: 100%;
right: 0.5rem;
z-index: 9;
width: 20rem;
background-color: var(--color-raised-bg);
@@ -427,7 +420,7 @@ onBeforeUnmount(() => {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.75rem;
gap: 0.5rem;
margin: 0;
padding: 0;
}
@@ -0,0 +1,84 @@
<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>
@@ -0,0 +1,130 @@
<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>
@@ -1,136 +0,0 @@
<script setup>
import {
CheckIcon,
PlusIcon,
SearchIcon,
} from '@modrinth/assets'
import { Admonition, Avatar, ButtonStyled, injectNotificationManager, StyledInput } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/profile'
import { add_server_to_profile, get_profile_worlds } from '@/helpers/worlds.ts'
const { handleError } = injectNotificationManager()
const modal = ref()
const searchFilter = ref('')
const profiles = ref([])
const serverName = ref('')
const serverAddress = ref('')
const shownProfiles = computed(() =>
profiles.value.filter((profile) => {
return profile.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
)
defineExpose({
show: async (name, address) => {
serverName.value = name
serverAddress.value = address
searchFilter.value = ''
const profilesVal = await list().catch(handleError)
for (const profile of profilesVal) {
profile.adding = false
profile.added = false
try {
const worlds = await get_profile_worlds(profile.path)
profile.added = worlds.some(
(w) => w.type === 'server' && w.address === serverAddress.value,
)
} catch {
// Ignore - will show as not added
}
}
profiles.value = profilesVal
modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
},
})
async function addServer(profile) {
profile.adding = true
try {
await add_server_to_profile(
profile.path,
serverName.value,
serverAddress.value,
'prompt',
)
profile.added = true
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: profile.name,
source: 'AddServerToInstanceModal',
})
} catch (err) {
handleError(err)
}
profile.adding = false
}
</script>
<template>
<ModalWrapper ref="modal" header="Add server to instance">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition type="warning" body="This server may not be compatible with all instances." />
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
placeholder="Search for an instance"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
<div
v-for="profile in shownProfiles"
:key="profile.path"
class="flex w-full items-center justify-between gap-2 bg-bg-raised text-icon shadow-none"
>
<router-link
class="btn btn-transparent p-2 text-left"
:to="`/instance/${encodeURIComponent(profile.path)}`"
@click="modal.hide()"
>
<Avatar
:src="profile.icon_path ? convertFileSrc(profile.icon_path) : null"
class="mr-2 [--size:2rem]"
/>
{{ profile.name }}
</router-link>
<ButtonStyled>
<button
:disabled="profile.added || profile.adding"
@click="addServer(profile)"
>
<PlusIcon v-if="!profile.added && !profile.adding" />
<CheckIcon v-else-if="profile.added" />
{{
profile.adding
? 'Adding...'
: profile.added
? 'Added'
: 'Add'
}}
</button>
</ButtonStyled>
</div>
</div>
<div class="input-group push-right">
<ButtonStyled>
<button @click="modal.hide()">Cancel</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
@@ -16,7 +16,6 @@ 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,
@@ -82,22 +81,6 @@ defineExpose({
handleError,
)
}
const linkedProjectIds = profilesVal
.filter((p) => p.linked_data?.project_id)
.map((p) => p.linked_data.project_id)
if (linkedProjectIds.length > 0) {
const linkedProjects = await get_project_v3_many(linkedProjectIds, 'stale_while_revalidate_skip_offline').catch(
() => [],
)
const serverProjectIds = new Set(
linkedProjects.filter((p) => p?.minecraft_server != null).map((p) => p.id),
)
for (const profile of profilesVal) {
profile.isServerInstance = serverProjectIds.has(profile.linked_data?.project_id)
}
}
profiles.value = profilesVal
installModal.value.show()
@@ -29,7 +29,7 @@ import { computed, type ComputedRef, type Ref, ref, shallowRef, watch } from 'vu
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_version, get_version_many } from '@/helpers/cache'
import { get_project, 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,12 +110,6 @@ 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(() => {
@@ -430,18 +424,6 @@ 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',
@@ -575,27 +557,17 @@ const messages = defineMessages({
})
}}
</span>
<span class="text-sm text-secondary leading-none capitalize">
<span class="text-sm text-secondary leading-none">
{{
modpackProject
? modpackVersion
? modpackVersion?.version_number
: props.isMinecraftServer
? ''
: 'Unknown version'
: '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>
@@ -627,10 +599,7 @@ const messages = defineMessages({
}}
</button>
</ButtonStyled>
<ButtonStyled
v-if="modpackProject && !props.isMinecraftServer"
hover-color-fill="background"
>
<ButtonStyled v-if="modpackProject" hover-color-fill="background">
<button
v-tooltip="
changingVersion
@@ -785,29 +754,17 @@ 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(
props.isMinecraftServer ? messages.unlinkServerTitle : messages.unlinkInstanceTitle,
)
}}
{{ formatMessage(messages.unlinkInstanceTitle) }}
</h2>
<p class="m-0">
{{
formatMessage(
props.isMinecraftServer
? instance.loader === 'vanilla'
? messages.unlinkServerVanillaDescription
: messages.unlinkServerDescription
: messages.unlinkInstanceDescription,
)
}}
{{ formatMessage(messages.unlinkInstanceDescription) }}
</p>
<ButtonStyled>
<button class="mt-2" @click="modalConfirmUnpair.show()">
<UnlinkIcon /> {{ formatMessage(messages.unlinkInstanceButton) }}
</button>
</ButtonStyled>
<template v-if="modpackProject && !props.isMinecraftServer">
<template v-if="modpackProject">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast block mt-4">
{{ formatMessage(messages.reinstallModpackTitle) }}
@@ -1,41 +1,36 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
<div class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.sharedServerInstance)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<div 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>
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar
:src="requiredContentProject.icon_url"
:alt="requiredContentProject.title"
size="48px"
/>
<div class="flex flex-col gap-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 flex-col gap-0.5">
<span class="font-semibold text-contrast">
<template v-if="usingCustomModpack">
{{ modpackVersion.name }}
</template>
<template v-else>
{{ requiredContentProject.title }}
</template>
</span>
<span class="font-semibold text-contrast">{{ project.title }}</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
{{ loaderDisplay }} {{ project.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
</template>
@@ -62,17 +57,11 @@
</div>
</template>
</NewModal>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="project?.title ?? ''"
:modpack-icon-url="project?.icon_url ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Avatar,
@@ -80,38 +69,33 @@ import {
commonMessages,
defineMessages,
formatLoader,
ModpackContentModal,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import {
get_organization,
get_project,
get_project_many,
get_team,
get_version,
get_version_many,
} from '@/helpers/cache.js'
import { installServerProject, useInstall } from '@/store/install.js'
import { get_organization, get_team, get_version } from '@/helpers/cache.js'
import { install } from '@/store/install.js'
import type { ContentItem } from '../../../../../../packages/ui/src/components/instances/types'
const props = defineProps<{
project: Labrinth.Projects.v2.Project
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<any>(null)
const project = ref<any>(null)
const requiredContentProject = ref<any>(null)
const organization = ref<any>(null)
const teamMembers = ref<any[]>([])
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const installStore = useInstall()
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
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(() => {
@@ -121,7 +105,7 @@ const sharedBy = computed(() => {
icon_url: organization.value.icon_url,
}
}
if (teamMembers.value?.length) {
if (teamMembers.value) {
const owner = teamMembers.value.find((member: { is_owner: boolean }) => member.is_owner)
if (owner) {
return {
@@ -134,39 +118,26 @@ const sharedBy = computed(() => {
})
const loaderDisplay = computed(() => {
const loader = requiredContentProject.value?.loaders?.[0]
const loader = props.project.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
modpackVersion.value = await get_version(versionId, 'bypass')
if (modpackVersion.value?.project_id) {
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
}
if (project.value?.organization) {
organization.value = await get_organization(project.value.organization, 'bypass')
} else if (project.value?.team_id) {
teamMembers.value = await get_team(project.value.team_id, 'bypass')
}
}
// 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)
async function handleAccept() {
hide()
const serverProjectId = project.value.id
installStore.startInstallingServer(serverProjectId)
try {
await installServerProject(serverProjectId)
onInstallComplete.value()
await install(props.project.id, null, null, 'ProjectPageInstallToPlayModal')
} catch (error) {
console.error('Failed to install server project from InstallToPlayModal:', error)
} finally {
installStore.stopInstallingServer(serverProjectId)
console.error('Failed to install project from InstallToPlayModal:', error)
}
}
@@ -174,91 +145,12 @@ function handleDecline() {
hide()
}
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
modpackContentModal.value?.showLoading()
try {
// Ensure version data is available — the useQuery may not have resolved yet
const versionId = modpackVersionId.value
const version =
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
const deps = version?.dependencies ?? []
const projectIds = deps
.map((d: { project_id?: string }) => d.project_id)
.filter((id: string | undefined): id is string => !!id)
const versionIds = deps
.map((d: { version_id?: string }) => d.version_id)
.filter((id: string | undefined): id is string => !!id)
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const versions: Labrinth.Versions.v2.Version[] =
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((p: any) => [p.id, p]))
const contentItems: ContentItem[] = deps.map((dep: any): ContentItem => {
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
const depVersion = dep.version_id ? versions.find((v: any) => v.id === dep.version_id) : null
return {
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
project_type: depProject?.project_type ?? 'mod',
has_update: false,
update_version_id: null,
project: {
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
title: depProject?.title ?? dep.file_name ?? 'Unknown',
icon_url: depProject?.icon_url ?? null,
},
...(depVersion
? {
version: {
id: depVersion.id,
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
version_number: depVersion.version_number ?? undefined,
date_published: depVersion.date_published ?? undefined,
},
}
: {}),
}
})
modpackContentModal.value?.show(contentItems)
} catch (err) {
console.error('Failed to load modpack contents:', err)
modpackContentModal.value?.show([])
}
}
async function show(
projectVal: Labrinth.Projects.v3.Project,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
project.value = projectVal
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
organization.value = null
teamMembers.value = []
onInstallComplete.value = callback
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
hide_ads_window()
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
show_ads_window()
}
const messages = defineMessages({
@@ -270,18 +162,14 @@ 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, then launch directly into the server.',
'This server requires mods to play. Click install to set up the required files from Modrinth.',
},
requiredModpack: {
id: 'app.modal.install-to-play.required-modpack',
defaultMessage: 'Required modpack',
sharedByToday: {
id: 'app.modal.install-to-play.shared-by-today',
defaultMessage: '{name} shared this instance with you today.',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
@@ -295,10 +183,6 @@ const messages = defineMessages({
id: 'app.modal.install-to-play.install-button',
defaultMessage: 'Install',
},
viewContents: {
id: 'app.modal.install-to-play.view-contents',
defaultMessage: 'View contents',
},
})
defineExpose({ show, hide })
@@ -16,7 +16,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref, watch } from 'vue'
import { ref } from 'vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
@@ -24,7 +24,6 @@ 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'
@@ -32,26 +31,7 @@ const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const isMinecraftServer = ref(false)
watch(
() => props.instance,
(instance) => {
isMinecraftServer.value = false
if (instance.linked_data?.project_id) {
get_project_v3(instance.linked_data.project_id, 'must_revalidate')
.then((project: any) => {
if (project?.minecraft_server != null) {
isMinecraftServer.value = true
}
})
.catch(() => {})
}
},
{ immediate: true },
)
const tabs = computed<TabbedModalTab<InstanceSettingsTabProps>[]>(() => [
const tabs: TabbedModalTab<InstanceSettingsTabProps>[] = [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
@@ -92,7 +72,7 @@ const tabs = computed<TabbedModalTab<InstanceSettingsTabProps>[]>(() => [
icon: CodeIcon,
content: HooksSettings,
},
])
]
const modal = ref()
@@ -118,10 +98,6 @@ defineExpose({ show })
</span>
</template>
<TabbedModal
:tabs="
tabs.map((tab) => ({ ...tab, props: { ...props, isMinecraftServer: isMinecraftServer } }))
"
/>
<TabbedModal :tabs="tabs.map((tab) => ({ ...tab, props }))" />
</ModalWrapper>
</template>
@@ -1,14 +1,8 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.updateToPlay)"
:closable="true"
no-padding
@hide="() => show_ads_window()"
>
<div v-if="instance" class="max-w-[500px]">
<NewModal ref="modal" :header="formatMessage(messages.updateToPlay)" :closable="true" no-padding>
<div class="max-w-[500px]">
<div class="flex flex-col gap-4 p-4">
<Admonition type="info" :header="formatMessage(messages.updateRequired)">
<Admonition type="warning" :header="formatMessage(messages.updateRequired)">
{{ formatMessage(messages.updateRequiredDescription, { name: instance.name }) }}
</Admonition>
@@ -32,25 +26,18 @@
</div>
</div>
</div>
<div
v-if="diffs.length"
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
>
<div v-if="diffs.length" class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto">
<div
v-for="(diff, index) in diffs"
v-for="diff in diffs"
:key="diff.project_id"
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]'"
class="grid grid-cols-[auto_1fr_1fr_1fr] items-center min-h-10 h-10 gap-2"
>
<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="index === diffs.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
class="w-[1px] h-2 relative top-1"
></div>
<div class="bg-surface-5 w-[1px] h-2 relative top-1"></div>
</div>
<div class="flex gap-1 col-span-2">
@@ -62,28 +49,14 @@
>
{{ 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="
diff.project?.title &&
(getFilename(diff.newVersion) || getFilename(diff.currentVersion) || diff.fileName)
"
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion) || decodeURIComponent(diff.fileName || '')""
v-if="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
v-tooltip="getFilename(diff.newVersion) || getFilename(diff.currentVersion)"
class="text-xs truncate text-right"
>
{{
getFilename(diff.newVersion) ||
getFilename(diff.currentVersion) ||
decodeURIComponent(diff.fileName || '')
}}
{{ getFilename(diff.newVersion) || getFilename(diff.currentVersion) }}
</span>
</div>
</div>
@@ -138,11 +111,9 @@ import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { get_project, get_project_many, 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
@@ -158,7 +129,6 @@ interface BaseDiff {
newVersionId?: string
currentVersion?: Version
newVersion?: Version
fileName?: string
}
interface AddedDiff extends BaseDiff {
type: 'added'
@@ -181,21 +151,22 @@ 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 modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const latestVersionId = ref<string | null>(null)
const latestVersion = 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(() =>
modpackVersion.value?.date_published ? new Date(modpackVersion.value.date_published) : null,
latestVersion.value?.date_published ? new Date(latestVersion.value.date_published) : null,
)
function getFilename(version?: Version): string | undefined {
@@ -206,27 +177,18 @@ 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>(
currentWithProject.map((d) => [d.project_id!, d]),
currentDeps.map((d) => [d.project_id || '', d]),
)
const latestByProject = new Map<string, Dependency>(
latestWithProject.map((d) => [d.project_id!, d]),
latestDeps.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 (by project_id)
// Find added and updated dependencies
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 })
@@ -244,8 +206,9 @@ async function computeDependencyDiffs(
}
})
// Find removed dependencies (by project_id)
// Find removed dependencies
currentByProject.forEach((currentDep, projectId) => {
if (!projectId) return
if (!latestByProject.has(projectId)) {
diffs.push({
type: 'removed',
@@ -255,19 +218,6 @@ 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 = [
@@ -278,14 +228,14 @@ async function computeDependencyDiffs(
),
] as string[]
const [projects, versions] = await Promise.all([
get_project_many(allProjectIds, 'bypass'),
get_version_many(allVersionIds, 'bypass'),
get_project_many(allProjectIds, 'must_revalidate'),
get_version_many(allVersionIds, 'must_revalidate'),
])
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]))
const mappedDiffs = diffs
return diffs
.map((diff) => {
const project = projectMap.get(diff.project_id)
return {
@@ -305,25 +255,35 @@ async function computeDependencyDiffs(
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
}).filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
return mappedDiffs
})
}
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
if (!inst.linked_data) return null
async function checkUpdateAvailable(instance: GameInstance): Promise<DependencyDiff[] | null> {
if (!instance.linked_data) return null
try {
// 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')
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)
// Compute dependency diffs between current and latest version
if (instanceModpackVersion && modpackVersion.value) {
if (currentVersion && latestVersion.value) {
return await computeDependencyDiffs(
modpackVersion.value.dependencies || [],
instanceModpackVersion.dependencies || [],
currentVersion.dependencies || [],
latestVersion.value.dependencies || [],
)
}
} catch (error) {
@@ -334,10 +294,9 @@ async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[
}
watch(
() => instance.value,
async (newInstance) => {
if (!newInstance) return
const result = await checkUpdateAvailable(newInstance)
() => instance,
async () => {
const result = await checkUpdateAvailable(instance)
diffs.value = result || []
},
{ immediate: true, deep: true },
@@ -345,25 +304,18 @@ watch(
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.linked_data?.project_id
if (serverProjectId) installStore.startInstallingServer(serverProjectId)
try {
if (modpackVersionId.value && instance.value) {
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
onUpdateComplete.value()
if (latestVersionId.value) {
await update_managed_modrinth_version(instance.path, latestVersionId.value)
}
} catch (error) {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) installStore.stopInstallingServer(serverProjectId)
}
}
function handleReport() {
if (instance.value?.linked_data?.project_id) {
openUrl(
`https://modrinth.com/report?item=project&itemID=${instance.value.linked_data.project_id}`,
)
if (instance.linked_data?.project_id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.linked_data.project_id}`)
}
}
@@ -371,16 +323,7 @@ function handleDecline() {
hide()
}
function show(
instanceVal: GameInstance,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
onUpdateComplete.value = callback
hide_ads_window()
function show(e?: MouseEvent) {
modal.value?.show(e)
}
@@ -435,13 +378,5 @@ const diffTypeMessages = defineMessages({
},
})
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 })
defineExpose({ show, hide })
</script>
@@ -44,7 +44,7 @@ import type {
SingleplayerWorld,
World,
} from '@/helpers/worlds.ts'
import { getWorldIdentifier, isLinkedWorld, set_world_display_status } from '@/helpers/worlds.ts'
import { getWorldIdentifier, set_world_display_status } from '@/helpers/worlds.ts'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
@@ -117,7 +117,6 @@ const serverIncompatible = computed(
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const linked = computed(() => isLinkedWorld(props.world))
const messages = defineMessages({
hardcore: {
@@ -172,10 +171,6 @@ 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>
@@ -401,12 +396,8 @@ const messages = defineMessages({
id: 'edit',
action: () => emit('edit'),
shown: !instancePath,
disabled: locked || linked,
tooltip: locked
? formatMessage(messages.worldInUse)
: linked
? formatMessage(messages.linkedServer)
: undefined,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
{
id: 'open-folder',
@@ -441,12 +432,8 @@ const messages = defineMessages({
hoverFilled: true,
action: () => emit('delete'),
shown: !instancePath,
disabled: locked || linked,
tooltip: locked
? formatMessage(messages.worldInUse)
: linked
? formatMessage(messages.linkedServer)
: undefined,
disabled: locked,
tooltip: locked ? formatMessage(messages.worldInUse) : undefined,
},
]"
>
-16
View File
@@ -8,14 +8,6 @@ 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 })
}
@@ -56,14 +48,6 @@ 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 })
}
+3 -4
View File
@@ -18,7 +18,7 @@ import { install_to_existing_profile } from '@/helpers/pack.js'
- icon is a path to an image file, which will be copied into the profile directory
*/
export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall, linkedData) {
export async function create(name, gameVersion, modloader, loaderVersion, icon, skipInstall) {
//Trim string name to avoid "Unable to find directory"
name = name.trim()
return await invoke('plugin:profile-create|profile_create', {
@@ -28,7 +28,6 @@ export async function create(name, gameVersion, modloader, loaderVersion, icon,
loaderVersion,
icon,
skipInstall,
linkedData,
})
}
@@ -175,8 +174,8 @@ export async function get_pack_export_candidates(profilePath) {
// Run Minecraft using a pathed profile
// Returns PID of child
export async function run(path, serverAddress = null) {
return await invoke('plugin:profile|profile_run', { path, serverAddress })
export async function run(path) {
return await invoke('plugin:profile|profile_run', { path })
}
export async function kill(path) {
-1
View File
@@ -139,5 +139,4 @@ type AppSettings = {
export type InstanceSettingsTabProps = {
instance: GameInstance
offline?: boolean
isMinecraftServer?: boolean
}
+1 -13
View File
@@ -30,7 +30,6 @@ export type ServerWorld = BaseWorld & {
index: number
address: string
pack_status: ServerPackStatus
linked_project_id?: string
}
export type World = SingleplayerWorld | ServerWorld
@@ -141,15 +140,8 @@ 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,
linkedProjectId,
})
return await invoke('plugin:worlds|add_server_to_profile', { path, name, address, packStatus })
}
export async function edit_server_in_profile(
@@ -220,10 +212,6 @@ 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,
+20 -26
View File
@@ -5,9 +5,6 @@
"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"
},
@@ -17,11 +14,11 @@
"app.modal.install-to-play.mod-count": {
"message": "{count, plural, one {# mod} other {# mods}}"
},
"app.modal.install-to-play.required-modpack": {
"message": "Required modpack"
},
"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."
"message": "This server requires mods to play. Click install to set up the required files from Modrinth."
},
"app.modal.install-to-play.shared-by-today": {
"message": "{name} shared this instance with you today."
},
"app.modal.install-to-play.shared-instance": {
"message": "Shared instance"
@@ -29,9 +26,6 @@
"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"
},
@@ -89,33 +83,39 @@
"app.settings.tabs.resource-management": {
"message": "Resource management"
},
"app.update-popup.body": {
"app.update-toast.body": {
"message": "Modrinth App v{version} is ready to install! Reload to update now, or automatically when you close Modrinth App."
},
"app.update-popup.body.download-complete": {
"app.update-toast.body.download-complete": {
"message": "Modrinth App v{version} has finished downloading. Reload to update now, or automatically when you close Modrinth App."
},
"app.update-popup.body.linux": {
"app.update-toast.body.linux": {
"message": "Modrinth App v{version} is available. Use your package manager to update for the latest features and fixes!"
},
"app.update-popup.body.metered": {
"app.update-toast.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-popup.changelog": {
"app.update-toast.changelog": {
"message": "Changelog"
},
"app.update-popup.download": {
"app.update-toast.download": {
"message": "Download ({size})"
},
"app.update-popup.download-complete": {
"message": "Download complete"
"app.update-toast.download-page": {
"message": "Download"
},
"app.update-popup.reload": {
"app.update-toast.downloading": {
"message": "Downloading..."
},
"app.update-toast.reload": {
"message": "Reload"
},
"app.update-popup.title": {
"app.update-toast.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,12 +464,6 @@
"instance.settings.tabs.installation.unknown-version": {
"message": "(unknown version)"
},
"instance.settings.tabs.installation.unlink-server.description": {
"message": "This instance is linked to a server, which means mods can't be updated and you can't change the mod loader or Minecraft version. Unlinking will permanently disconnect this instance from the server."
},
"instance.settings.tabs.installation.unlink-server.title": {
"message": "Unlink from server"
},
"instance.settings.tabs.installation.unlink.button": {
"message": "Unlink instance"
},
+144 -426
View File
@@ -1,35 +1,23 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, ExternalIcon, GlobeIcon, SearchIcon } from '@modrinth/assets'
import type { Category, GameVersion, Platform, ProjectType, SortType, Tags } from '@modrinth/ui'
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, onUnmounted, ref, shallowRef, toRaw, watch } from 'vue'
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
@@ -38,23 +26,13 @@ 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, 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_search_results } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } from '@/helpers/profile.js'
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
import { get_server_status } from '@/helpers/worlds'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { getServerAddress, playServerProject, useInstall } from '@/store/install.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const installStore = useInstall()
const router = useRouter()
const route = useRoute()
@@ -64,21 +42,15 @@ const projectTypes = computed(() => {
})
const [categories, loaders, availableGameVersions] = await Promise.all([
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[]>),
get_categories().catch(handleError).then(ref),
get_loaders().catch(handleError).then(ref),
get_game_versions().catch(handleError).then(ref),
])
const tags: Ref<Tags> = computed(() => ({
gameVersions: availableGameVersions.value ?? [],
loaders: loaders.value ?? [],
categories: categories.value ?? [],
gameVersions: availableGameVersions.value as GameVersion[],
loaders: loaders.value as Platform[],
categories: categories.value as Category[],
}))
type Instance = {
@@ -99,18 +71,15 @@ type InstanceProject = {
const instance: Ref<Instance | null> = ref(null)
const instanceProjects: Ref<InstanceProject[] | null> = ref(null)
const instanceHideInstalled = ref(false)
const newlyInstalled = ref<string[]>([])
const newlyInstalled = ref([])
const PERSISTENT_QUERY_PARAMS = ['i', 'ai']
await updateInstanceContext()
watch(
() => [route.query.i, route.query.ai, route.path],
() => {
updateInstanceContext()
},
)
watch(route, () => {
updateInstanceContext()
})
async function updateInstanceContext() {
if (route.query.i) {
@@ -195,86 +164,7 @@ const {
createPageParams,
} = useSearch(projectTypes, tags, instanceFilters)
const serverHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
const serverPings = shallowRef<Record<string, number | undefined>>({})
const runningServerProjects = ref<Record<string, string>>({})
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
const packs = await listInstances()
const newRunning: Record<string, string> = {}
for (const hit of hits) {
const inst = packs.find((p: any) => p.linked_data?.project_id === hit.project_id)
if (inst) {
const processes = await get_by_profile_path(inst.path).catch(() => [])
if (Array.isArray(processes) && processes.length > 0) {
newRunning[hit.project_id] = inst.path
}
}
}
runningServerProjects.value = newRunning
}
async function handleStopServerProject(projectId: string) {
const instancePath = runningServerProjects.value[projectId]
if (!instancePath) return
await kill(instancePath).catch(() => {})
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
async function handlePlayServerProject(projectId: string) {
await playServerProject(projectId)
checkServerRunningStates(serverHits.value)
}
function handleAddServerToInstance(project: Labrinth.Search.v3.ResultSearchProject) {
const address = getServerAddress(project.minecraft_java_server)
if (!address) return
installStore.showAddServerToInstanceModal(project.name, address)
}
const unlistenProcesses = await process_listener((e: any) => {
if (e.event === 'finished') {
const projectId = Object.entries(runningServerProjects.value).find(
([, path]) => path === e.profile_path_id,
)?.[0]
if (projectId) {
const { [projectId]: _, ...rest } = runningServerProjects.value
runningServerProjects.value = rest
}
}
})
onUnmounted(() => {
unlistenProcesses()
})
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage })
async function pingServerHits(hits: Labrinth.Search.v3.ResultSearchProject[]) {
for (const hit of hits) {
const address = hit.minecraft_java_server?.address
if (!address) continue
get_server_status(address)
.then((status) => {
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
})
.catch((err) => {
console.error(`Failed to ping server ${address}:`, err)
})
}
}
const previousFilterState = ref('')
const isRefreshing = ref(false)
const offline = ref(!navigator.onLine)
window.addEventListener('offline', () => {
@@ -289,14 +179,20 @@ breadcrumbs.setContext({ name: 'Discover content', link: route.path, query: rout
const loading = ref(true)
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
const projectType = ref(route.params.projectType)
watch(projectType, () => {
loading.value = true
})
interface SearchResults extends Labrinth.Search.v2.SearchResults {
hits: (Labrinth.Search.v2.ResultSearchProject & { installed?: boolean })[]
type SearchResult = {
project_id: string
}
type SearchResults = {
total_hits: number
limit: number
hits: SearchResult[]
}
const results: Ref<SearchResults | null> = shallowRef(null)
@@ -304,125 +200,73 @@ const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
const effectiveRequestParams = computed(() => {
return projectType.value === 'server' ? serverRequestParams.value : requestParams.value
})
watch(effectiveRequestParams, async () => {
watch(requestParams, () => {
if (!route.params.projectType) return
await nextTick()
refreshSearch()
})
async function refreshSearch() {
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 = {
let rawResults = await get_search_results(requestParams.value)
if (!rawResults) {
rawResults = {
result: {
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) {
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
total_hits: 0,
limit: 1,
},
}
const currentFilterState = JSON.stringify({
query: query.value,
filters: toRaw(currentFilters.value),
sort: toRaw(currentSortType.value),
maxResults: maxResults.value,
projectTypes: toRaw(projectTypes.value),
})
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
currentPage.value = 1
}
previousFilterState.value = currentFilterState
const persistentParams: LocationQuery = {}
for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value
}
}
if (instanceHideInstalled.value) {
persistentParams.ai = 'true'
} else {
delete persistentParams.ai
}
const params = {
...persistentParams,
...(isServer ? createServerPageParams() : createPageParams()),
}
breadcrumbs.setContext({
name: 'Discover content',
link: `/browse/${projectType.value}`,
query: params,
})
const queryString = Object.entries(params)
.flatMap(([key, value]) => {
const values = Array.isArray(value) ? value : [value]
return values
.filter((v): v is string => v != null)
.map((v) => `${encodeURIComponent(key)}=${encodeURIComponent(v)}`)
})
.join('&')
const newUrl = `${route.path}${queryString ? '?' + queryString : ''}`
window.history.replaceState(window.history.state, '', newUrl)
} catch (err) {
console.error('Error refreshing search:', err)
} finally {
loading.value = false
isRefreshing.value = false
}
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,
)
}
}
results.value = rawResults.result
const currentFilterState = JSON.stringify({
query: query.value,
filters: currentFilters.value,
sort: currentSortType.value,
maxResults: maxResults.value,
projectTypes: projectTypes.value,
})
if (previousFilterState.value && previousFilterState.value !== currentFilterState) {
currentPage.value = 1
}
previousFilterState.value = currentFilterState
const persistentParams: LocationQuery = {}
for (const [key, value] of Object.entries(route.query)) {
if (PERSISTENT_QUERY_PARAMS.includes(key)) {
persistentParams[key] = value
}
}
if (instanceHideInstalled.value) {
persistentParams.ai = 'true'
} else {
delete persistentParams.ai
}
const params = {
...persistentParams,
...createPageParams(),
}
breadcrumbs.setContext({
name: 'Discover content',
link: `/browse/${projectType.value}`,
query: params,
})
await router.replace({ path: route.path, query: params })
loading.value = false
}
async function setPage(newPageNumber: number) {
@@ -445,7 +289,7 @@ function clearSearch() {
}
watch(
() => route.params.projectType as ProjectType,
() => route.params.projectType,
async (newType) => {
// Check if the newType is not the same as the current value
if (!newType || newType === projectType.value) return
@@ -464,9 +308,8 @@ const selectableProjectTypes = computed(() => {
if (instance.value) {
if (
availableGameVersions.value &&
availableGameVersions.value.findIndex((x) => x.version === instance.value?.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
availableGameVersions.value.findIndex((x) => x.version === instance.value.game_version) <=
availableGameVersions.value.findIndex((x) => x.version === '1.13')
) {
dataPacks = true
}
@@ -495,7 +338,6 @@ 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) {
@@ -532,27 +374,9 @@ const messages = defineMessages({
},
})
const getServerModpackContent = (project: Labrinth.Search.v3.ResultSearchProject) => {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick: () => {
router.push(`/project/${project_id}`)
},
showCustomModpackTooltip: project_id === project.project_id,
}
}
return undefined
}
const options = ref(null)
const handleRightClick = (event: any, result: any) => {
// @ts-ignore
options.value?.showMenu(event, result, [
const handleRightClick = (event, result) => {
options.value.showMenu(event, result, [
{
name: 'open_link',
},
@@ -561,7 +385,7 @@ const handleRightClick = (event: any, result: any) => {
},
])
}
const handleOptionsClick = (args: any) => {
const handleOptionsClick = (args) => {
switch (args.option) {
case 'open_link':
openUrl(`https://modrinth.com/${args.item.project_type}/${args.item.slug}`)
@@ -600,54 +424,33 @@ 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="true"
>
<template #header>
<h3 class="text-base m-0">{{ filterType.formatted_name }}</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="instanceFilters"
:filter-type="filter"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3"
inner-panel-class="ml-2 mr-3"
:open-by-default="
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
"
>
<template #header>
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
</SearchSidebarFilter>
</template>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="instanceFilters"
:filter-type="filter"
class="border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid"
button-class="button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg"
content-class="mb-3"
inner-panel-class="ml-2 mr-3"
:open-by-default="
filter.id.startsWith('category') || filter.id === 'environment' || filter.id === 'license'
"
>
<template #header>
<h3 class="text-base m-0">{{ filter.formatted_name }}</h3>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByInstance) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByInstance) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }} </template>
</SearchSidebarFilter>
</Teleport>
<div ref="searchWrapper" class="flex flex-col gap-3 p-6">
<template v-if="instance">
@@ -669,17 +472,11 @@ previousFilterState.value = JSON.stringify({
<div class="flex gap-2">
<DropdownSelect
v-slot="{ selected }"
:model-value="projectType === 'server' ? serverCurrentSortType : currentSortType"
v-model="currentSortType"
class="max-w-[16rem]"
name="Sort by"
:options="(projectType === 'server' ? serverSortTypes : sortTypes) as any"
:options="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>
@@ -697,14 +494,6 @@ 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"
@@ -715,107 +504,36 @@ previousFilterState.value = JSON.stringify({
<section v-if="loading" class="offline">
<LoadingIndicator />
</section>
<section v-else-if="offline && results?.total_hits === 0" class="offline">
<section v-else-if="offline && results.total_hits === 0" class="offline">
You are currently offline. Connect to the internet to browse Modrinth!
</section>
<section
v-else-if="
projectType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="offline"
>
No results found for your query!
</section>
<ProjectCardList v-else :layout="'list'">
<template v-if="projectType === 'server'">
<ProjectCard
v-for="project in serverHits"
:key="`server-card-${project.project_id}`"
:title="project.name"
:icon-url="project.icon_url || undefined"
:summary="project.summary"
:tags="project.categories"
:link="`/project/${project.slug ?? project.project_id}`"
:server-online-players="project.minecraft_java_server?.ping?.data?.players_online ?? 0"
:server-region-code="project.minecraft_server?.country"
:server-recent-plays="project.minecraft_java_server?.verified_plays_4w ?? 0"
:server-modpack-content="getServerModpackContent(project)"
:server-ping="serverPings[project.project_id]"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:hide-online-players-label="true"
:hide-recent-plays-label="true"
layout="list"
:max-tags="2"
is-server-project
exclude-loaders
@contextmenu.prevent.stop="
(event: any) =>
handleRightClick(event, { project_type: 'server', slug: project.slug })
"
>
<template #actions>
<div class="flex gap-2">
<ButtonStyled circular>
<button
v-tooltip="'Add server to instance'"
@click.stop="() => handleAddServerToInstance(project)"
>
<PlusIcon />
</button>
</ButtonStyled>
<ButtonStyled
v-if="runningServerProjects[project.project_id]"
color="red"
type="outlined"
>
<button @click="() => handleStopServerProject(project.project_id)">
<StopCircleIcon />
Stop
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" type="outlined">
<button
:disabled="
(installStore.installingServerProjects as string[]).includes(
project.project_id,
)
"
@click="() => handlePlayServerProject(project.project_id)"
>
<PlayIcon />
{{
(installStore.installingServerProjects as string[]).includes(
project.project_id,
)
? 'Installing...'
: 'Play'
}}
</button>
</ButtonStyled>
</div>
</template>
</ProjectCard>
</template>
<template v-else>
<SearchCard
v-for="result in results?.hits ?? []"
:key="result?.project_id"
:project-type="projectType"
:project="result"
:instance="instance ?? undefined"
:installed="result.installed || newlyInstalled.includes(result.project_id || '')"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event: any) => handleRightClick(event, result)"
/>
</template>
<SearchCard
v-for="result in results.hits"
:key="result?.project_id"
:project-type="projectType"
:project="result"
:instance="instance"
:categories="[
...categories.filter(
(cat) =>
result?.display_categories.includes(cat.name) && cat.project_type === projectType,
),
...loaders.filter(
(loader) =>
result?.display_categories.includes(loader.name) &&
loader.supported_project_types?.includes(projectType),
),
]"
:installed="result.installed || newlyInstalled.includes(result.project_id)"
@install="
(id) => {
newlyInstalled.push(id)
}
"
@contextmenu.prevent.stop="(event) => handleRightClick(event, result)"
/>
<ContextMenu ref="options" @option-clicked="handleOptionsClick">
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
+100 -291
View File
@@ -1,95 +1,36 @@
<template>
<div v-if="instance">
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
<div>
<div
class="p-6 pr-2 pb-4"
@contextmenu.prevent.stop="(event) => handleRightClick(event, instance.path)"
>
<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 ? icon : undefined"
:alt="instance.name"
size="64px"
:tint-by="instance.path"
/>
<Avatar :src="icon" :alt="instance.name" size="96px" :tint-by="instance.path" />
</template>
<template #title>
{{ instance.name }}
</template>
<template #summary> </template>
<template #stats>
<div class="flex items-center flex-wrap gap-2">
<template v-if="!isServerInstance">
<div class="flex items-center gap-2 capitalize font-medium">
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex items-center gap-2 font-medium">
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
<div v-if="linkedProjectV3" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div
v-if="linkedProjectV3"
class="flex gap-1.5 items-center font-medium text-primary"
>
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
</template>
<template v-else>
<ServerOnlinePlayers :online="playersOnline ?? 0" :status-online="statusOnline" />
<div
v-if="playersOnline !== undefined && (minecraftServer?.country || ping)"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerRegion v-if="minecraftServer?.country" :region="minecraftServer?.country" />
<ServerPing v-if="ping" :ping="ping" />
<div
v-if="modpackContentProjectV3 && (minecraftServer?.country || ping)"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<div
v-if="linkedProjectV3"
class="flex gap-1.5 items-center font-medium text-primary"
>
Linked to
<Avatar
:src="linkedProjectV3.icon_url"
:alt="linkedProjectV3.name"
:tint-by="instance.path"
size="24px"
/>
<router-link
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
class="hover:underline text-primary"
>
{{ linkedProjectV3.name }}
</router-link>
</div>
<div
class="flex items-center gap-2 font-semibold transform capitalize border-0 border-solid border-divider pr-4 md:border-r"
>
<GameIcon class="h-6 w-6 text-secondary" />
{{ instance.loader }} {{ instance.game_version }}
</div>
<div class="flex items-center gap-2 font-semibold">
<TimerIcon class="h-6 w-6 text-secondary" />
<template v-if="timePlayed > 0">
{{ timePlayedHumanized }}
</template>
<template v-else> Never played </template>
</div>
</template>
<template #actions>
@@ -122,7 +63,7 @@
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="playing === false && loading === false && !isServerInstance"
v-else-if="playing === false && loading === false"
color="brand"
size="large"
>
@@ -131,44 +72,6 @@
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"
@@ -176,23 +79,21 @@
>
<button disabled>Loading...</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
<ButtonStyled size="large" circular>
<button v-tooltip="'Instance settings'" @click="settingsModal.show()">
<SettingsIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular size="large">
<ButtonStyled size="large" type="transparent" circular>
<OverflowMenu
:options="[
{
id: 'open-folder',
action: () => {
if (instance) showProfileInFolder(instance.path)
},
action: () => showProfileInFolder(instance.path),
},
{
id: 'export-mrpack',
action: () => exportModal?.show(),
action: () => $refs.exportModal.show(),
},
]"
>
@@ -211,11 +112,7 @@
<NavTabs :links="tabs" />
</div>
<div v-if="!!instance" class="p-6 pt-4">
<RouterView
v-if="route.path.startsWith('/instance')"
v-slot="{ Component }"
:key="instance.path"
>
<RouterView v-slot="{ Component }" :key="instance.path">
<template v-if="Component">
<Suspense
:key="instance.path"
@@ -230,7 +127,6 @@
:playing="playing"
:versions="modrinthVersions"
:installed="instance.install_stage !== 'installed'"
:is-server-instance="isServerInstance"
@play="updatePlayState"
@stop="() => stopInstance('InstanceSubpage')"
></component>
@@ -264,17 +160,16 @@
</ContextMenu>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
<script setup>
import {
CheckCircleIcon,
ClipboardCopyIcon,
DownloadIcon,
DropdownIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GameIcon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
@@ -284,6 +179,7 @@ import {
ServerIcon,
SettingsIcon,
StopCircleIcon,
TimerIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
@@ -295,9 +191,6 @@ import {
injectNotificationManager,
LoadingIndicator,
OverflowMenu,
ServerOnlinePlayers,
ServerPing,
ServerRegion,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import dayjs from 'dayjs'
@@ -312,21 +205,20 @@ 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_v3, get_version, get_version_many } from '@/helpers/cache.js'
import { get_project, 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, get_projects, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
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()
@@ -340,93 +232,38 @@ window.addEventListener('online', () => {
offline.value = false
})
const instance = ref<GameInstance>()
const modrinthVersions = ref<any[]>([])
const instance = ref()
const modrinthVersions = ref([])
const playing = ref(false)
const loading = ref(false)
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)
const updateToPlayModal = ref()
async function fetchInstance() {
isServerInstance.value = false
linkedProjectV3.value = undefined
modpackContentProjectV3.value = null
modrinthVersions.value = []
hasContent.value = true
ping.value = undefined
instance.value = await get(route.params.id).catch(handleError)
instance.value = await get(route.params.id as string).catch(handleError)
if (!offline.value && instance.value?.linked_data && instance.value.linked_data.project_id) {
try {
linkedProjectV3.value = await get_project_v3(
instance.value.linked_data.project_id,
'must_revalidate',
)
if (linkedProjectV3.value && linkedProjectV3.value.versions) {
const versions = await get_version_many(linkedProjectV3.value.versions, 'must_revalidate')
modrinthVersions.value = versions.sort(
(a: any, b: any) => dayjs(b.date_published).valueOf() - dayjs(a.date_published).valueOf(),
)
if (linkedProjectV3.value?.minecraft_server != null) {
isServerInstance.value = true
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (serverAddress) {
get_server_status(serverAddress)
.then((status) => {
if (status.ping != null) {
ping.value = status.ping
playersOnline.value = status.players?.online
}
})
.catch((err) => {
console.error(`Failed to ping server ${serverAddress}:`, err)
})
}
await fetchModpackContent()
const projects = await get_projects(instance.value!.path).catch(() => ({}))
hasContent.value = Object.keys(projects).length > 0
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),
)
})
}
}
} catch (error: any) {
handleError(error)
}
})
}
await updatePlayState()
}
async function fetchModpackContent() {
modpackContentProjectV3.value = null
const versionId = instance.value?.linked_data?.version_id
if (!versionId) return
const contentVersion = await get_version(versionId, 'bypass')
const projectId = contentVersion?.project_id
if (projectId) {
modpackContentProjectV3.value = await get_project_v3(projectId, 'bypass')
}
}
async function updatePlayState() {
const runningProcesses = await get_by_profile_path(route.params.id as string).catch(handleError)
const runningProcesses = await get_by_profile_path(route.params.id).catch(handleError)
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
playing.value = runningProcesses.length > 0
}
await fetchInstance()
@@ -439,74 +276,61 @@ watch(
},
)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id)}`)
const tabs = computed(() => {
const allTabs = []
if (!isServerInstance.value || hasContent.value) {
allTabs.push({ label: 'Content', href: `${basePath.value}` })
}
allTabs.push({ label: 'Worlds', href: `${basePath.value}/worlds` })
allTabs.push({ label: 'Logs', href: `${basePath.value}/logs` })
return allTabs
})
watch(
[isServerInstance, hasContent],
() => {
if (isServerInstance.value && !hasContent.value && route.path === basePath.value) {
router.replace(`${basePath.value}/worlds`)
}
const tabs = computed(() => [
{
label: 'Content',
href: `${basePath.value}`,
},
{ immediate: true },
{
label: 'Worlds',
href: `${basePath.value}/worlds`,
},
{
label: 'Logs',
href: `${basePath.value}/logs`,
},
])
breadcrumbs.setName(
'Instance',
instance.value.name.length > 40
? instance.value.name.substring(0, 40) + '...'
: instance.value.name,
)
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,
})
}
breadcrumbs.setContext({
name: instance.value.name,
link: route.path,
query: route.query,
})
const loadingBar = useLoading()
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 options = ref(null)
const startInstance = async (context) => {
loading.value = true
try {
await run(route.params.id as string)
await run(route.params.id)
playing.value = true
} catch (err) {
handleSevereError(err, { profilePath: route.params.id as string })
handleSevereError(err, { profilePath: route.params.id })
}
loading.value = false
trackEvent('InstancePlay', {
trackEvent('InstanceStart', {
loader: instance.value.loader,
game_version: instance.value.game_version,
source: context,
})
}
const stopInstance = async (context: string) => {
const stopInstance = async (context) => {
playing.value = false
await kill(route.params.id as string).catch(handleError)
await kill(route.params.id).catch(handleError)
if (!instance.value) return
trackEvent('InstanceStop', {
loader: instance.value.loader,
game_version: instance.value.game_version,
@@ -514,22 +338,11 @@ const stopInstance = async (context: string) => {
})
}
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: MouseEvent) => {
const handleRightClick = (event) => {
const baseOptions = [
{ name: 'add_content' },
{ type: 'divider' },
@@ -538,7 +351,7 @@ const handleRightClick = (event: MouseEvent) => {
{ name: 'copy_path' },
]
options.value?.showMenu(
options.value.showMenu(
event,
instance.value,
playing.value
@@ -559,7 +372,7 @@ const handleRightClick = (event: MouseEvent) => {
)
}
const handleOptionsClick = async (args: { option: string; item: unknown }) => {
const handleOptionsClick = async (args) => {
switch (args.option) {
case 'play':
await startInstance('InstancePageContextMenu')
@@ -569,29 +382,27 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
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 as string)}/options`,
path: `/instance/${encodeURIComponent(route.params.id)}/options`,
})
break
case 'open_folder':
if (instance.value) await showProfileInFolder(instance.value.path)
await showProfileInFolder(instance.value.path)
break
case 'copy_path': {
if (instance.value) {
const fullPath = await get_full_path(instance.value?.path)
await navigator.clipboard.writeText(fullPath)
}
const fullPath = await get_full_path(instance.value.path)
await navigator.clipboard.writeText(fullPath)
break
}
}
}
const unlistenProfiles = await profile_listener(async (event: any) => {
const unlistenProfiles = await profile_listener(async (event) => {
if (event.profile_path_id === route.params.id) {
if (event.event === 'removed') {
await router.push({
@@ -599,26 +410,24 @@ const unlistenProfiles = await profile_listener(async (event: any) => {
})
return
}
instance.value = await get(route.params.id as string).catch(handleError)
instance.value = await get(route.params.id).catch(handleError)
}
})
const unlistenProcesses = await process_listener((e: any) => {
const unlistenProcesses = await process_listener((e) => {
if (e.event === 'finished' && e.profile_path_id === route.params.id) {
playing.value = false
}
})
const icon = computed(() =>
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : null,
)
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
const settingsModal = ref()
const timePlayed = computed(() => {
return instance.value
? instance.value.recent_time_played + instance.value.submitted_time_played
: 0
return instance.value.recent_time_played + instance.value.submitted_time_played
})
const timePlayedHumanized = computed(() => {
@@ -278,12 +278,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import type { ContentItem } from '@modrinth/ui/src/components/content/ContentListItem.vue'
import type {
Organization,
Project,
TeamMember,
Version
} from '@modrinth/utils'
import type { Organization, Project, TeamMember, Version } from '@modrinth/utils'
import { formatProjectType } from '@modrinth/utils'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { useStorage } from '@vueuse/core'
@@ -328,7 +323,6 @@ const props = defineProps<{
playing: boolean
versions: Version[]
installed: boolean
isServerInstance?: boolean
}>()
type ProjectListEntryAuthor = {
@@ -358,7 +352,6 @@ type ProjectListEntry = {
const isPackLocked = computed(() => {
return props.instance.linked_data && props.instance.linked_data.locked
})
const canUpdatePack = computed(() => {
if (!props.instance.linked_data || !props.versions || !props.versions[0]) return false
return props.instance.linked_data.version_id !== props.versions[0].id
@@ -80,13 +80,9 @@
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
isLinkedWorld(world)
? undefined
: world.type === 'server'
? editServerModal?.show(world)
: editWorldModal?.show(world)
world.type === 'server' ? editServerModal?.show(world) : editWorldModal?.show(world)
"
@delete="() => !isLinkedWorld(world) && promptToRemoveWorld(world)"
@delete="() => promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
@@ -154,7 +150,6 @@ import {
handleDefaultProfileUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
isLinkedWorld,
type ProfileEvent,
type ProtocolVersion,
refreshServerData,
@@ -171,7 +166,6 @@ import {
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds.ts'
import { playServerProject } from '@/store/install'
const { handleError } = injectNotificationManager()
const route = useRoute()
@@ -334,9 +328,6 @@ async function joinWorld(world: World) {
startingInstance.value = true
worldPlaying.value = world
if (world.type === 'server') {
if (isLinkedWorld(world)) {
playServerProject(world.linked_project_id)
}
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
} else if (world.type === 'singleplayer') {
await start_join_singleplayer_world(instance.value.path, world.path).catch(handleJoinError)
@@ -10,7 +10,7 @@ defineProps({
</script>
<template>
<GridDisplay
v-if="instances && instances.length > 0"
v-if="instances.length > 0"
label="Instances"
:instances="instances.filter((i) => !i.linked_data)"
/>
@@ -10,7 +10,7 @@ defineProps({
</script>
<template>
<GridDisplay
v-if="instances && instances.length > 0"
v-if="instances.length > 0"
label="Instances"
:instances="instances.filter((i) => i.linked_data)"
/>
@@ -47,7 +47,7 @@ onUnmounted(() => {
{ label: 'Saved', href: `/library/saved`, shown: false },
]"
/>
<template v-if="instances && instances.length > 0">
<template v-if="instances.length > 0">
<RouterView v-if="route.path.startsWith('/library')" :instances="instances" />
</template>
<div v-else class="no-instance">
@@ -9,5 +9,5 @@ defineProps({
})
</script>
<template>
<GridDisplay v-if="instances && instances.length > 0" label="Instances" :instances="instances" />
<GridDisplay v-if="instances.length > 0" label="Instances" :instances="instances" />
</template>
@@ -1,6 +1,6 @@
<template>
<div class="gallery">
<Card v-for="(image, index) in filteredGallery" :key="image.url" class="gallery-item">
<Card v-for="(image, index) in project.gallery" :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="filteredGallery.length > 1"
v-if="project.gallery.length > 1"
class="previous"
icon-only
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</Button>
<Button v-if="filteredGallery.length > 1" class="next" icon-only @click="nextImage()">
<Button v-if="project.gallery.length > 1" class="next" icon-only @click="nextImage()">
<RightArrowIcon aria-hidden="true" />
</Button>
</div>
@@ -92,13 +92,11 @@ import {
XIcon,
} from '@modrinth/assets'
import { Button, Card } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { 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,
@@ -106,10 +104,6 @@ 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)
@@ -121,10 +115,10 @@ const hideImage = () => {
const nextImage = () => {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
if (expandedGalleryIndex.value >= props.project.gallery.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
expandedGalleryItem.value = props.project.gallery[expandedGalleryIndex.value]
trackEvent('GalleryImageNext', {
project_id: props.project.id,
url: expandedGalleryItem.value.url,
@@ -134,9 +128,9 @@ const nextImage = () => {
const previousImage = () => {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = filteredGallery.value.length - 1
expandedGalleryIndex.value = props.project.gallery.length - 1
}
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
expandedGalleryItem.value = props.project.gallery[expandedGalleryIndex.value]
trackEvent('GalleryImagePrevious', {
project_id: props.project.id,
url: expandedGalleryItem.value,
+14 -229
View File
@@ -1,31 +1,13 @@
<template>
<div v-if="data">
<div>
<InstallToPlayModal ref="installToPlayModal" :project="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"
@@ -34,16 +16,17 @@
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
@@ -52,67 +35,7 @@
>
<ProjectBackgroundGradient :project="data" />
</Teleport>
<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">
<ProjectHeader :project="data" @contextmenu.prevent.stop="handleRightClick">
<template #actions>
<ButtonStyled size="large" color="brand">
<button
@@ -180,7 +103,6 @@
query: instanceFilters,
},
subpages: ['version'],
shown: projectV3?.minecraft_server == null,
},
{
label: 'Gallery',
@@ -190,7 +112,6 @@
]"
/>
<RouterView
v-if="route.path.startsWith('/project')"
:project="data"
:versions="versions"
:members="members"
@@ -221,10 +142,7 @@ import {
GlobeIcon,
HeartIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
StopCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
@@ -236,43 +154,22 @@ 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, onUnmounted, ref, shallowRef, watch } from 'vue'
import { computed, 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_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_project, get_team, get_version_many } from '@/helpers/cache.js'
import { get as getInstance, get_projects as getInstanceProjects } 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 {
getServerAddress,
install as installVersion,
playServerProject,
useInstall,
} from '@/store/install.js'
import { install as installVersion } from '@/store/install.js'
import { useTheming } from '@/store/state.js'
dayjs.extend(relativeTime)
@@ -283,7 +180,6 @@ const router = useRouter()
const breadcrumbs = useBreadcrumbs()
const themeStore = useTheming()
const installStore = useInstall()
const installing = ref(false)
const data = shallowRef(null)
const versions = shallowRef([])
@@ -294,16 +190,8 @@ const instanceProjects = ref(null)
const installed = ref(false)
const installedVersion = ref(null)
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 installToPlayModal = ref()
const instanceFilters = computed(() => {
if (!instance.value) {
@@ -328,41 +216,8 @@ 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')
@@ -390,81 +245,11 @@ async function fetchProjectData() {
installedVersion.value = installedFile.metadata.version_id
}
}
isServerProject.value = projectV3.value?.minecraft_server != null
// Ping server for latency
const serverAddress = projectV3.value?.minecraft_java_server?.address
serverStatusOnline.value = !!projectV3.value?.minecraft_java_server?.ping?.data
if (serverAddress) {
serverPing.value = undefined
get_server_status(serverAddress)
.then((status) => {
if (status.ping != null) {
serverPing.value = status.ping
}
})
.catch((err) => {
console.error(`Failed to ping server ${serverAddress}:`, err)
})
}
// Fetch server sidebar data (modpack version + project)
const content = projectV3.value?.minecraft_java_server?.content
if (content?.kind === 'modpack' && content.version_id) {
const modpackVersion = await get_version(content.version_id, 'bypass').catch(handleError)
if (modpackVersion) {
serverRecommendedVersion.value = modpackVersion.game_versions?.[0] ?? null
serverModpackLoaders.value = modpackVersion.mrpack_loaders ?? []
if (modpackVersion.project_id) {
const modpackProject = await get_project_v3(
modpackVersion.project_id,
'must_revalidate',
).catch(handleError)
if (modpackProject) {
serverRequiredContent.value = {
name: modpackProject.name,
versionNumber: modpackVersion.version_number ?? '',
icon: modpackProject.icon_url,
onclickName:
modpackProject.id !== project.id
? () => router.push(`/project/${modpackProject.id}`)
: undefined,
onclickVersion:
modpackProject.id !== project.id
? () => router.push(`/project/${modpackProject.id}/version/${modpackVersion.id}`)
: undefined,
}
}
}
}
} else if (content?.kind === 'vanilla') {
serverRecommendedVersion.value = content.recommended_game_version ?? null
const supported = content.supported_game_versions ?? []
serverSupportedVersions.value = supported.filter((v) => !!v)
}
breadcrumbs.setName('Project', data.value.title)
await updateServerPlayState()
}
await fetchProjectData()
const unlistenProcesses = await process_listener((e) => {
if (
e.event === 'finished' &&
serverInstancePath.value &&
e.profile_path_id === serverInstancePath.value
) {
serverPlaying.value = false
}
})
onUnmounted(() => {
unlistenProcesses()
})
watch(
() => route.params.id,
async () => {
@@ -1,30 +0,0 @@
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)
}
}
+3 -350
View File
@@ -2,42 +2,22 @@ import dayjs from 'dayjs'
import { defineStore } from 'pinia'
import { trackEvent } from '@/helpers/analytics'
import { get_project, get_project_v3, get_version, get_version_many } from '@/helpers/cache.js'
import {
install_to_existing_profile,
create_profile_and_install as packInstall,
} from '@/helpers/pack.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import {
add_project_from_version,
check_installed,
create,
edit,
edit_icon,
get,
get_projects,
install as installProfile,
list,
remove_project,
} from '@/helpers/profile.js'
import {
add_server_to_profile,
edit_server_in_profile,
get_profile_worlds,
start_join_server,
} from '@/helpers/worlds.ts'
import router from '@/routes.js'
import { handleSevereError } from '@/store/error.js'
export const useInstall = defineStore('installStore', {
state: () => ({
installConfirmModal: null,
modInstallModal: null,
incompatibilityWarningModal: null,
installToPlayModal: null,
updateToPlayModal: null,
popupNotificationManager: null,
installingServerProjects: [],
addServerToInstanceModal: null,
}),
actions: {
setInstallConfirmModal(ref) {
@@ -58,38 +38,6 @@ 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)
},
},
})
@@ -140,9 +88,8 @@ export const install = async (
createInstanceCallback = () => {},
) => {
const project = await get_project(projectId, 'must_revalidate')
const projectV3 = await get_project_v3(projectId, 'must_revalidate')
if (project.project_type === 'modpack' || projectV3?.minecraft_server != null) {
if (project.project_type === 'modpack') {
const version = versionId ?? project.versions[project.versions.length - 1]
const packs = await list()
@@ -273,297 +220,3 @@ export const installVersionDependencies = async (profile, version) => {
}
}
}
/**
* Server projects that use modpack content use have linked_data.project_id as
* the server project id and linked_data.version_id as the modpack content version id
*
* The modpack content version can be of the same server project, or from a different project
*/
export const installServerProject = async (serverProjectId) => {
const [project, projectV3] = await Promise.all([
get_project(serverProjectId, 'must_revalidate'),
get_project_v3(serverProjectId, 'must_revalidate'),
])
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const content = projectV3?.minecraft_java_server?.content
if (!content || content.kind !== 'modpack') return
const contentVersionId = content.version_id
const contentVersion = await get_version(contentVersionId, 'bypass')
const contentProjectId = contentVersion.project_id
const gameVersion = contentVersion.game_versions?.[0] ?? ''
const profilePath = await create(
project.title,
gameVersion,
'vanilla',
null,
project.icon_url,
true,
{
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
)
// Save the icon path before pack install overwrites it
const profileBeforeInstall = await get(profilePath)
const originalIconPath = profileBeforeInstall?.icon_path ?? null
await install_to_existing_profile(contentProjectId, contentVersionId, project.title, profilePath)
// Pack install overwrites name, icon, and linked_data with the content project's values.
// Restore them to point to the server project.
await edit(profilePath, {
name: project.title,
linked_data: {
project_id: serverProjectId,
version_id: contentVersionId,
locked: true,
},
})
await edit_icon(profilePath, originalIconPath)
await syncServerAsWorld(profilePath, project.title, serverAddress, serverProjectId)
}
export const getServerAddress = (javaServer) => {
if (!javaServer) return null
const { address, port } = javaServer
return port !== 25565 ? `${address}:${port}` : address
}
const syncServerAsWorld = async (
profilePath,
serverName,
serverAddress,
serverProjectId = null,
) => {
if (!profilePath || !serverAddress) return
try {
const worlds = await get_profile_worlds(profilePath)
if (serverProjectId) {
// Check if a linked world for this project already exists
const linkedWorld = worlds.find(
(w) => w.type === 'server' && w.linked_project_id === serverProjectId,
)
if (linkedWorld) {
// Sync linked world data with project details
if (linkedWorld.address !== serverAddress || linkedWorld.name !== serverName) {
await edit_server_in_profile(
profilePath,
linkedWorld.index,
serverName,
serverAddress,
linkedWorld.pack_status,
)
}
return
}
}
const alreadyExists = worlds.some((w) => w.type === 'server' && w.address === serverAddress)
if (!alreadyExists) {
await add_server_to_profile(
profilePath,
serverName,
serverAddress,
'prompt',
serverProjectId ?? undefined,
)
}
} catch (err) {
console.error('Failed to add server to instance worlds:', err)
}
}
const joinServer = async (profilePath, serverAddress, serverProjectId) => {
if (!serverAddress) return
await start_join_server(profilePath, serverAddress)
}
const findInstalledInstance = async (projectId) => {
const packs = await list()
return packs.find((pack) => pack.linked_data?.project_id === projectId) ?? null
}
const createVanillaInstance = async (project, gameVersion, serverAddress) => {
const profilePath = await create(
project.title,
gameVersion,
'fabric',
'latest',
project.icon_url,
false,
{
project_id: project.id,
version_id: '',
locked: true,
},
)
await syncServerAsWorld(profilePath, project.title, serverAddress, project.id)
return profilePath
}
const updateVanillaGameVersion = async (instance, targetGameVersion) => {
if (instance.game_version === targetGameVersion) return
await edit(instance.path, { game_version: targetGameVersion })
await installProfile(instance.path, false)
}
const showModpackInstallSuccess = (installStore, project, serverAddress) => {
installStore.popupNotificationManager?.addPopupNotification({
title: 'Install complete',
text: `${project.name} is installed and ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
await joinServer(
project.path,
serverAddress,
project.linked_data?.project_id ?? null,
)
} catch (err) {
handleSevereError(err, { profilePath: project.path })
}
},
color: 'brand',
},
]
: []),
{
label: 'Instance',
action: () => router.push(`/instance/${encodeURIComponent(project.path)}`),
},
],
autoCloseMs: null,
})
}
const showUpdateSuccess = (installStore, instance, serverAddress) => {
installStore.popupNotificationManager?.addPopupNotification({
title: 'Update complete',
text: `${instance.name} has been updated and is ready to play.`,
type: 'success',
buttons: [
...(serverAddress
? [
{
label: 'Launch game',
action: async () => {
try {
if (serverAddress) await start_join_server(instance.path, serverAddress)
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
},
color: 'brand',
},
]
: []),
{
label: 'Instance',
action: () => router.push(`/instance/${encodeURIComponent(instance.path)}`),
},
],
autoCloseMs: null,
})
}
/**
* Handles logic when clicking "Play" on a server project. This includes:
* - Checking if need to install modpack content. If so, opens install to play modal
* - Checking if need to update modpack content. If so, open update to play modal
* - Checking if need to create instance for vanilla server. If so, creates instance.
* - Adding server to worlds list if not already there
* - Joining server
*/
export const playServerProject = async (projectId) => {
const installStore = useInstall()
const [project, projectV3] = await Promise.all([
get_project(projectId, 'must_revalidate'),
get_project_v3(projectId, 'must_revalidate'),
])
if (projectV3?.minecraft_server == null) {
console.warn('playServerProject failed: project is not a server project')
}
const content = projectV3?.minecraft_java_server?.content
const serverAddress = getServerAddress(projectV3?.minecraft_java_server)
const isVanilla = content?.kind === 'vanilla'
const isModpack = content?.kind === 'modpack'
const modpackVersionId = content?.version_id ?? null
const recommendedGameVersion = content?.recommended_game_version
let instance = await findInstalledInstance(project.id)
if (isVanilla && !instance) {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
const path = await createVanillaInstance(project, recommendedGameVersion, serverAddress)
if (path) {
instance = await get(path)
showModpackInstallSuccess(installStore, instance, serverAddress)
}
} finally {
installStore.stopInstallingServer(projectId)
}
return
}
if (isModpack && !instance) {
installStore.showInstallToPlayModal(projectV3, modpackVersionId, async () => {
const newInstance = await findInstalledInstance(project.id)
if (!newInstance) return
// Ensure the server is in the worlds list after modpack install
await syncServerAsWorld(newInstance.path, project.title, serverAddress, project.id)
showModpackInstallSuccess(installStore, newInstance, serverAddress)
})
return
}
if (!instance) return
await syncServerAsWorld(instance.path, project.title, serverAddress, project.id)
// Update existing instance if needed
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {
installStore.showUpdateToPlayModal(instance, modpackVersionId, () => {
showUpdateSuccess(installStore, instance, serverAddress)
})
return
}
if (isVanilla && instance.game_version !== recommendedGameVersion) {
if (installStore.installingServerProjects.includes(projectId)) return
installStore.startInstallingServer(projectId)
try {
await updateVanillaGameVersion(instance, recommendedGameVersion)
showUpdateSuccess(installStore, instance, serverAddress)
} finally {
installStore.stopInstallingServer(projectId)
}
return
}
// join server
try {
await joinServer(instance.path, serverAddress, project.id)
} catch (err) {
handleSevereError(err, { profilePath: instance.path })
}
}
-4
View File
@@ -29,8 +29,6 @@ fn main() {
.commands(&[
"get_project",
"get_project_many",
"get_project_v3",
"get_project_v3_many",
"get_version",
"get_version_many",
"get_user",
@@ -41,8 +39,6 @@ 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(
+1 -7
View File
@@ -30,13 +30,11 @@ macro_rules! impl_cache_methods {
impl_cache_methods!(
(Project, Project),
(ProjectV3, ProjectV3),
(Version, Version),
(User, User),
(Team, Vec<TeamMember>),
(Organization, Organization),
(SearchResults, SearchResults),
(SearchResultsV3, SearchResultsV3)
(SearchResults, SearchResults)
);
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
@@ -44,8 +42,6 @@ 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,
@@ -56,8 +52,6 @@ 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()
+2 -10
View File
@@ -6,7 +6,6 @@ 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")
@@ -251,15 +250,8 @@ 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,
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?;
pub async fn profile_run(path: &str) -> Result<ProcessMetadata> {
let process = profile::run(path, QuickPlayType::None).await?;
Ok(process)
}
+1 -2
View File
@@ -20,7 +20,6 @@ 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,
@@ -28,7 +27,7 @@ pub async fn profile_create(
modloader,
loader_version,
icon,
linked_data,
None,
skip_install,
)
.await?;
+2 -9
View File
@@ -151,18 +151,11 @@ 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,
linked_project_id,
)
.await?,
worlds::add_server_to_profile(&path, name, address, pack_status)
.await?,
)
}
@@ -34,7 +34,7 @@
}
.value {
color: var(--color-text-primary);
color: var(--color-text-dark);
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-primary);
color: var(--color-text-dark);
font-size: 1.17rem;
font-weight: bold;
@@ -381,7 +381,7 @@
}
&:active {
color: var(--color-text-primary);
color: var(--color-text-dark);
}
}
@@ -1113,7 +1113,7 @@ svg.inline-svg {
display: flex;
flex-direction: column;
gap: var(--gap-12);
padding: var(--gap-16);
padding: var(--gap-16) var(--gap-24);
h2 {
font-size: var(--text-18);
+3 -3
View File
@@ -380,18 +380,18 @@ a {
}
h1 {
color: var(--color-text-primary);
color: var(--color-text-dark);
}
h2 {
margin-top: 0;
margin-bottom: 1rem;
color: var(--color-text-primary);
color: var(--color-text-dark);
}
h3 {
margin-block: var(--spacing-card-md) var(--spacing-card-sm);
color: var(--color-text-primary);
color: var(--color-text-dark);
}
input {
@@ -114,19 +114,28 @@ iframe[id^='google_ads_iframe'] {
color: var(--color-base);
}
#qc-cmp2-ui::before,
#qc-cmp2-ui::before {
background: var(--color-raised-bg);
}
#qc-cmp2-ui::after {
background: var(--color-raised-bg);
}
#qc-cmp2-ui button[mode='primary'],
#qc-cmp2-ui button[mode='secondary'] {
#qc-cmp2-ui button[mode='primary'] {
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);
}
+1 -9
View File
@@ -1,6 +1,5 @@
<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' }"
@@ -300,14 +299,7 @@ watch(
},
)
watch(
() => props.links,
async () => {
await nextTick()
updateActiveTab()
},
{ deep: true },
)
watch(() => props.links, updateActiveTab, { deep: true })
</script>
<style scoped>
@@ -1,6 +1,6 @@
<template>
<div class="space-y-2.5">
<div v-if="!noHeader" class="flex items-center justify-between">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
Minecraft versions <span class="text-red">*</span>
</span>
@@ -45,7 +45,6 @@
versionType === 'all' && !group.isReleaseGroup ? 'w-max' : 'w-16',
modelValue.includes(version) ? '!text-contrast' : '',
]"
:disabled="disabled"
@click="() => handleToggleVersion(version)"
@blur="
() => {
@@ -77,8 +76,6 @@ type GameVersion = Labrinth.Tags.v2.GameVersion
const props = defineProps<{
modelValue: string[]
gameVersions: Labrinth.Tags.v2.GameVersion[]
noHeader?: boolean
disabled?: boolean
}>()
const emit = defineEmits<{
@@ -161,7 +158,7 @@ function groupVersions(gameVersions: GameVersion[]) {
const groups: Record<string, string[]> = {}
let currentGroupKey: string
let currentGroupKey
gameVersions.forEach((gameVersion) => {
if (gameVersion.version_type === 'release') {
@@ -1,35 +1,12 @@
<template>
<NewModal
ref="modal"
:header="
projectType === 'server'
? formatMessage(messages.serverProjectTitle)
: formatMessage(messages.title)
"
:max-width="'550px'"
>
<div class="flex w-full flex-col gap-6">
<NewModal ref="modal" :header="formatMessage(messages.title)">
<div class="min-w-md flex max-w-md flex-col gap-3">
<CreateLimitAlert v-model="hasHitLimit" type="project" />
<div v-if="showTypeSelector" class="flex flex-col gap-2.5">
<label for="project-type">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.typeLabel) }}
</span>
</label>
<Combobox
id="project-type"
v-model="projectType"
name="project-type"
:options="projectTypeOptions"
:disabled="hasHitLimit"
/>
</div>
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-md font-semibold text-contrast">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.nameLabel) }}
<span class="text-brand-red">*</span>
</span>
</label>
<StyledInput
@@ -42,46 +19,32 @@
@update:model-value="updatedName()"
/>
</div>
<label for="slug" class="flex flex-col gap-2.5">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
</span>
<div class="text-input-wrapper !w-full">
<div class="flex flex-col gap-2">
<label for="slug">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.urlLabel) }}
<span class="text-brand-red">*</span>
</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">https://modrinth.com/project/</div>
<StyledInput
id="slug"
v-model="slug"
:maxlength="64"
class="w-full"
type="text"
autocomplete="off"
:disabled="hasHitLimit"
@update:model-value="manualSlug = true"
/>
</div>
</label>
<div class="flex flex-col gap-2.5">
<label for="owner">
<span class="text-md font-semibold text-contrast">
{{ formatMessage(messages.ownerLabel) }}
</span>
</label>
<Combobox
id="owner"
v-model="owner"
name="owner"
:options="[userOption, ...ownerOptions]"
searchable
:disabled="hasHitLimit"
show-icon-in-selected
/>
<span>{{ formatMessage(messages.ownerDescription) }}</span>
</div>
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-md font-semibold text-contrast">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.visibilityLabel) }}
<span class="text-brand-red">*</span>
</span>
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
</label>
<Chips
id="visibility"
@@ -91,13 +54,14 @@
:capitalize="false"
:disabled="hasHitLimit"
/>
<span>{{ formatMessage(messages.visibilityDescription) }}</span>
</div>
<div class="flex flex-col gap-2.5">
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-md font-semibold text-contrast">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.summaryLabel) }}
<span class="text-brand-red">*</span>
</span>
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</label>
<StyledInput
id="additional-information"
@@ -107,9 +71,8 @@
:placeholder="formatMessage(messages.summaryPlaceholder)"
:disabled="hasHitLimit"
/>
<span>{{ formatMessage(messages.summaryDescription) }}</span>
</div>
<div class="flex justify-end gap-2.5">
<div class="flex justify-end gap-2">
<ButtonStyled class="w-24">
<button @click="cancel">
<XIcon aria-hidden="true" />
@@ -117,7 +80,7 @@
</button>
</ButtonStyled>
<ButtonStyled color="brand" class="w-32">
<button v-tooltip="missingFieldsTooltip" :disabled="disableCreate" @click="createProject">
<button :disabled="hasHitLimit" @click="createProject">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.createProject) }}
</button>
@@ -127,77 +90,30 @@
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
<script setup>
import { PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
Combobox,
type ComboboxOption,
commonMessages,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, defineAsyncComponent, h } from 'vue'
import CreateLimitAlert from './CreateLimitAlert.vue'
type ProjectTypes = 'server' | 'project'
interface VisibilityOption {
actual: Labrinth.Projects.v2.ProjectStatus
display: string
}
interface ShowOptions {
type?: 'server' | 'project'
showTypeSelector?: boolean
}
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const router = useRouter()
defineExpose({
show,
})
const auth = (await useAuth()) as Ref<{
user: { id: string; username: string; avatar_url: string } | null
}>
const messages = defineMessages({
title: {
id: 'create.project.title',
defaultMessage: 'Creating a project',
},
serverProjectTitle: {
id: 'create.project.server-project-title',
defaultMessage: 'Creating a server project',
},
typeLabel: {
id: 'create.project.type-label',
defaultMessage: 'Type',
},
typeProject: {
id: 'create.project.type-project',
defaultMessage: 'Project',
},
typeServer: {
id: 'create.project.type-server',
defaultMessage: 'Server',
},
ownerLabel: {
id: 'create.project.owner-label',
defaultMessage: 'Owner',
},
ownerDescription: {
id: 'create.project.owner-description',
defaultMessage: `Set the project owner as yourself or an organization you're a member of.`,
},
nameLabel: {
id: 'create.project.name-label',
defaultMessage: 'Name',
@@ -230,10 +146,6 @@ 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',
@@ -246,39 +158,24 @@ const messages = defineMessages({
id: 'create.project.visibility-private',
defaultMessage: 'Private',
},
missingFieldsTooltip: {
id: 'create.project.missing-fields-tooltip',
defaultMessage: 'Missing fields: {fields}',
})
const props = defineProps({
organizationId: {
type: String,
required: false,
default: null,
},
})
const props = defineProps<{
organizationId?: string | null
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const modal = ref()
const hasHitLimit = ref(false)
const name = ref('')
const slug = ref('')
const description = ref('')
const manualSlug = ref(false)
const projectType = ref<ProjectTypes>('project')
const showTypeSelector = ref(false)
const projectTypeOptions = computed<ComboboxOption<ProjectTypes>[]>(() => [
{
value: 'project',
label: formatMessage(messages.typeProject),
},
{
value: 'server',
label: formatMessage(messages.typeServer),
},
])
const ownerOptions = ref<ComboboxOption<string>[]>([])
const owner = ref<string | undefined>('self')
const organizations = ref<Labrinth.Projects.v3.Organization[]>([])
const visibilities = ref<VisibilityOption[]>([
const visibilities = ref([
{
actual: 'approved',
display: formatMessage(messages.visibilityPublic),
@@ -292,84 +189,10 @@ const visibilities = ref<VisibilityOption[]>([
display: formatMessage(messages.visibilityPrivate),
},
])
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 visibility = ref(visibilities.value[0])
const cancel = () => {
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)
}
modal.value.hide()
}
async function createProject() {
@@ -377,7 +200,9 @@ async function createProject() {
const formData = new FormData()
const projectData: Labrinth.Projects.v2.CreateProjectBase = {
const auth = await useAuth()
const projectData = {
title: name.value.trim(),
project_type: 'mod',
slug: slug.value,
@@ -387,8 +212,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',
},
],
@@ -399,71 +224,45 @@ async function createProject() {
is_draft: true,
}
if (props.organizationId) {
projectData.organization_id = props.organizationId
}
formData.append('data', JSON.stringify(projectData))
try {
let createdProjectId: string | undefined
await useBaseFetch('project', {
method: 'POST',
body: formData,
headers: {
'Content-Disposition': formData,
},
})
if (projectType.value === 'server') {
const result = await labrinth.projects_v3.createServerProject({
base: {
name: projectData.title,
slug: projectData.slug,
summary: projectData.description,
description: '',
requested_status: projectData.requested_status,
organization_id: owner.value !== 'self' ? owner.value : undefined,
},
minecraft_server: {
// empty component
},
minecraft_java_server: {
address: '',
port: 25565,
},
minecraft_bedrock_server: {
address: '',
port: 19132,
},
})
createdProjectId = result.id
} else {
const result = (await useBaseFetch('project', {
method: 'POST',
body: formData,
headers: {
'Content-Disposition': formData as unknown as string,
},
})) as Labrinth.Projects.v3.Project
createdProjectId = result.id
console.log(createdProjectId)
}
modal.value?.hide()
modal.value.hide()
await router.push(`/project/${slug.value}/settings`)
} catch (err: unknown) {
const error = err as { data?: { description?: string } }
} catch (err) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: error.data?.description ?? String(err),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
async function show(event?: MouseEvent, options?: ShowOptions) {
function show(event) {
name.value = ''
slug.value = ''
description.value = ''
manualSlug.value = false
owner.value = 'self'
projectType.value = options?.type ?? 'project'
showTypeSelector.value = options?.showTypeSelector ?? false
await fetchOrganizations()
modal.value?.show(event)
modal.value.show(event)
}
defineExpose({
show,
})
function updatedName() {
if (!manualSlug.value) {
slug.value = name.value
@@ -100,7 +100,7 @@
/>
</div>
<div v-for="field in selectedRail?.fields" :key="field.name" class="flex flex-col gap-2.5">
<div v-for="field in visibleFields" :key="field.name" class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast">
{{ formatMessage(field.label) }}
@@ -398,6 +398,26 @@ const isBusinessEntity = computed(() => {
return providerDataValue.kycData?.type === 'business'
})
const visibleFields = computed(() => {
const rail = selectedRail.value
if (!rail) return []
return rail.fields.filter((field) => {
if (!field.dependsOn) return true
const { field: dependsOnField, value: dependsOnValue } = field.dependsOn
const currentValue = formData.value[dependsOnField]
if (!currentValue) return false
if (Array.isArray(dependsOnValue)) {
return dependsOnValue.includes(currentValue)
} else {
return currentValue === dependsOnValue
}
})
})
const allRequiredFieldsFilled = computed(() => {
const rail = selectedRail.value
if (!rail) return false
@@ -407,7 +427,7 @@ const allRequiredFieldsFilled = computed(() => {
if (rail.requiresBankName && !formData.value.bankName) return false
const requiredFields = rail.fields.filter((f) => f.required)
const requiredFields = visibleFields.value.filter((f) => f.required)
const allRequiredPresent = requiredFields.every((f) => {
const value = formData.value[f.name]
return value !== undefined && value !== null && value !== ''
@@ -100,7 +100,6 @@ 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
@@ -173,7 +172,6 @@ const emit = defineEmits<{
const nagContext = computed<NagContext>(() => ({
project: props.project,
projectV3: props.projectV3,
versions: props.versions,
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
currentRoute: props.routeName,
@@ -1654,7 +1654,7 @@ function shouldShowStage(stage: Stage): boolean {
function shouldShowAction(action: Action): boolean {
if (typeof action.shouldShow === 'function') {
return action.shouldShow(projectV2.value, projectV3.value)
return action.shouldShow(projectV2.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, projectV3.value)
return option.shouldShow(projectV2.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, projectV3.value)
return option.shouldShow(projectV2.value)
}
return true
})
@@ -1,303 +0,0 @@
<template>
<div>
<div class="flex flex-col gap-3">
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<div class="text-xl font-semibold text-contrast">Server compatibility</div>
<div v-if="!content" class="text-sm text-secondary">
Select whether your server is vanilla or modded and which versions it supports. The
Modrinth App uses this when a player joins.
</div>
<div v-else>
<div v-if="content.kind === 'vanilla'" class="flex items-center gap-1.5">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<BoxIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Vanilla server
</div>
<div
v-else-if="content.kind === 'modpack' && !usingCustomMrpack"
class="flex items-center gap-1.5"
>
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<PackageIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Published modpack
</div>
<div v-else class="flex items-center gap-1.5">
<div
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-md border border-solid border-surface-5 bg-surface-4 font-medium"
>
<PackagePlusIcon class="h-4 w-4 shrink-0 text-secondary" />
</div>
Custom modpack
</div>
</div>
</div>
<ButtonStyled v-if="content" type="outlined">
<button class="!border-[1px]" @click="handleSwitchCompatibility">
<ArrowLeftRightIcon />
Switch type
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button @click="handleSetCompatibility">
<ComponentIcon />
Set compatibility
</button>
</ButtonStyled>
</div>
<div
v-if="content"
class="flex justify-between rounded-2xl border border-solid border-surface-5 p-4"
>
<!-- kind = vanilla -->
<div
v-if="content?.kind === 'vanilla'"
class="flex flex-col items-start justify-between gap-2.5"
>
<div class="flex flex-col gap-2">
<div class="font-medium text-secondary">Recommended version</div>
<div class="text-2xl font-semibold text-contrast">
{{ content.recommended_game_version ?? '—' }}
</div>
</div>
<div class="flex flex-col gap-2">
<div class="font-medium text-secondary">Supported versions</div>
<div class="flex flex-wrap gap-1.5">
<TagItem
v-for="v in formatVersionsForDisplay(
content.supported_game_versions,
tags.gameVersions,
)"
:key="v"
>
{{ v }}
</TagItem>
<div v-if="!content.supported_game_versions.length">—</div>
</div>
</div>
</div>
<!-- kind = modpack -->
<div
v-if="content?.kind === 'modpack' && modpackProject"
class="flex w-full max-w-[500px] flex-col items-start justify-between gap-4"
>
<div class="flex w-full flex-col gap-2">
<div class="font-medium text-secondary">Required modpack</div>
<div class="w-fullitems-center flex gap-3 rounded-2xl bg-surface-1 p-3">
<Avatar
v-if="!usingCustomMrpack"
:src="modpackProject.icon_url"
size="56px"
:tint-by="modpackProject.name"
/>
<div
v-else
class="flex h-14 w-14 shrink-0 items-center justify-center rounded-xl border border-solid border-surface-5 bg-surface-3"
>
<PackagePlusIcon class="h-10 w-10 shrink-0 text-secondary" />
</div>
<div class="flex flex-col justify-center gap-1">
<div class="font-semibold text-contrast">
<NuxtLink
v-if="!usingCustomMrpack"
:to="`/modpack/${modpackProject.slug}`"
class="hover:underline"
>
{{ modpackProject.name }}
</NuxtLink>
<span v-else>{{ modpackFileName }}</span>
</div>
<div class="flex h-6 items-center gap-1.5 text-secondary">
<NuxtLink
v-if="modpackOrg?.name"
:to="`/organization/${modpackOrg.slug}`"
class="flex items-center gap-1 hover:underline"
>
<Avatar
v-if="modpackOrg?.icon_url"
:src="modpackOrg.icon_url"
size="24px"
class="rounded-2xl"
/>
{{ modpackOrg.name }}
</NuxtLink>
<div
v-if="modpackOrg?.name && modpackVersion"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
></div>
<NuxtLink
v-if="modpackVersion && !usingCustomMrpack"
:to="`/modpack/${modpackProject?.slug}/version/${modpackVersion.id}`"
class="hover:underline"
>
v{{ modpackVersion.version_number }}
</NuxtLink>
<div v-else-if="modpackVersion" class="flex items-center gap-1.5">
<div>v{{ modpackVersion.version_number }}</div>
<div class="h-1.5 w-1.5 rounded-full bg-surface-5"></div>
<a
v-if="modpackVersion.files?.length"
:href="
modpackVersion.files.find((f) => f.primary)?.url ??
modpackVersion.files[0]?.url
"
class="flex items-center gap-0.5 hover:underline"
>
<DownloadIcon />
Download
</a>
</div>
</div>
</div>
</div>
</div>
<div v-if="modpackVersion" class="flex flex-col gap-2">
<div class="font-medium text-secondary">Required version</div>
<div class="flex flex-wrap gap-1.5">
<TagItem v-for="gv in modpackVersion.game_versions" :key="gv">
{{ gv }}
</TagItem>
<TagItem
v-for="loader in modpackVersion.mrpack_loaders"
:key="loader"
:style="`--_color: var(--color-platform-${loader})`"
>
<component
:is="getLoaderIcon(loader)"
v-if="getLoaderIcon(loader)"
class="h-4 w-4"
/>
<FormattedTag :tag="loader" enforce-type="loader" />
</TagItem>
</div>
</div>
</div>
<ButtonStyled v-if="content">
<button class="!w-full !max-w-[160px]" @click="handleUpdateContent">
<RefreshCwIcon />
Update
</button>
</ButtonStyled>
</div>
</div>
<ServerCompatibilityModal ref="serverCompatibilityModal" />
</div>
</template>
<script setup lang="ts">
import {
ArrowLeftRightIcon,
BoxIcon,
ComponentIcon,
DownloadIcon,
getLoaderIcon,
PackageIcon,
PackagePlusIcon,
RefreshCwIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
FormattedTag,
injectModrinthClient,
injectProjectPageContext,
TagItem,
} from '@modrinth/ui'
import { formatVersionsForDisplay } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useGeneratedState } from '~/composables/generated'
import ServerCompatibilityModal from './ServerCompatibilityModal/ServerCompatibilityModal.vue'
const serverCompatibilityModal = useTemplateRef<InstanceType<typeof ServerCompatibilityModal>>(
'serverCompatibilityModal',
)
const { projectV3 } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const tags = useGeneratedState()
const content = computed(() => {
if (!projectV3.value) return null
const content = projectV3.value.minecraft_java_server?.content
if (!content) return null
if (content?.kind === 'vanilla' && !content.recommended_game_version) {
return null
}
return content
})
const modpackVersionId = computed(() => {
if (content.value?.kind === 'modpack') return content.value.version_id
return null
})
const { data: modpackVersion } = useQuery({
queryKey: computed(() => ['versions', 'detail', modpackVersionId.value]),
queryFn: () => labrinth.versions_v3.getVersion(modpackVersionId.value!),
enabled: computed(() => !!modpackVersionId.value),
})
const modpackProjectId = computed(() => modpackVersion.value?.project_id ?? null)
const { data: modpackProject } = useQuery({
queryKey: computed(() => ['project', 'v3', modpackProjectId.value]),
queryFn: () => labrinth.projects_v3.get(modpackProjectId.value!),
enabled: computed(() => !!modpackProjectId.value),
})
const { data: modpackOrg } = useQuery({
queryKey: computed(() => ['project', 'org', modpackProjectId.value]),
queryFn: () => labrinth.projects_v3.getOrganization(modpackProjectId.value!),
enabled: computed(() => !!modpackProjectId.value && !!modpackProject.value?.organization),
})
const usingCustomMrpack = computed(() => modpackVersion.value?.project_id === projectV3.value?.id)
const modpackFileName = computed(() => {
if (!modpackVersion.value?.files?.length) return null
const primary = modpackVersion.value.files.find((f) => f.primary)
return (primary ?? modpackVersion.value.files[0]).filename
})
function handleSetCompatibility() {
serverCompatibilityModal.value?.show()
}
function handleSwitchCompatibility() {
serverCompatibilityModal.value?.show({ isSwitchingCompatibilityType: true })
}
function handleUpdateContent() {
if (!content.value?.kind) return
switch (content.value.kind) {
case 'vanilla':
serverCompatibilityModal.value?.show({ updateContentKind: 'vanilla' })
break
case 'modpack':
if (usingCustomMrpack.value) {
serverCompatibilityModal.value?.show({ updateContentKind: 'custom-modpack' })
} else {
serverCompatibilityModal.value?.show({ updateContentKind: 'published-modpack' })
}
break
default:
break
}
}
</script>
@@ -1,14 +0,0 @@
<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>
@@ -1,71 +0,0 @@
<template>
<MultiStageModal
ref="modal"
:stages="ctx.stageConfigs"
:context="ctx"
:fade="ctx.isSwitchingCompatibilityType.value ? 'danger' : 'standard'"
@hide="handleHide"
/>
</template>
<script setup lang="ts">
import { injectProjectPageContext, MultiStageModal } from '@modrinth/ui'
import type { ComponentExposed } from 'vue-component-type-helpers'
import {
type CompatibilityType,
createServerCompatibilityContext,
provideServerCompatibilityContext,
} from '../../../../providers/manage-server-compatibility-modal'
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const { projectV3 } = injectProjectPageContext()
const ctx = createServerCompatibilityContext(modal)
provideServerCompatibilityContext(ctx)
interface ShowModalOptions {
stageId?: string | null
updateContentKind?: CompatibilityType
isSwitchingCompatibilityType?: boolean
}
async function show(options?: ShowModalOptions) {
if (options?.updateContentKind) {
ctx.compatibilityType.value = options.updateContentKind
ctx.isEditingExistingCompatibility.value = true
// Prefill existing values for vanilla
const content = projectV3.value?.minecraft_java_server?.content
if (options.updateContentKind === 'vanilla' && content && content.kind === 'vanilla') {
ctx.supportedGameVersions.value = content.supported_game_versions ?? []
ctx.recommendedGameVersion.value = content.recommended_game_version ?? null
}
await nextTick()
modal.value?.setStage(1)
} else {
modal.value?.setStage(options?.stageId ?? 0)
}
if (options?.isSwitchingCompatibilityType) {
ctx.isSwitchingCompatibilityType.value = true
}
modal.value?.show()
}
function handleHide() {
ctx.resetContext()
}
function hide() {
handleHide()
modal.value?.hide()
}
defineExpose({
show,
hide,
})
</script>
@@ -1,68 +0,0 @@
<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>
@@ -1,139 +0,0 @@
<template>
<div class="flex flex-col gap-6">
<DataLossWarningBanner />
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Select modpack</div>
<div class="flex flex-col gap-6 rounded-2xl border border-solid border-surface-5 p-4">
<div class="flex flex-col gap-2">
<label class="font-semibold text-contrast">Project</label>
<ProjectCombobox
v-model="selectedProjectId"
:project-types="['modpack']"
:exclude-project-ids="[currentProjectId]"
placeholder="Select modpack"
search-placeholder="Search by name or paste ID..."
loading-message="Loading..."
no-results-message="No results found"
/>
</div>
<div v-if="selectedProjectId" class="flex flex-col gap-2">
<label class="font-semibold text-contrast">Version</label>
<Combobox
v-model="selectedVersionId"
placeholder="Select version"
:options="versionOptions"
:searchable="true"
search-placeholder="Search versions..."
:no-options-message="versionsLoading ? 'Loading...' : 'No results found'"
/>
</div>
</div>
<div v-if="selectedVersion" class="flex flex-col gap-4 rounded-2xl bg-surface-2 p-4">
<div class="flex items-center justify-between">
<div class="text-secondary">Game version</div>
<div class="flex flex-wrap gap-1">
<TagItem v-for="gv in selectedVersion.game_versions" :key="gv">
{{ gv }}
</TagItem>
</div>
</div>
<div
v-if="selectedVersion.mrpack_loaders?.length"
class="flex items-center justify-between"
>
<div class="text-secondary">Platform</div>
<div class="flex flex-wrap gap-1">
<TagItem
v-for="loader in selectedVersion.mrpack_loaders"
:key="loader"
:style="`--_color: var(--color-platform-${loader})`"
>
<component :is="getLoaderIcon(loader)" v-if="getLoaderIcon(loader)" class="h-4 w-4" />
<FormattedTag :tag="loader" enforce-type="loader" />
</TagItem>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { getLoaderIcon } from '@modrinth/assets'
import {
Combobox,
FormattedTag,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
ProjectCombobox,
TagItem,
} from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import { injectServerCompatibilityContext } from '~/providers/manage-server-compatibility-modal'
import DataLossWarningBanner from '../DataLossWarningBanner.vue'
const { projectV3 } = injectProjectPageContext()
const currentProjectId = computed(() => projectV3.value?.id)
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
interface VersionInfo {
id: string
version_number: string
name: string
}
const versionsLoading = ref(false)
const projectVersions = ref<VersionInfo[]>([])
const versionOptions = computed(() =>
projectVersions.value.map((v) => ({
label: v.version_number,
value: v.id,
})),
)
const { data: selectedVersion } = useQuery({
queryKey: computed(() => ['versions', 'detail', selectedVersionId.value]),
queryFn: () => labrinth.versions_v3.getVersion(selectedVersionId.value),
enabled: computed(() => !!selectedVersionId.value),
})
watch(
() => selectedProjectId.value,
async (newProjectId) => {
selectedVersionId.value = ''
projectVersions.value = []
if (!newProjectId) return
versionsLoading.value = true
try {
const versions = await labrinth.versions_v3.getProjectVersions(newProjectId)
projectVersions.value = versions.map((v) => ({
id: v.id,
version_number: v.version_number,
name: v.name,
}))
} catch (error: unknown) {
const err = error as { data?: { description?: string } }
addNotification({
title: 'Failed to load versions',
text: err.data?.description || String(error),
type: 'error',
})
} finally {
versionsLoading.value = false
}
},
)
</script>
@@ -1,64 +0,0 @@
<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>
@@ -1,92 +0,0 @@
<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>
@@ -32,13 +32,14 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
projectBackground: false,
searchBackground: false,
advancedDebugInfo: false,
FilesRefreshButton: false,
showProjectPageDownloadModalServersPromo: false,
showProjectPageCreateServersTooltip: true,
showProjectPageQuickServerButton: false,
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
hideRussiaCensorshipBanner: false,
serverDiscovery: true,
serverDiscovery: false,
disablePrettyProjectUrlRedirects: false,
hidePreviewBanner: false,
i18nDebug: false,
@@ -92,11 +92,6 @@ export const useGeneratedState = () =>
id: 'modpack',
display: 'modpack',
},
{
actual: 'server',
id: 'server',
display: 'server',
},
],
loaderData: {
pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'],
-2
View File
@@ -3,8 +3,6 @@ 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') {
+1 -16
View File
@@ -398,10 +398,6 @@
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),
@@ -414,13 +410,10 @@
]"
>
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.publish) }}
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<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) }}
@@ -842,10 +835,6 @@ 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',
@@ -878,10 +867,6 @@ const messages = defineMessages({
id: 'layout.action.new-project',
defaultMessage: 'New project',
},
newServerProject: {
id: 'layout.action.new-server-project',
defaultMessage: 'New server',
},
newCollection: {
id: 'layout.action.new-collection',
defaultMessage: 'New collection',
@@ -497,27 +497,12 @@
"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."
},
@@ -530,15 +515,6 @@
"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"
},
@@ -1457,12 +1433,6 @@
"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"
},
+6 -13
View File
@@ -5,16 +5,7 @@ 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',
'server',
]
const PROJECT_TYPES = ['project', 'mod', 'plugin', 'datapack', 'shader', 'resourcepack', 'modpack']
export default defineNuxtRouteMiddleware(async (to) => {
// Only handle project routes
@@ -32,7 +23,6 @@ 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
@@ -45,9 +35,12 @@ 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(projectType, project.loaders, tags.value)
const correctType = getProjectTypeForUrlShorthand(
project.project_type,
project.loaders,
tags.value,
)
// Preserve the rest of the path (subpages like /versions, /settings, etc.)
const pathParts = to.path.split('/')
+13 -238
View File
@@ -73,7 +73,6 @@
"
/>
</NewModal>
<OpenInAppModal ref="openInAppModal" />
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
@@ -433,14 +432,7 @@
}"
>
<div class="normal-page__header relative my-4">
<component
:is="isServerProject ? ServerProjectHeader : ProjectHeader"
v-bind="
isServerProject
? { project, projectV3, member: !!currentMember }
: { project, member: !!currentMember }
"
>
<ProjectHeader :project="project" :member="!!currentMember">
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
<nuxt-link
@@ -454,7 +446,6 @@
<div class="hidden sm:contents">
<ButtonStyled
v-if="!isServerProject"
size="large"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
@@ -467,6 +458,8 @@
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
@mouseenter="loadVersions"
@focus="loadVersions"
@click="(event) => downloadModal.show(event)"
>
<DownloadIcon aria-hidden="true" />
@@ -475,29 +468,10 @@
}}
</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`"
@@ -505,21 +479,13 @@
<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"
@@ -771,7 +737,7 @@
</OverflowMenu>
</ButtonStyled>
</template>
</component>
</ProjectHeader>
<ProjectMemberHeader
v-if="currentMember"
:project="project"
@@ -830,19 +796,7 @@
</div>
<div class="normal-page__sidebar">
<ProjectSidebarServerInfo
v-if="isServerProject"
:project-v3="projectV3"
:tags="tags"
:required-content="serverRequiredContent"
:recommended-version="serverRecommendedVersion"
:supported-versions="serverSupportedVersions"
:loaders="serverModpackLoaders"
:status-online="projectV3?.minecraft_java_server?.ping?.data != null"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarCompatibility
v-if="!isServerProject"
:project="project"
:tags="tags"
:v3-metadata="projectV3"
@@ -851,22 +805,16 @@
<AdPlaceholder v-if="!auth.user && tags.approvedStatuses.includes(project.status)" />
<ProjectSidebarLinks
:project="project"
:project-v3="projectV3"
:link-target="$external()"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarCreators
v-if="!isServerProject"
:organization="organization"
:members="members"
:org-link="(slug) => `/organization/${slug}`"
:user-link="(username) => `/user/${username}`"
class="card flex-card experimental-styles-within"
/>
<ProjectSidebarTags
:project="project"
class="card flex-card experimental-styles-within"
/>
<!-- TODO: Finish license modal and enable -->
<ProjectSidebarDetails
v-if="false"
@@ -879,7 +827,7 @@
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
<div class="details-list">
<div v-if="!isServerProject" class="details-list__item">
<div class="details-list__item">
<BookTextIcon aria-hidden="true" />
<div>
{{ formatMessage(messages.licensedLabel) }}
@@ -1005,7 +953,6 @@ import {
ListIcon,
ModrinthIcon,
MoreVerticalIcon,
PlayIcon,
PlusIcon,
ReportIcon,
ScaleIcon,
@@ -1029,7 +976,6 @@ import {
injectNotificationManager,
IntlFormatted,
NewModal,
OpenInAppModal,
OverflowMenu,
PopoutMenu,
ProjectBackgroundGradient,
@@ -1039,11 +985,8 @@ import {
ProjectSidebarCreators,
ProjectSidebarDetails,
ProjectSidebarLinks,
ProjectSidebarServerInfo,
ProjectSidebarTags,
provideProjectPageContext,
ScrollablePanel,
ServerProjectHeader,
ServersPromo,
StyledInput,
TagItem,
@@ -1091,7 +1034,6 @@ const user = await useUser()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const cosmetics = useCosmetics()
const router = useNativeRouter()
const { locale, formatMessage } = useVIntl()
@@ -1099,7 +1041,6 @@ const debug = useDebugLogger('DownloadModal')
const settingsModal = ref()
const downloadModal = ref()
const openInAppModal = ref()
const overTheTopDownloadAnimation = ref()
const userSelectedGameVersion = ref(null)
@@ -1110,8 +1051,6 @@ const gameVersionFilterInput = ref()
const versionFilter = ref('')
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
const baseId = useId()
@@ -1182,21 +1121,6 @@ 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()
@@ -1619,79 +1543,6 @@ const { data: projectV3, error: _projectV3Error } = useQuery({
enabled: computed(() => !!projectId.value),
})
// Server sidebar: modpack version + project for required content
const serverModpackVersionId = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
return content?.kind === 'modpack' ? content.version_id : null
})
const { data: serverModpackVersion } = useQuery({
queryKey: computed(() => ['sidebar-modpack-version', serverModpackVersionId.value]),
queryFn: () => client.labrinth.versions_v3.getVersion(serverModpackVersionId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackVersionId.value),
})
const serverModpackProjectId = computed(() => serverModpackVersion.value?.project_id ?? null)
const { data: serverModpackProject } = useQuery({
queryKey: computed(() => ['sidebar-modpack-project', serverModpackProjectId.value]),
queryFn: () => client.labrinth.projects_v3.get(serverModpackProjectId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackProjectId.value),
})
const serverRequiredContent = computed(() => {
if (!serverModpackProject.value) return null
const primaryFile =
serverModpackVersion.value?.files?.find((f) => f.primary) ??
serverModpackVersion.value?.files?.[0]
return {
name: serverModpackProject.value.name,
versionNumber: serverModpackVersion.value?.version_number ?? '',
icon: serverModpackProject.value.icon_url,
onclickName: () => navigateTo(`/modpack/${serverModpackProject.value.slug}`),
onclickVersion: () =>
navigateTo(
`/modpack/${serverModpackProject.value.slug}/version/${serverModpackVersion.value?.id}`,
),
onclickDownload: primaryFile?.url
? () => navigateTo(primaryFile.url, { external: true })
: undefined,
}
})
const serverRecommendedVersion = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
if (!content) return null
if (content.kind === 'modpack') {
return serverModpackVersion.value?.game_versions?.[0] ?? null
}
if (content.kind === 'vanilla') {
return content.recommended_game_version ?? null
}
return null
})
const serverSupportedVersions = computed(() => {
const content = projectV3.value?.minecraft_java_server?.content
if (!content) return []
if (content.kind === 'vanilla') {
return content.supported_game_versions?.filter((v) => !!v) ?? []
}
return []
})
const serverModpackLoaders = computed(() => {
if (!serverModpackVersion.value) return []
return serverModpackVersion.value.mrpack_loaders ?? []
})
// Members
const { data: allMembersRaw, error: _membersError } = useQuery({
queryKey: computed(() => ['project', projectId.value, 'members']),
@@ -1860,6 +1711,7 @@ const patchProjectMutation = useMutation({
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async () => {
@@ -1912,57 +1764,6 @@ 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 }) => {
@@ -1989,6 +1790,7 @@ const patchIconMutation = useMutation({
text: err.data ? err.data.description : err.message,
type: 'error',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
},
onSettled: async () => {
@@ -2345,30 +2147,7 @@ async function patchProject(resData, quiet = false) {
text: formatMessage(messages.projectUpdatedMessage),
type: 'success',
})
}
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',
})
window.scrollTo({ top: 0, behavior: 'smooth' })
}
resolve(true)
},
@@ -2500,8 +2279,7 @@ async function deleteVersion(id) {
}
const navLinks = computed(() => {
const routeType = route.params.type || project.value.project_type
const projectUrl = `/${routeType}/${project.value.slug ? project.value.slug : project.value.id}`
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
return [
{
@@ -2516,15 +2294,13 @@ const navLinks = computed(() => {
{
label: formatMessage(messages.changelogTab),
href: `${projectUrl}/changelog`,
shown: hasVersions.value && projectV3.value?.minecraft_server === undefined,
shown: hasVersions.value,
onHover: loadVersions,
},
{
label: formatMessage(messages.versionsTab),
href: `${projectUrl}/versions`,
shown:
(hasVersions.value || !!currentMember.value) &&
projectV3.value?.minecraft_server === undefined,
shown: hasVersions.value || !!currentMember.value,
subpages: [`${projectUrl}/version/`],
onHover: loadVersions,
},
@@ -2559,7 +2335,6 @@ provideProjectPageContext({
// Mutation functions
patchProject,
patchProjectV3,
patchIcon,
setProcessing,
+13 -18
View File
@@ -32,8 +32,8 @@
:src="
previewImage
? previewImage
: filteredGallery[editIndex]?.url
? filteredGallery[editIndex].url
: project.gallery?.[editIndex]?.url
? project.gallery[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="filteredGallery.length > 1"
v-if="(project?.gallery?.length ?? 0) > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="filteredGallery.length > 1"
v-if="(project?.gallery?.length ?? 0) > 1"
class="next circle-button"
@click="nextImage()"
>
@@ -222,7 +222,7 @@
</div>
</template>
</Admonition>
<div v-if="currentMember && filteredGallery.length" class="card header-buttons">
<div v-if="currentMember && project?.gallery?.length" class="card header-buttons">
<FileInput
:max-size="5242880"
:accept="acceptFileTypes"
@@ -243,8 +243,8 @@
@change="handleFiles"
/>
</div>
<div v-if="filteredGallery.length" class="items">
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
<div v-if="project?.gallery?.length" class="items">
<div v-for="(item, index) in project.gallery" :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,13 +414,8 @@ 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) {
@@ -440,18 +435,18 @@ useEventListener(document, 'keydown', (e) => {
// Navigation functions
function nextImage() {
expandedGalleryIndex.value++
if (expandedGalleryIndex.value >= filteredGallery.value.length) {
if (expandedGalleryIndex.value >= project.value.gallery!.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
}
function previousImage() {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = filteredGallery.value.length - 1
expandedGalleryIndex.value = project.value.gallery!.length - 1
}
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value] as GalleryItem
expandedGalleryItem.value = project.value.gallery![expandedGalleryIndex.value] as GalleryItem
}
function expandImage(item: GalleryItem, index: number) {
@@ -514,7 +509,7 @@ async function editGalleryItem() {
shouldPreventActions.value = true
startLoading()
const imageUrl = filteredGallery.value[editIndex.value].url
const imageUrl = project.value!.gallery![editIndex.value].url
const success = await contextEditGalleryItem(
imageUrl,
editTitle.value || undefined,
@@ -534,7 +529,7 @@ async function editGalleryItem() {
async function deleteGalleryImage() {
startLoading()
const imageUrl = filteredGallery.value[deleteIndex.value].url!
const imageUrl = project.value!.gallery![deleteIndex.value].url!
await contextDeleteGalleryItem(imageUrl)
stopLoading()
@@ -7,7 +7,6 @@ import {
ImageIcon,
InfoIcon,
LinkIcon,
ServerIcon,
TagsIcon,
UsersIcon,
VersionIcon,
@@ -37,8 +36,6 @@ 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}`
@@ -60,36 +57,26 @@ 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),
@@ -105,13 +92,13 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.members),
icon: UsersIcon,
},
!isServerProject.value && {
{
link: `/${base}/settings/analytics`,
label: formatMessage(commonProjectSettingsMessages.analytics),
icon: ChartIcon,
},
!isServerProject.value && { type: 'heading', label: 'moderation', shown: showEnvironment },
!isServerProject.value && {
{ type: 'heading', label: 'moderation', shown: showEnvironment },
{
link: `/${base}/settings/environment`,
label: formatMessage(commonProjectSettingsMessages.environment),
icon: GlobeIcon,
@@ -138,16 +125,14 @@ watch(route, () => {
<div class="mb-8 flex w-full flex-col gap-4">
<ModerationProjectNags
v-if="
projectV3 &&
((currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status))
(currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status)
"
:project="project"
:project-v3="projectV3"
:versions="versions ?? undefined"
:versions="versions"
:current-member="currentMember"
:collapsed="collapsedChecklist"
:route-name="route.name as string"
:route-name="route.name"
:tags="tags"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
@@ -32,8 +32,8 @@
:src="
previewImage
? previewImage
: filteredGallery[editIndex] && filteredGallery[editIndex].url
? filteredGallery[editIndex].url
: project.gallery[editIndex] && project.gallery[editIndex].url
? project.gallery[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="filteredGallery.length > 1"
v-if="project.gallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="filteredGallery.length > 1"
v-if="project.gallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
@@ -215,7 +215,7 @@
/>
</div>
<div class="items">
<div v-for="(item, index) in filteredGallery" :key="index" class="card gallery-item">
<div v-for="(item, index) in project.gallery" :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,27 +340,22 @@ 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 >= filteredGallery.value.length) {
if (expandedGalleryIndex.value >= project.value.gallery.length) {
expandedGalleryIndex.value = 0
}
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
}
const previousImage = () => {
expandedGalleryIndex.value--
if (expandedGalleryIndex.value < 0) {
expandedGalleryIndex.value = filteredGallery.value.length - 1
expandedGalleryIndex.value = project.value.gallery.length - 1
}
expandedGalleryItem.value = filteredGallery.value[expandedGalleryIndex.value]
expandedGalleryItem.value = project.value.gallery[expandedGalleryIndex.value]
}
const expandImage = (item, index) => {
@@ -419,7 +414,7 @@ const editGalleryItem = async () => {
shouldPreventActions.value = true
const success = await editGalleryItemMutation(
filteredGallery.value[editIndex.value].url,
project.value.gallery[editIndex.value].url,
editTitle.value || undefined,
editDescription.value || undefined,
editFeatured.value,
@@ -434,7 +429,7 @@ const editGalleryItem = async () => {
}
const deleteGalleryImage = async () => {
await deleteGalleryItemMutation(filteredGallery.value[deleteIndex.value].url)
await deleteGalleryItemMutation(project.value.gallery[deleteIndex.value].url)
}
const handleKeydown = (e) => {
@@ -10,236 +10,154 @@
@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 class="label">
<h3>
<span class="label__title size-card-header">Project information</span>
</h3>
</div>
<label for="project-icon">
<span class="label__title">Icon</span>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
aria-label="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove icon
</button>
</div>
</div>
<div>
<label for="project-name">
<span class="label__title">Name</span>
<label for="project-name">
<span class="label__title">Name</span>
</label>
<StyledInput id="project-name" v-model="name" :maxlength="2048" :disabled="!hasPermission" />
<label for="project-slug">
<span class="label__title">URL</span>
</label>
<div class="text-input-wrapper">
<div class="text-input-wrapper__before">
<span class="hidden sm:inline">https://modrinth.com</span>/{{
$getProjectTypeForUrl(project.project_type, project.loaders)
}}/
</div>
<StyledInput
id="project-slug"
v-model="slug"
:maxlength="64"
autocomplete="off"
:disabled="!hasPermission"
/>
</div>
<label for="project-summary">
<span class="label__title">Summary</span>
</label>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
<StyledInput
id="project-summary"
v-model="summary"
multiline
:maxlength="256"
:disabled="!hasPermission"
wrapper-class="summary-input"
/>
<template
v-if="
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
</label>
<StyledInput
id="project-name"
v-model="name"
:maxlength="2048"
<Multiselect
id="project-env-client"
v-model="clientSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
/>
</div>
<div>
<label for="project-slug">
<span class="label__title">URL</span>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<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"
<Multiselect
id="project-env-server"
v-model="serverSide"
class="small-multiselect"
placeholder="Select one"
:options="sideTypes"
:custom-label="(value) => value.charAt(0).toUpperCase() + value.slice(1)"
:searchable="false"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
:disabled="!hasPermission"
resize="vertical"
/>
<div v-if="summaryWarning" class="my-2 flex items-center gap-1.5 text-orange">
<TriangleAlertIcon class="my-auto" />
{{ summaryWarning }}
</div>
</div>
</template>
<div class="adjacent-input">
<label for="project-visibility">
<span class="label__title">Visibility</span>
<div class="label__description">
Public and archived projects are visible in search. Unlisted projects are published, but
not visible in search or on user profiles. Private projects are only accessible by
members of the project.
<div>
<label for="project-icon">
<span class="label__title"
>Icon <span class="font-normal text-secondary">(optional)</span></span
>
</label>
<div class="input-group">
<Avatar
:src="deletedIcon ? null : previewImage ? previewImage : project.icon_url"
:alt="project.title"
size="md"
class="project__icon"
/>
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"
prompt="Upload icon"
aria-label="Upload icon"
:disabled="!hasPermission"
@change="showPreviewImage"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedIcon && (previewImage || project.icon_url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markIconForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove icon
</button>
</div>
</div>
</div>
<!-- Server Project Settings -->
<template v-if="isServerProject">
<!-- Banner -->
<div>
<label>
<span class="label__title"
>Banner <span class="font-normal text-secondary">(optional)</span></span
>
</label>
<div class="mt-2">
<label
class="flex cursor-pointer flex-col items-center justify-center rounded-2xl border-dashed border-surface-5 transition-colors"
:class="
!deletedBanner && (bannerPreview || bannerGalleryImage?.url)
? 'border-none'
: 'aspect-[468/60] border-2 bg-surface-2'
"
>
<div
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
class="relative h-full w-full overflow-hidden rounded-2xl"
>
<img
:src="bannerPreview || bannerGalleryImage?.url"
alt="Banner preview"
class="h-full w-full object-cover"
/>
</div>
<ImageIcon v-else aria-hidden="true" class="h-8 w-8 text-secondary" />
<input
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
class="hidden"
:disabled="!hasPermission"
@change="
(e) => {
const input = e.target
if (input.files?.length) {
if (fileIsValid(input.files[0], { maxSize: 524288, alertOnInvalid: true }))
showBannerPreview(Array.from(input.files))
}
}
"
/>
</label>
</div>
<div class="mt-2 flex items-center gap-2">
<FileInput
:max-size="524288"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="iconified-button"
prompt="Upload banner"
:disabled="!hasPermission"
@change="showBannerPreview"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<button
v-if="!deletedBanner && (bannerPreview || bannerGalleryImage?.url)"
class="iconified-button"
:disabled="!hasPermission"
@click="markBannerForDeletion"
>
<TrashIcon aria-hidden="true" />
Remove banner
</button>
</div>
<div class="mt-2 text-secondary">Gif, 468×60px recommended.</div>
</div>
</template>
<template
v-if="
!isServerProject &&
!flags.newProjectEnvironmentSettings &&
project.versions?.length !== 0 &&
project.project_type !== 'resourcepack' &&
project.project_type !== 'plugin' &&
project.project_type !== 'shader' &&
project.project_type !== 'datapack'
"
>
<div class="adjacent-input">
<label for="project-env-client">
<span class="label__title">Client-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
client side. Just because a mod works in Singleplayer doesn't mean it has actual
client-side functionality.
</span>
</label>
<Combobox
v-model="clientSide"
:options="sideTypeOptions"
placeholder="Select one"
:disabled="!hasPermission"
/>
</div>
<div class="adjacent-input">
<label for="project-env-server">
<span class="label__title">Server-side</span>
<span class="label__description">
Select based on if the
{{ formatProjectType(project.project_type).toLowerCase() }} has functionality on the
<strong>logical</strong> server. Remember that Singleplayer contains an integrated
server.
</span>
</label>
<Combobox
v-model="serverSide"
:options="sideTypeOptions"
placeholder="Select one"
:disabled="!hasPermission"
/>
</div>
</template>
<div>
<label>
<span class="label__title">Visibility</span>
</label>
<div class="flex flex-col gap-2.5">
<Combobox
v-model="visibility"
:options="visibilityOptions"
placeholder="Select one"
:disabled="!hasPermission"
:max-height="500"
/>
<div>If approved by the moderators:</div>
<ul class="visibility-info m-0">
<p>If approved by the moderators:</p>
<ul class="visibility-info">
<li>
<CheckIcon
v-if="visibility === 'approved' || visibility === 'archived'"
@@ -269,7 +187,31 @@
</li>
</ul>
</div>
</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>
@@ -293,21 +235,14 @@
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,
@@ -316,15 +251,13 @@ import {
import { MIN_SUMMARY_CHARS } from '@modrinth/moderation'
import {
Avatar,
Combobox,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
UnsavedChangesPopup,
} from '@modrinth/ui'
import { fileIsValid, formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { formatProjectStatus, formatProjectType } from '@modrinth/utils'
import { Multiselect } from 'vue-multiselect'
import FileInput from '~/components/ui/FileInput.vue'
import { useFeatureFlags } from '~/composables/featureFlags.ts'
@@ -332,13 +265,11 @@ import { useFeatureFlags } from '~/composables/featureFlags.ts'
const { addNotification } = injectNotificationManager()
const {
projectV2: project,
projectV3,
currentMember,
patchProject,
patchIcon,
invalidate,
} = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const flags = useFeatureFlags()
@@ -359,15 +290,6 @@ 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
@@ -389,37 +311,9 @@ const summaryWarning = computed(() => {
return null
})
const sideTypeOptions = [
{ value: 'required', label: 'Required' },
{ value: 'optional', label: 'Optional' },
{ value: 'unsupported', label: 'Unsupported' },
]
const sideTypes = ['required', 'optional', 'unsupported']
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 patchData = computed(() => {
const data = {}
if (name.value !== project.value.title) {
@@ -448,52 +342,9 @@ const basePatchData = computed(() => {
return data
})
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 hasChanges = computed(() => {
return Object.keys(patchData.value).length > 0 || deletedIcon.value || icon.value
})
const hasModifiedVisibility = () => {
const originalVisibility = tags.value.approvedStatuses.includes(project.value.status)
@@ -503,33 +354,17 @@ const hasModifiedVisibility = () => {
return originalVisibility !== visibility.value
}
async function handleSave() {
saving.value = true
try {
const hasV2Changes = Object.keys(basePatchData.value).length > 0
const saveChanges = async () => {
if (hasChanges.value) {
await patchProject(patchData.value)
}
if (hasV2Changes) {
await patchProject(basePatchData.value)
}
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await patchIcon(icon.value)
icon.value = null
}
if (deletedBanner.value) {
await deleteBanner()
deletedBanner.value = false
} else if (bannerFile.value) {
await uploadBanner()
bannerFile.value = null
bannerPreview.value = null
}
} finally {
saving.value = false
if (deletedIcon.value) {
await deleteIcon()
deletedIcon.value = false
} else if (icon.value) {
await patchIcon(icon.value)
icon.value = null
}
}
@@ -543,83 +378,6 @@ 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',
@@ -651,14 +409,10 @@ 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;
@@ -681,6 +435,15 @@ svg {
}
}
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
.small-multiselect {
max-width: 15rem;
}
.button-group {
justify-content: flex-start;
}
@@ -1,84 +1,6 @@
<template>
<div>
<!-- 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">
<section class="universal-card">
<h2>External links</h2>
<div class="adjacent-input">
<label
@@ -252,51 +174,17 @@
<script setup>
import { SaveIcon, TriangleAlertIcon } from '@modrinth/assets'
import { commonLinkDomains, isCommonUrl, isDiscordUrl, isLinkShortener } from '@modrinth/moderation'
import {
DropdownSelect,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
} from '@modrinth/ui'
import { DropdownSelect, injectProjectPageContext, StyledInput } from '@modrinth/ui'
const tags = useGeneratedState()
const {
projectV2: project,
projectV3,
currentMember,
patchProject,
invalidate,
} = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { projectV2: project, currentMember, patchProject } = injectProjectPageContext()
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)
@@ -393,56 +281,6 @@ 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))
@@ -483,7 +321,13 @@ function updateDonationLinks() {
}
function checkDifference(newLink, existingLink) {
return newLink != existingLink
if (newLink === '' && existingLink !== null) {
return true
}
if (!newLink && !existingLink) {
return false
}
return newLink !== existingLink
}
</script>
<style lang="scss" scoped>
@@ -1,408 +0,0 @@
<template>
<div>
<section class="universal-card">
<div class="flex flex-col gap-6">
<div class="text-2xl font-semibold text-contrast">Server details</div>
<!-- Country -->
<div class="max-w-[600px]">
<label for="server-country">
<span class="label__title">Country</span>
</label>
<Combobox
id="server-country"
v-model="country"
:options="countryOptions"
searchable
placeholder="Select country"
:disabled="!hasPermission"
/>
</div>
<!-- Language -->
<div class="max-w-[600px]">
<label for="server-language">
<span class="label__title">Language</span>
</label>
<Multiselect
id="server-language"
v-model="languages"
:options="languageOptions.map((l) => l.value)"
:custom-label="(code) => languageOptions.find((l) => l.value === code)?.label ?? code"
:multiple="true"
:searchable="true"
:show-labels="false"
:close-on-select="false"
placeholder="Select languages"
:disabled="!hasPermission"
/>
</div>
<!-- Java Address -->
<div class="max-w-[600px]">
<label for="java-address">
<span class="label__title !text-contrast">Java address</span>
</label>
<div class="mt-2 flex items-center gap-2" @focusout="pingJavaServer">
<StyledInput
id="java-address"
v-model="javaAddress"
placeholder="Enter address"
:disabled="!hasPermission"
wrapper-class="flex-grow"
autocomplete="off"
/>
<StyledInput
v-model="javaPort"
type="number"
:min="1"
:max="65535"
:disabled="!hasPermission"
wrapper-class="w-24"
input-class="text-center"
autocomplete="off"
/>
</div>
<div
v-if="javaPingResult !== null"
class="mt-2 flex items-center gap-1.5"
:class="javaPingResult.online ? 'text-green' : 'text-orange'"
>
<CheckIcon v-if="javaPingResult.online" class="h-4 w-4" />
<TriangleAlertIcon v-else class="h-4 w-4" />
{{
javaPingResult.online
? `Server is online! ${javaPingResult.latency ? `Latency: ${javaPingResult.latency}ms` : ``}`
: 'Cannot ping server'
}}
</div>
</div>
<!-- Bedrock Address -->
<div class="max-w-[600px]">
<label for="bedrock-address">
<span class="label__title !text-contrast">Bedrock address</span>
</label>
<div class="mt-2 flex items-center gap-2">
<StyledInput
id="bedrock-address"
v-model="bedrockAddress"
placeholder="Enter address"
:disabled="!hasPermission"
wrapper-class="flex-grow"
autocomplete="off"
/>
<StyledInput
v-model="bedrockPort"
type="number"
:min="1"
:max="65535"
:disabled="!hasPermission"
wrapper-class="w-24"
input-class="text-center"
autocomplete="off"
/>
</div>
</div>
<CompatibilityCard />
</div>
</section>
<UnsavedChangesPopup
:original="original"
:modified="modified"
:saving="saving"
@reset="resetChanges"
@save="handleSave"
/>
</div>
</template>
<script setup>
import { CheckIcon, TriangleAlertIcon } from '@modrinth/assets'
import {
Combobox,
injectNotificationManager,
injectProjectPageContext,
StyledInput,
UnsavedChangesPopup,
} from '@modrinth/ui'
import { Multiselect } from 'vue-multiselect'
import CompatibilityCard from '~/components/ui/project-settings/CompatibilityCard.vue'
const { addNotification } = injectNotificationManager()
const { projectV2: project, projectV3, currentMember, patchProjectV3 } = injectProjectPageContext()
const javaAddress = ref('')
const javaPort = ref(25565)
const bedrockAddress = ref('')
const bedrockPort = ref(19132)
const country = ref('')
const languages = ref([])
function initFromProjectV3(v3) {
if (!v3) return
javaAddress.value = v3.minecraft_java_server?.address ?? ''
javaPort.value = v3.minecraft_java_server?.port ?? 25565
bedrockAddress.value = v3.minecraft_bedrock_server?.address ?? ''
bedrockPort.value = v3.minecraft_bedrock_server?.port ?? 19132
country.value = v3.minecraft_server?.country ?? ''
languages.value = v3.minecraft_server?.languages ?? []
}
// initialize projectV3 values once
if (projectV3.value) {
initFromProjectV3(projectV3.value)
} else {
const stop = watch(
() => projectV3.value,
(v3) => {
if (!v3) return
initFromProjectV3(v3)
stop()
},
)
}
const javaPingLoading = ref(false)
const javaPingResult = ref(null)
const pingJavaServer = async () => {
const address = javaAddress.value?.trim()
if (!address) {
javaPingResult.value = null
return
}
javaPingLoading.value = true
javaPingResult.value = null
const port = javaPort.value || 25565
const query = port !== 25565 ? `${address}:${port}` : address
try {
// TODO replace with api-client labrinth server ping route
} catch {
javaPingResult.value = { online: false, latency: null }
} finally {
javaPingLoading.value = false
}
}
const hasPermission = computed(() => {
const EDIT_DETAILS = 1 << 2
return ((currentMember.value?.permissions ?? 0) & EDIT_DETAILS) === EDIT_DETAILS
})
const countryOptions = [
{ value: 'US', label: 'United States' },
{ value: 'CA', label: 'Canada' },
{ value: 'GB', label: 'United Kingdom' },
{ value: 'DE', label: 'Germany' },
{ value: 'FR', label: 'France' },
{ value: 'NL', label: 'Netherlands' },
{ value: 'FI', label: 'Finland' },
{ value: 'SE', label: 'Sweden' },
{ value: 'NO', label: 'Norway' },
{ value: 'DK', label: 'Denmark' },
{ value: 'PL', label: 'Poland' },
{ value: 'CZ', label: 'Czech Republic' },
{ value: 'RO', label: 'Romania' },
{ value: 'CH', label: 'Switzerland' },
{ value: 'AT', label: 'Austria' },
{ value: 'BE', label: 'Belgium' },
{ value: 'IE', label: 'Ireland' },
{ value: 'ES', label: 'Spain' },
{ value: 'IT', label: 'Italy' },
{ value: 'PT', label: 'Portugal' },
{ value: 'RU', label: 'Russia' },
{ value: 'UA', label: 'Ukraine' },
{ value: 'LT', label: 'Lithuania' },
{ value: 'LV', label: 'Latvia' },
{ value: 'EE', label: 'Estonia' },
{ value: 'BG', label: 'Bulgaria' },
{ value: 'HR', label: 'Croatia' },
{ value: 'HU', label: 'Hungary' },
{ value: 'SK', label: 'Slovakia' },
{ value: 'RS', label: 'Serbia' },
{ value: 'GR', label: 'Greece' },
{ value: 'TR', label: 'Turkey' },
{ value: 'IL', label: 'Israel' },
{ value: 'AE', label: 'United Arab Emirates' },
{ value: 'SA', label: 'Saudi Arabia' },
{ value: 'IN', label: 'India' },
{ value: 'SG', label: 'Singapore' },
{ value: 'JP', label: 'Japan' },
{ value: 'KR', label: 'South Korea' },
{ value: 'CN', label: 'China' },
{ value: 'HK', label: 'Hong Kong' },
{ value: 'TW', label: 'Taiwan' },
{ value: 'AU', label: 'Australia' },
{ value: 'NZ', label: 'New Zealand' },
{ value: 'BR', label: 'Brazil' },
{ value: 'AR', label: 'Argentina' },
{ value: 'CL', label: 'Chile' },
{ value: 'CO', label: 'Colombia' },
{ value: 'MX', label: 'Mexico' },
{ value: 'ZA', label: 'South Africa' },
{ value: 'NG', label: 'Nigeria' },
{ value: 'KE', label: 'Kenya' },
{ value: 'EG', label: 'Egypt' },
{ value: 'MY', label: 'Malaysia' },
{ value: 'TH', label: 'Thailand' },
{ value: 'VN', label: 'Vietnam' },
{ value: 'PH', label: 'Philippines' },
{ value: 'ID', label: 'Indonesia' },
{ value: 'PK', label: 'Pakistan' },
{ value: 'BD', label: 'Bangladesh' },
]
const languageOptions = [
{ value: 'en', label: 'English' },
{ value: 'es', label: 'Spanish' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'fr', label: 'French' },
{ value: 'de', label: 'German' },
{ value: 'it', label: 'Italian' },
{ value: 'nl', label: 'Dutch' },
{ value: 'ru', label: 'Russian' },
{ value: 'uk', label: 'Ukrainian' },
{ value: 'pl', label: 'Polish' },
{ value: 'cs', label: 'Czech' },
{ value: 'sk', label: 'Slovak' },
{ value: 'hu', label: 'Hungarian' },
{ value: 'ro', label: 'Romanian' },
{ value: 'bg', label: 'Bulgarian' },
{ value: 'hr', label: 'Croatian' },
{ value: 'sr', label: 'Serbian' },
{ value: 'el', label: 'Greek' },
{ value: 'tr', label: 'Turkish' },
{ value: 'ar', label: 'Arabic' },
{ value: 'he', label: 'Hebrew' },
{ value: 'hi', label: 'Hindi' },
{ value: 'bn', label: 'Bengali' },
{ value: 'ur', label: 'Urdu' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'th', label: 'Thai' },
{ value: 'vi', label: 'Vietnamese' },
{ value: 'id', label: 'Indonesian' },
{ value: 'ms', label: 'Malay' },
{ value: 'tl', label: 'Filipino' },
{ value: 'sv', label: 'Swedish' },
{ value: 'no', label: 'Norwegian' },
{ value: 'da', label: 'Danish' },
{ value: 'fi', label: 'Finnish' },
{ value: 'lt', label: 'Lithuanian' },
{ value: 'lv', label: 'Latvian' },
{ value: 'et', label: 'Estonian' },
]
const javaServerPatchData = computed(() => {
const origJava = projectV3.value?.minecraft_java_server
const addressChanged =
javaAddress.value !== (origJava?.address ?? '') ||
javaPort.value !== (origJava?.port ?? 25565)
if (addressChanged) {
return {
address: javaAddress.value.trim(),
port: javaPort.value,
}
}
return {}
})
const bedrockServerPatchData = computed(() => {
const origBedrock = projectV3.value?.minecraft_bedrock_server
if (
bedrockAddress.value !== (origBedrock?.address ?? '') ||
bedrockPort.value !== (origBedrock?.port ?? 19132)
) {
return {
address: bedrockAddress.value.trim(),
port: bedrockPort.value,
}
}
return {}
})
const serverPatchData = computed(() => {
const origServer = projectV3.value?.minecraft_server
const countryChanged = country.value && country.value !== origServer?.country
const languagesChanged =
JSON.stringify([...languages.value].sort()) !==
JSON.stringify([...(origServer?.languages ?? [])].sort())
if (countryChanged || languagesChanged) {
return {
...origServer,
...(countryChanged ? { country: country.value } : {}),
...(languagesChanged ? { languages: languages.value } : {}),
}
}
return {}
})
const v3PatchData = computed(() => {
const data = {}
if (Object.keys(serverPatchData.value).length > 0) {
data.minecraft_server = serverPatchData.value
}
if (Object.keys(javaServerPatchData.value).length > 0) {
data.minecraft_java_server = javaServerPatchData.value
}
if (Object.keys(bedrockServerPatchData.value).length > 0) {
data.minecraft_bedrock_server = bedrockServerPatchData.value
}
return data
})
const saving = ref(false)
const original = computed(() => ({
javaAddress: projectV3.value?.minecraft_java_server?.address ?? '',
javaPort: projectV3.value?.minecraft_java_server?.port ?? 25565,
bedrockAddress: projectV3.value?.minecraft_bedrock_server?.address ?? '',
bedrockPort: projectV3.value?.minecraft_bedrock_server?.port ?? 19132,
country: projectV3.value?.minecraft_server?.country ?? '',
languages: projectV3.value?.minecraft_server?.languages ?? [],
}))
const modified = computed(() => ({
javaAddress: javaAddress.value,
javaPort: javaPort.value,
bedrockAddress: bedrockAddress.value,
bedrockPort: bedrockPort.value,
country: country.value,
languages: languages.value,
}))
function resetChanges() {
javaAddress.value = projectV3.value?.minecraft_java_server?.address ?? ''
javaPort.value = projectV3.value?.minecraft_java_server?.port ?? 25565
bedrockAddress.value = projectV3.value?.minecraft_bedrock_server?.address ?? ''
bedrockPort.value = projectV3.value?.minecraft_bedrock_server?.port ?? 19132
country.value = projectV3.value?.minecraft_server?.country ?? ''
languages.value = projectV3.value?.minecraft_server?.languages ?? []
}
async function handleSave() {
saving.value = true
try {
const hasV3Changes = Object.keys(v3PatchData.value).length > 0
if (hasV3Changes) {
await patchProjectV3(v3PatchData.value)
}
} finally {
saving.value = false
}
}
</script>
@@ -31,10 +31,7 @@
that apply.
</p>
<p
v-if="project.versions.length === 0 && projectV3?.minecraft_server == null"
class="known-errors"
>
<p v-if="project.versions.length === 0" class="known-errors">
Please upload a version first in order to select tags!
</p>
<template v-else>
@@ -159,28 +156,24 @@ interface Category {
const tags = useGeneratedState()
const { formatMessage, locale } = useVIntl()
const { projectV2: project, projectV3, patchProject } = injectProjectPageContext()
const { projectV2: project, 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) =>
matchesProjectType(x) &&
x.project_type === project.value.actualProjectType &&
(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) => matchesProjectType(x) && project.value.categories.includes(x.name),
(x: Category) =>
x.project_type === project.value.actualProjectType &&
project.value.categories.includes(x.name),
) as Category[],
}),
async () => {
@@ -224,7 +217,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 (matchesProjectType(x)) {
if (x.project_type === project.value.actualProjectType) {
const header = x.header
if (!lists[header]) {
lists[header] = []
@@ -237,13 +230,7 @@ const categoryLists = computed(() => {
const tooManyTagsWarning = computed(() => {
const tagCount = current.value.selectedTags.length
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) {
if (tagCount > 8) {
return `You've selected ${tagCount} tags. Consider reducing to 8 or fewer to keep your project focused and easier to discover.`
}
return null
@@ -208,7 +208,7 @@
</ProjectPageVersions>
<template v-if="!versions?.length">
<div class="grid place-items-center py-10">
<div class="grid place-content-center py-10">
<svg
width="250"
height="200"
+1 -1
View File
@@ -20,7 +20,7 @@
link: '/dashboard/affiliate-links',
label: formatMessage(commonMessages.affiliateLinksButton),
icon: AffiliateIcon,
shown: !!isAffiliate,
shown: isAffiliate,
},
{ link: '/dashboard/revenue', label: 'Revenue', icon: CurrencyIcon, matchNested: true },
]"
+1 -12
View File
@@ -159,10 +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($event, { showTypeSelector: true })"
>
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
@@ -457,14 +454,6 @@ 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' &&
+124 -272
View File
@@ -24,7 +24,6 @@ import {
DropdownSelect,
injectModrinthClient,
injectNotificationManager,
OpenInAppModal,
Pagination,
ProjectCard,
ProjectCardList,
@@ -34,12 +33,11 @@ import {
StyledInput,
Toggle,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { templateRef, useThrottleFn, useTimeoutFn } from '@vueuse/core'
import { useThrottleFn, useTimeoutFn } from '@vueuse/core'
import { computed, type Reactive, watch } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
@@ -86,10 +84,6 @@ 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] : []))
@@ -105,7 +99,6 @@ const resultsDisplayMode = computed<DisplayMode>(() =>
const server = ref<Reactive<ModrinthServer>>()
const serverHideInstalled = ref(false)
const eraseDataOnInstall = ref(false)
const serverInAppModal = templateRef('openInAppModal')
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
@@ -343,28 +336,6 @@ 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,
@@ -372,12 +343,7 @@ const {
} = useLazyFetch(
() => {
const config = useRuntimeConfig()
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}`
}
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
return `${base}search${requestParams.value}`
},
@@ -429,7 +395,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
const params = {
...persistentParams,
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
...createPageParams(),
}
router.replace({ path: route.path, query: params })
@@ -440,12 +406,6 @@ watch([currentFilters], () => {
updateSearchResults(1, false)
})
watch([serverCurrentFilters, serverCurrentSortType], () => {
if (currentType.value === 'server') {
updateSearchResults(1, false)
}
})
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
function cycleSearchDisplayMode() {
@@ -484,45 +444,6 @@ useSeoMeta({
ogTitle,
ogDescription: description,
})
const serverHits = computed(
() =>
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
)
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
const content = hit.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick:
project_id !== hit.project_id
? () => {
navigateTo(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === hit.project_id,
}
}
return undefined
}
function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject) {
serverInAppModal.value?.show({
serverProject: {
name: project.name,
slug: project.slug ?? '',
numPlayers: project.minecraft_java_server?.ping?.data?.players_online,
icon: project.icon_url ?? '',
statusOnline: !!project.minecraft_java_server?.ping?.data,
region: project.minecraft_server?.country,
},
})
}
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
@@ -626,66 +547,41 @@ function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject
@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}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</template>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</div>
</aside>
<section class="normal-page__content">
@@ -705,10 +601,10 @@ function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="effectiveSortType"
v-model="currentSortType"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="effectiveSortTypes"
:options="[...sortTypes]"
:display-name="(option?: SortType) => option?.display"
@change="updateSearchResults()"
>
@@ -754,14 +650,6 @@ function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject
/>
</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"
@@ -769,14 +657,7 @@ function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject
:provided-message="messages.providedByServer"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div
v-else-if="
currentType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="no-results"
>
<div v-else-if="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">
@@ -786,117 +667,90 @@ function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject
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"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
:author="{ name: result.author, link: `/user/${result.author}` }"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="deprioritizedTags"
:exclude-loaders="excludeLoaders"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
}
: undefined
"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
<DownloadIcon />
Download
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</template>
<template v-else-if="server">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
>
<CheckIcon />
Installed
</button>
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
Installing...
</button>
<button v-else @click="serverInstall(result as InstallableSearchResult)">
<DownloadIcon />
Install
</button>
</ButtonStyled>
</template>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
:author="{ name: result.author, link: `/user/${result.author}` }"
:date-updated="result.date_modified"
:date-published="result.date_created"
:displayed-date="currentSortType.name === 'newest' ? 'published' : 'updated'"
:downloads="result.downloads"
:summary="result.description"
:tags="result.display_categories"
:all-tags="result.categories"
:deprioritized-tags="deprioritizedTags"
:exclude-loaders="excludeLoaders"
:followers="result.follows"
:banner="result.featured_gallery ?? undefined"
:color="result.color ?? undefined"
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side,
serverSide: result.server_side,
}
: undefined
"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
<DownloadIcon />
Download
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<HeartIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button>
<BookmarkIcon />
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</template>
</ProjectCard>
</template>
<template v-else-if="server">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
"
disabled
>
<CheckIcon />
Installed
</button>
<button v-else-if="(result as InstallableSearchResult).installing" disabled>
Installing...
</button>
<button v-else @click="serverInstall(result as InstallableSearchResult)">
<DownloadIcon />
Install
</button>
</ButtonStyled>
</template>
</template>
</ProjectCard>
</ProjectCardList>
</div>
<div class="pagination-after">
@@ -909,8 +763,6 @@ function handleServerProjectPlay(project: Labrinth.Search.v3.ResultSearchProject
</div>
</div>
</section>
<OpenInAppModal ref="openInAppModal" />
</template>
<style lang="scss" scoped>
.normal-page__content {
@@ -10,5 +10,8 @@ useHead({
</script>
<template>
<ServersManageFilesPage :show-debug-info="flags.advancedDebugInfo" />
<ServersManageFilesPage
:show-debug-info="flags.advancedDebugInfo"
:show-refresh-button="flags.FilesRefreshButton"
/>
</template>
+3 -6
View File
@@ -221,7 +221,6 @@ 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')
@@ -283,17 +282,15 @@ 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) ||
(projectType === 'minecraft_java_server' &&
queueItem.project.project_types.includes('minecraft_java_server')),
queueItem.project.project_types.length > 0 &&
queueItem.project.project_types[0] === projectType,
)
})
@@ -218,7 +218,7 @@ const onDeleteOrganization = useClientTry(async () => {
multiline
:maxlength="256"
:disabled="!hasPermission"
resize="vertical"
wrapper-class="summary-input"
/>
</div>
<div class="universal-card">
@@ -245,3 +245,10 @@ const onDeleteOrganization = useClientTry(async () => {
/>
</div>
</template>
<style scoped lang="scss">
.summary-input {
min-height: 8rem;
max-width: 24rem;
}
</style>
@@ -159,10 +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($event, { showTypeSelector: true })"
>
<button class="iconified-button brand-button" @click="$refs.modal_creation.show()">
<PlusIcon />
{{ formatMessage(commonMessages.createAProjectButton) }}
</button>
-1
View File
@@ -48,7 +48,6 @@ export default defineNuxtPlugin({
modpack: 'list',
shader: 'gallery',
datapack: 'list',
server: 'list',
user: 'list',
collection: 'list',
},
@@ -1,425 +0,0 @@
import type { Labrinth, UploadProgress } from '@modrinth/api-client'
import { ArrowLeftRightIcon, LeftArrowIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
createContext,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
type MultiStageModal,
type StageConfigInput,
} from '@modrinth/ui'
import JSZip from 'jszip'
import type { Ref, ShallowRef } from 'vue'
import { markRaw, toRaw } from 'vue'
import type { ComponentExposed } from 'vue-component-type-helpers'
import SelectCompatibilityType from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectCompatibilityType.vue'
import SelectPublishedModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue'
import SelectVanillaVersions from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectVanillaVersions.vue'
import UploadCustomModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/UploadCustomModpack.vue'
export type CompatibilityType = 'vanilla' | 'published-modpack' | 'custom-modpack'
export interface ServerCompatibilityContextValue {
// Stage management
stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[]
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>
isSubmitting: Ref<boolean>
isUploading: Ref<boolean>
uploadProgress: Ref<UploadProgress>
// State
compatibilityType: Ref<CompatibilityType | null>
selectedProjectId: Ref<string>
selectedVersionId: Ref<string>
supportedGameVersions: Ref<string[]>
recommendedGameVersion: Ref<string | null>
customModpackFile: Ref<File | null>
hasLicensePermission: Ref<boolean>
isEditingExistingCompatibility: Ref<boolean>
isSwitchingCompatibilityType: Ref<boolean>
// Actions
resetContext: () => void
handleSave: () => Promise<void>
}
export const [injectServerCompatibilityContext, provideServerCompatibilityContext] =
createContext<ServerCompatibilityContextValue>('ServerCompatibilityModal')
export function createServerCompatibilityContext(
modal: ShallowRef<ComponentExposed<typeof MultiStageModal> | null>,
): ServerCompatibilityContextValue {
const { projectV3, patchProjectV3 } = injectProjectPageContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const isSubmitting = ref(false)
const isUploading = ref(false)
const uploadProgress = ref<UploadProgress>({ loaded: 0, total: 0, progress: 0 })
const compatibilityType = ref<CompatibilityType | null>(null)
const selectedProjectId = ref('')
const selectedVersionId = ref('')
const supportedGameVersions = ref<string[]>([])
const recommendedGameVersion = ref<string | null>(null)
const customModpackFile = ref<File | null>(null)
const hasLicensePermission = ref(false)
const isEditingExistingCompatibility = ref(false)
const isSwitchingCompatibilityType = ref(false)
async function uploadCustomModpackFile(file: File): Promise<Labrinth.Versions.v3.Version> {
const rawFile = toRaw(file)
// Default to filename if we can't parse the mrpack
let versionName = rawFile.name.replace(/\.(zip|mrpack)$/i, '')
let versionNumber = versionName
const loaders: string[] = []
let gameVersions: string[] = []
try {
const zip = await JSZip.loadAsync(rawFile)
const indexFile = zip.file('modrinth.index.json')
if (indexFile) {
const indexContent = await indexFile.async('text')
const metadata = JSON.parse(indexContent) as {
name?: string
versionId?: string
dependencies?: Record<string, string>
}
if (metadata.name) {
versionName = metadata.name
}
if (metadata.versionId) {
versionNumber = metadata.versionId
}
if (metadata.dependencies) {
if ('forge' in metadata.dependencies) loaders.push('forge')
if ('neoforge' in metadata.dependencies) loaders.push('neoforge')
if ('fabric-loader' in metadata.dependencies) loaders.push('fabric')
if ('quilt-loader' in metadata.dependencies) loaders.push('quilt')
if (metadata.dependencies.minecraft) {
gameVersions = [metadata.dependencies.minecraft]
}
}
}
} catch {
console.warn('Could not parse modrinth.index.json from mrpack')
}
const draftVersion: Labrinth.Versions.v3.DraftVersion = {
project_id: projectV3.value.id,
name: versionName,
version_number: versionNumber,
version_type: 'release',
loaders,
game_versions: gameVersions,
featured: false,
status: 'listed',
changelog: '',
dependencies: [],
environment: 'client_and_server',
}
const files: Labrinth.Versions.v3.DraftVersionFile[] = [{ file: rawFile, fileType: undefined }]
const uploadHandle = labrinth.versions_v3.createVersion(draftVersion, files, 'modpack')
uploadHandle.onProgress((progress) => {
uploadProgress.value = progress
})
return await uploadHandle.promise
}
async function handleSave() {
isSubmitting.value = true
setTimeout(() => {
if (compatibilityType.value === 'custom-modpack' && isSubmitting.value === true) {
isUploading.value = true
uploadProgress.value = { loaded: 0, total: 0, progress: 0 }
}
}, 1500)
try {
switch (compatibilityType.value) {
case 'vanilla':
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'vanilla',
supported_game_versions: supportedGameVersions.value,
recommended_game_version: recommendedGameVersion.value,
},
},
})
break
case 'published-modpack':
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'modpack',
version_id: selectedVersionId.value,
},
},
})
break
case 'custom-modpack': {
if (!customModpackFile.value) break
// Upload the modpack file as a new version
let uploadedVersion: Labrinth.Versions.v3.Version
uploadedVersion = await uploadCustomModpackFile(customModpackFile.value)
// Patch the project to point to the newly uploaded version
try {
await patchProjectV3({
minecraft_java_server: {
content: {
kind: 'modpack',
version_id: uploadedVersion.id,
},
},
})
} catch (err) {
// If patch fails, clean up the uploaded version
try {
await labrinth.versions_v3.deleteVersion(uploadedVersion.id)
} catch {
console.error('Failed to clean up uploaded version after patch failure')
}
throw err
}
break
}
}
isUploading.value = false
isSubmitting.value = false
await nextTick()
modal.value?.hide()
} catch (err) {
isUploading.value = false
isSubmitting.value = false
const error = err as { data?: { description?: string } }
addNotification({
title: 'Failed to save server compatibility',
text: error.data?.description || String(err),
type: 'error',
})
}
}
function resetContext() {
compatibilityType.value = null
selectedProjectId.value = ''
selectedVersionId.value = ''
supportedGameVersions.value = []
recommendedGameVersion.value = null
customModpackFile.value = null
hasLicensePermission.value = false
isEditingExistingCompatibility.value = false
isSwitchingCompatibilityType.value = false
}
return {
stageConfigs,
modal,
isSubmitting,
isUploading,
uploadProgress,
compatibilityType,
selectedProjectId,
selectedVersionId,
supportedGameVersions,
recommendedGameVersion,
customModpackFile,
hasLicensePermission,
isEditingExistingCompatibility,
isSwitchingCompatibilityType,
resetContext,
handleSave,
}
}
const selectCompatibilityTypeStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-compatibility-type',
stageContent: markRaw(SelectCompatibilityType),
title: 'Compatibility type',
cannotNavigateForward: (ctx) => !ctx.compatibilityType.value,
leftButtonConfig: null,
rightButtonConfig: null,
}
const selectVanillaVersionsStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-vanilla-versions',
stageContent: markRaw(SelectVanillaVersions),
title: 'Vanilla versions',
skip: (ctx) => ctx.compatibilityType.value !== 'vanilla' && !!ctx.compatibilityType.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: 'Change type',
icon: ArrowLeftRightIcon,
iconPosition: 'before' as const,
color: 'red' as const,
disabled:
ctx.isSubmitting.value ||
ctx.supportedGameVersions.value.length === 0 ||
!ctx.recommendedGameVersion.value,
onClick: () => ctx.handleSave(),
}
: {
label: ctx.isSubmitting.value
? ctx.isEditingExistingCompatibility.value
? 'Updating…'
: 'Saving…'
: ctx.isEditingExistingCompatibility.value
? 'Save changes'
: 'Save',
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value ||
ctx.supportedGameVersions.value.length === 0 ||
!ctx.recommendedGameVersion.value,
onClick: () => ctx.handleSave(),
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
const selectPublishedModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'select-published-modpack',
stageContent: markRaw(SelectPublishedModpack),
title: 'Select modpack',
skip: (ctx) => ctx.compatibilityType.value !== 'published-modpack',
cannotNavigateForward: (ctx) => !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: 'Change type',
icon: ArrowLeftRightIcon,
iconPosition: 'before' as const,
color: 'red' as const,
disabled:
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
onClick: () => ctx.handleSave(),
}
: {
label: ctx.isSubmitting.value
? ctx.isEditingExistingCompatibility.value
? 'Updating…'
: 'Saving…'
: ctx.isEditingExistingCompatibility.value
? 'Save changes'
: 'Save',
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value,
onClick: () => ctx.handleSave(),
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
function getUploadLabel(ctx: ServerCompatibilityContextValue): string {
if (ctx.isUploading.value) {
if (ctx.uploadProgress.value.progress >= 1) {
return 'Saving…'
}
return `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
}
if (ctx.isSubmitting.value) {
return ctx.isEditingExistingCompatibility.value ? 'Updating…' : 'Saving…'
}
return ctx.isEditingExistingCompatibility.value ? 'Save changes' : 'Save'
}
const uploadCustomModpackStage: StageConfigInput<ServerCompatibilityContextValue> = {
id: 'upload-custom-modpack',
stageContent: markRaw(UploadCustomModpack),
title: 'Custom modpack',
skip: (ctx) => ctx.compatibilityType.value !== 'custom-modpack',
disableClose: (ctx) => ctx.isUploading.value,
leftButtonConfig: (ctx) =>
ctx.isEditingExistingCompatibility.value
? {
label: 'Cancel',
icon: XIcon,
disabled: ctx.isUploading.value,
onClick: () => ctx.modal.value?.hide(),
}
: {
label: 'Back',
icon: LeftArrowIcon,
disabled: ctx.isUploading.value,
onClick: () => ctx.modal.value?.prevStage(),
},
rightButtonConfig: (ctx) =>
ctx.isSwitchingCompatibilityType.value
? {
label: ctx.isUploading.value
? `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%`
: 'Change type',
icon: ctx.isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'red' as const,
disabled:
ctx.isSubmitting.value ||
!ctx.customModpackFile.value ||
!ctx.hasLicensePermission.value,
onClick: () => ctx.handleSave(),
buttonClass: 'tabular-nums',
}
: {
label: getUploadLabel(ctx),
icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon,
iconPosition: 'before' as const,
iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined,
color: 'green' as const,
disabled:
ctx.isSubmitting.value ||
!ctx.customModpackFile.value ||
!ctx.hasLicensePermission.value,
onClick: () => ctx.handleSave(),
buttonClass: 'tabular-nums',
},
nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value,
}
const stageConfigs: StageConfigInput<ServerCompatibilityContextValue>[] = [
selectCompatibilityTypeStage,
selectVanillaVersionsStage,
selectPublishedModpackStage,
uploadCustomModpackStage,
]
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
@@ -41,14 +41,6 @@ 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',
+20
View File
@@ -13,6 +13,10 @@ export interface FieldConfig {
pattern?: string
validate?: (value: string) => string | null
autocomplete?: string
dependsOn?: {
field: string
value?: string | string[]
}
}
export interface RailConfig {
@@ -330,6 +334,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Enter PIX email',
}),
autocomplete: 'email',
dependsOn: {
field: 'pixAccountType',
value: 'EMAIL',
},
},
{
name: 'pixPhone',
@@ -341,6 +349,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: '+55...',
}),
autocomplete: 'tel',
dependsOn: {
field: 'pixAccountType',
value: 'PHONE',
},
},
{
name: 'branchCode',
@@ -352,6 +364,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Enter branch code',
}),
autocomplete: 'off',
dependsOn: {
field: 'pixAccountType',
value: 'BANK_ACCOUNT',
},
},
{
name: 'documentNumber',
@@ -367,6 +383,10 @@ export const MURALPAY_RAILS: Record<string, RailConfig> = {
defaultMessage: 'Brazilian tax identification number',
}),
autocomplete: 'off',
dependsOn: {
field: 'pixAccountType',
value: 'DOCUMENT',
},
},
],
},
-2
View File
@@ -157,5 +157,3 @@ MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
-3
View File
@@ -167,6 +167,3 @@ MURALPAY_TRANSFER_API_KEY=none
MURALPAY_SOURCE_ACCOUNT_ID=00000000-0000-0000-0000-000000000000
DEFAULT_AFFILIATE_REVENUE_SPLIT=0.1
SERVER_PING_TIMEOUT=10000
SERVER_PING_RETRIES=3
SERVER_PING_MIN_INTERVAL_SEC=1800
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n 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 ",
"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 ",
"describe": {
"columns": [
{
@@ -67,11 +67,6 @@
"ordinal": 12,
"name": "ordering",
"type_info": "Int4"
},
{
"ordinal": 13,
"name": "components: sqlx::types::Json<exp::VersionSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -92,9 +87,8 @@
false,
false,
true,
true,
false
true
]
},
"hash": "760df5118dcd1f9d28db991caabb3c1ce6fb8ac08829345445b027e5a46c4860"
"hash": "32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6"
}
@@ -1,15 +0,0 @@
{
"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"
}
@@ -1,26 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id, components AS \"components: Json<exp::ProjectSerial>\"\n FROM mods\n WHERE components ? 'minecraft_java_server'\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "components: Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": []
},
"nullable": [
false,
false
]
},
"hash": "591bf29b883b66b111496f8c07ebd2619cd3bcf92f637ca99876c14bd44c85f6"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.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 ",
"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 ",
"describe": {
"columns": [
{
@@ -62,11 +62,6 @@
"ordinal": 11,
"name": "color",
"type_info": "Int4"
},
{
"ordinal": 12,
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -88,9 +83,8 @@
false,
false,
true,
true,
false
true
]
},
"hash": "b760951120c1885530f3fb8208a5874fc472ec160a673abeeee10c778b9cf23f"
"hash": "702a2826d5857dc51b1a7a79c9043ae8987441bb5e89c9ea62d347e47899e3c2"
}
@@ -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 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 ",
"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 ",
"describe": {
"columns": [
{
@@ -137,11 +137,6 @@
"ordinal": 26,
"name": "additional_categories",
"type_info": "VarcharArray"
},
{
"ordinal": 27,
"name": "components: sqlx::types::Json<exp::ProjectSerial>",
"type_info": "Jsonb"
}
],
"parameters": {
@@ -177,9 +172,8 @@
false,
false,
null,
null,
false
null
]
},
"hash": "59b6eea93ce248d2b1eaf14fe8970bba8ccd2df0995a21bdb34ae3214cef6377"
"hash": "7a6d6a91e6bd27f7be34b8cc7955a66c4175ebd1c55e437f187f61efca681c62"
}
@@ -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 components\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12,\n $13\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 )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ",
"describe": {
"columns": [],
"parameters": {
@@ -16,11 +16,10 @@
"Varchar",
"Bool",
"Varchar",
"Int4",
"Jsonb"
"Int4"
]
},
"nullable": []
},
"hash": "07dff8c7711178f10bf85271287704deb72636f4cbd6927108aadfd6e0d63f1c"
"hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce"
}
@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "SELECT EXISTS(\n SELECT 1 FROM mods WHERE slug = $1 OR text_id_lower = $1\n )",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "exists",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
null
]
},
"hash": "b562b547271c94a1ea3db93c7b75ff7d7d79642345c93002dd774d7b30ac3d66"
}
@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n 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 ",
"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 ",
"describe": {
"columns": [],
"parameters": {
@@ -22,11 +22,10 @@
"Int4",
"Varchar",
"Int8",
"Varchar",
"Jsonb"
"Varchar"
]
},
"nullable": []
},
"hash": "cf0ce4ce54edc7533332f0bfab27d977a9613f8aa22669c0f8fe7bab2d5d6192"
"hash": "ee74bbff42dd29ab5a23d5811ea18e62ac199fe5e68275bf1bc7c71ace630702"
}
@@ -1,28 +0,0 @@
{
"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"
}
@@ -1,40 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT\n v.id AS \"version_id: DBVersionId\",\n m.id AS \"project_id: DBProjectId\",\n m.name AS \"project_name!\",\n COALESCE(m.icon_url, '') AS \"project_icon!\"\n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n WHERE v.id = ANY($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "version_id: DBVersionId",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "project_id: DBProjectId",
"type_info": "Int8"
},
{
"ordinal": 2,
"name": "project_name!",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "project_icon!",
"type_info": "Varchar"
}
],
"parameters": {
"Left": [
"Int8Array"
]
},
"nullable": [
false,
false,
false,
null
]
},
"hash": "fc99699ff89c3245c7288718ff15d16058b00bf5df286923c0c6b1db2dbbde4f"
}
-1
View File
@@ -21,7 +21,6 @@ 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",
@@ -1,5 +0,0 @@
-- mistake from previous migration `20260218183440_server_project_links_categories`
DELETE FROM categories
WHERE
header = 'minecraft_server_community'
AND category = 'adventure-mode';
+87 -108
View File
@@ -1,6 +1,5 @@
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::{
@@ -12,8 +11,7 @@ use crate::search::indexing::index_projects;
use crate::util::anrok;
use crate::{database, search};
use clap::ValueEnum;
use eyre::WrapErr;
use tracing::info;
use tracing::{error, info, warn};
#[derive(ValueEnum, Debug, Copy, Clone, PartialEq, Eq)]
#[clap(rename_all = "kebab_case")]
@@ -27,12 +25,6 @@ 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 {
@@ -48,7 +40,7 @@ impl BackgroundTask {
anrok_client: anrok::Client,
email_queue: EmailQueue,
mural_client: muralpay::Client,
) -> eyre::Result<()> {
) {
use BackgroundTask::*;
match self {
Migrations => run_migrations().await,
@@ -70,7 +62,7 @@ impl BackgroundTask {
)
.await;
update_bank_balances(pool).await
update_bank_balances(pool).await;
}
IndexSubscriptions => {
index_subscriptions(
@@ -79,69 +71,71 @@ impl BackgroundTask {
stripe_client,
anrok_client,
)
.await;
Ok(())
.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
Mail => {
run_email(email_queue).await;
}
}
}
}
pub async fn run_email(email_queue: EmailQueue) -> eyre::Result<()> {
pub async fn run_email(email_queue: EmailQueue) {
// 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();
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;
match email_queue.index(5).await {
Ok(true) => {
info!(
"Indexed email queue in {}ms",
then.elapsed().as_millis()
);
}
Ok(false) => {
info!("No more emails to index");
break;
}
Err(error) => {
error!(%error, "Failed to index email queue");
}
}
}
Ok(())
}
pub async fn update_bank_balances(pool: PgPool) -> eyre::Result<()> {
pub async fn update_bank_balances(pool: PgPool) {
let payouts_queue = PayoutsQueue::new();
insert_bank_balances_and_webhook(&payouts_queue, &pool)
.await
.wrap_err("failed to update bank balances")?;
info!("Bank balances updated successfully");
Ok(())
match insert_bank_balances_and_webhook(&payouts_queue, &pool).await {
Ok(_) => info!("Bank balances updated successfully"),
Err(error) => error!(%error, "Bank balances update failed"),
}
}
pub async fn run_migrations() -> eyre::Result<()> {
database::check_for_migrations().await?;
Ok(())
pub async fn run_migrations() {
database::check_for_migrations()
.await
.expect("An error occurred while running migrations.");
}
pub async fn index_search(
ro_pool: PgPool,
redis_pool: RedisPool,
search_config: search::SearchConfig,
) -> eyre::Result<()> {
) {
info!("Indexing local database");
index_projects(ro_pool, redis_pool, &search_config).await
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");
}
pub async fn release_scheduled(pool: PgPool) -> eyre::Result<()> {
pub async fn release_scheduled(pool: PgPool) {
info!("Releasing scheduled versions/projects!");
sqlx::query!(
let projects_results = sqlx::query!(
"
UPDATE mods
SET status = requested_status
@@ -149,11 +143,14 @@ pub async fn release_scheduled(pool: PgPool) -> eyre::Result<()> {
",
crate::models::projects::ProjectStatus::Scheduled.as_str(),
)
.execute(&pool)
.await
.wrap_err("failed syncing scheduled releases for projects")?;
.execute(&pool)
.await;
sqlx::query!(
if let Err(e) = projects_results {
warn!("Syncing scheduled releases for projects failed: {:?}", e);
}
let versions_results = sqlx::query!(
"
UPDATE versions
SET status = requested_status
@@ -161,96 +158,78 @@ pub async fn release_scheduled(pool: PgPool) -> eyre::Result<()> {
",
crate::models::projects::VersionStatus::Scheduled.as_str(),
)
.execute(&pool)
.await
.wrap_err("failed syncing scheduled releases for versions")?;
.execute(&pool)
.await;
if let Err(e) = versions_results {
warn!("Syncing scheduled releases for versions failed: {:?}", e);
}
info!("Finished releasing scheduled versions/projects");
Ok(())
}
pub async fn update_versions(
pool: PgPool,
redis_pool: RedisPool,
) -> eyre::Result<()> {
pub async fn update_versions(pool: PgPool, redis_pool: RedisPool) {
info!("Indexing game versions list from Mojang");
version_updater::update_versions(&pool, &redis_pool)
.await
.wrap_err("failed to update game versions")?;
let result = version_updater::update_versions(&pool, &redis_pool).await;
if let Err(e) = result {
warn!("Version update failed: {}", e);
}
info!("Done indexing game versions");
Ok(())
}
pub async fn payouts(
pool: PgPool,
clickhouse: clickhouse::Client,
redis_pool: RedisPool,
) -> eyre::Result<()> {
) {
info!("Started running payouts");
process_payout(&pool, &clickhouse)
.await
.wrap_err("payout processing failed")?;
let result = process_payout(&pool, &clickhouse).await;
if let Err(e) = result {
warn!("Payouts run failed: {e:#?}");
}
index_payouts_notifications(&pool, &redis_pool)
.await
.wrap_err("payout notifications indexing failed")?;
let result = index_payouts_notifications(&pool, &redis_pool).await;
if let Err(e) = result {
warn!("Payouts notifications indexing failed: {e:#?}");
}
process_affiliate_payouts(&pool)
.await
.wrap_err("affiliate payouts processing failed")?;
let result = process_affiliate_payouts(&pool).await;
if let Err(e) = result {
warn!("Affiliate payouts run failed: {e:#?}");
}
remove_payouts_for_refunded_charges(&pool)
.await
.wrap_err("removing payouts for refunded charges failed")?;
let result = remove_payouts_for_refunded_charges(&pool).await;
if let Err(e) = result {
warn!("Removing affiliate payouts for refunded charges failed: {e:#?}");
}
info!("Done running payouts");
Ok(())
}
pub async fn sync_payout_statuses(
pool: PgPool,
mural: muralpay::Client,
) -> eyre::Result<()> {
pub async fn sync_payout_statuses(pool: PgPool, mural: muralpay::Client) {
// Mural sets a max limit of 100 for search payouts endpoint
const LIMIT: u32 = 100;
info!("Started syncing payout statuses");
crate::queue::payouts::mural::sync_pending_payouts_from_mural(
let result = crate::queue::payouts::mural::sync_pending_payouts_from_mural(
&pool, &mural, LIMIT,
)
.await
.wrap_err("failed to sync pending payouts from Mural")?;
.await;
if let Err(e) = result {
warn!("Failed to sync pending payouts from Mural: {e:?}");
}
crate::queue::payouts::mural::sync_failed_mural_payouts_to_labrinth(
&pool, &mural, LIMIT,
)
.await
.wrap_err("failed to sync failed Mural payouts to Labrinth")?;
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:?}");
}
info!("Done syncing payout statuses");
Ok(())
}
pub async fn ping_minecraft_java_servers(
pool: PgPool,
redis_pool: RedisPool,
clickhouse: clickhouse::Client,
) -> eyre::Result<()> {
info!("Started pinging Minecraft Java servers");
let server_ping_queue = crate::queue::server_ping::ServerPingQueue::new(
pool, redis_pool, clickhouse,
);
server_ping_queue
.ping_minecraft_java_servers()
.await
.wrap_err("failed to ping Minecraft Java servers")?;
info!("Successfully pinged Minecraft Java servers");
info!("Done pinging Minecraft Java servers");
Ok(())
}
mod version_updater {
-59
View File
@@ -6,8 +6,6 @@ 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
@@ -16,8 +14,6 @@ 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()?
@@ -164,60 +160,5 @@ pub async fn init_client_with_database(
.execute()
.await?;
client
.query(&format!(
"
CREATE TABLE IF NOT EXISTS {database}.{MINECRAFT_JAVA_SERVER_PINGS} {cluster_line}
(
recorded DateTime64(4),
project_id UInt64,
address String,
port UInt16,
online Bool,
latency_ms Nullable(UInt32),
description Nullable(String),
version_name Nullable(String),
version_protocol Nullable(UInt32),
players_online Nullable(UInt32),
players_max Nullable(UInt32)
)
ENGINE = {engine}
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
"
))
.execute()
.await?;
client
.query(&format!(
"
CREATE TABLE IF NOT EXISTS {database}.{MINECRAFT_SERVER_PLAYS} {cluster_line}
(
recorded DateTime64(4),
user_id UInt64,
project_id UInt64,
minecraft_uuid UUID
)
ENGINE = {engine}
{ttl}
PRIMARY KEY (project_id, recorded)
SETTINGS index_granularity = 8192
"
))
.execute()
.await?;
client
.query(&format!(
"
ALTER TABLE {database}.{MINECRAFT_SERVER_PLAYS} {cluster_line}
ADD COLUMN IF NOT EXISTS minecraft_uuid UUID
"
))
.execute()
.await?;
Ok(client.with_database(database))
}
-2
View File
@@ -58,8 +58,6 @@ 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")]
+39 -116
View File
@@ -6,13 +6,9 @@ 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};
@@ -180,7 +176,6 @@ pub struct ProjectBuilder {
pub gallery_items: Vec<DBGalleryItem>,
pub color: Option<u32>,
pub monetization_status: MonetizationStatus,
pub components: exp::ProjectSerial,
}
impl ProjectBuilder {
@@ -220,7 +215,6 @@ impl ProjectBuilder {
side_types_migration_review_status:
SideTypesMigrationReviewStatus::Reviewed,
loaders: vec![],
components: self.components,
};
project_struct.insert(&mut *transaction).await?;
@@ -300,7 +294,6 @@ pub struct DBProject {
pub monetization_status: MonetizationStatus,
pub side_types_migration_review_status: SideTypesMigrationReviewStatus,
pub loaders: Vec<String>,
pub components: exp::ProjectSerial,
}
impl DBProject {
@@ -315,16 +308,14 @@ 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,
components
side_types_migration_review_status
)
VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11,
$12, $13,
LOWER($14), $15, $16, $17,
$18,
$19
$18
)
",
self.id as DBProjectId,
@@ -344,8 +335,7 @@ 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(),
serde_json::to_value(&self.components).expect("serialization shouldn't fail"),
self.side_types_migration_review_status.as_str()
)
.execute(&mut *transaction)
.await?;
@@ -357,15 +347,12 @@ impl DBProject {
id: DBProjectId,
transaction: &mut PgTransaction<'_>,
redis: &RedisPool,
) -> Result<Option<()>, ApiError> {
let project = Self::get_id(id, &mut *transaction, redis)
.await
.wrap_internal_err("failed to fetch project by ID")?;
) -> Result<Option<()>, DatabaseError> {
let project = Self::get_id(id, &mut *transaction, redis).await?;
if let Some(project) = project {
DBProject::clear_cache(id, project.inner.slug, Some(true), redis)
.await
.wrap_internal_err("failed to clear project cache")?;
.await?;
sqlx::query!(
"
@@ -375,8 +362,7 @@ impl DBProject {
id as DBProjectId
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to delete project followers")?;
.await?;
sqlx::query!(
"
@@ -386,8 +372,7 @@ impl DBProject {
id as DBProjectId
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to delete project gallery items")?;
.await?;
sqlx::query!(
"
@@ -397,10 +382,7 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await
.wrap_internal_err(
"failed to delete duplicate project followers",
)?;
.await?;
sqlx::query!(
"
@@ -411,8 +393,7 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to clear report project references")?;
.await?;
sqlx::query!(
"
@@ -422,8 +403,7 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to delete project categories")?;
.await?;
sqlx::query!(
"
@@ -433,13 +413,11 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to delete project links")?;
.await?;
for version in project.versions {
super::DBVersion::remove_full(version, redis, transaction)
.await
.wrap_internal_err("failed to remove project version")?;
.await?;
}
sqlx::query!(
@@ -449,8 +427,7 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to delete dependency references")?;
.await?;
sqlx::query!(
"
@@ -461,8 +438,7 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to clear payout project references")?;
.await?;
sqlx::query!(
"
@@ -472,12 +448,10 @@ impl DBProject {
id as DBProjectId,
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to delete project row")?;
.await?;
models::DBTeamMember::clear_cache(project.inner.team_id, redis)
.await
.wrap_internal_err("failed to clear team member cache")?;
.await?;
let affected_user_ids = sqlx::query!(
"
@@ -490,12 +464,9 @@ impl DBProject {
.fetch(&mut *transaction)
.map_ok(|x| DBUserId(x.user_id))
.try_collect::<Vec<_>>()
.await
.wrap_internal_err("failed to delete team members")?;
.await?;
DBUser::clear_project_cache(&affected_user_ids, redis)
.await
.wrap_internal_err("failed to clear user project cache")?;
DBUser::clear_project_cache(&affected_user_ids, redis).await?;
sqlx::query!(
"
@@ -505,8 +476,7 @@ impl DBProject {
project.inner.team_id as DBTeamId,
)
.execute(&mut *transaction)
.await
.wrap_internal_err("failed to delete team")?;
.await?;
Ok(Some(()))
} else {
@@ -518,7 +488,7 @@ impl DBProject {
string: &str,
executor: E,
redis: &RedisPool,
) -> Result<Option<ProjectQueryResult>, ApiError>
) -> Result<Option<ProjectQueryResult>, DatabaseError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -531,7 +501,7 @@ impl DBProject {
id: DBProjectId,
executor: E,
redis: &RedisPool,
) -> Result<Option<ProjectQueryResult>, ApiError>
) -> Result<Option<ProjectQueryResult>, DatabaseError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -548,7 +518,7 @@ impl DBProject {
project_ids: &[DBProjectId],
exec: E,
redis: &RedisPool,
) -> Result<Vec<ProjectQueryResult>, ApiError>
) -> Result<Vec<ProjectQueryResult>, DatabaseError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -567,7 +537,7 @@ impl DBProject {
project_strings: &[T],
exec: E,
redis: &RedisPool,
) -> Result<Vec<ProjectQueryResult>, ApiError>
) -> Result<Vec<ProjectQueryResult>, DatabaseError>
where
E: crate::database::Acquire<'a, Database = sqlx::Postgres>,
{
@@ -577,9 +547,7 @@ 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())
@@ -700,8 +668,7 @@ impl DBProject {
});
async move { Ok(acc) }
}
)
.await?;
).await?;
let links: DashMap<DBProjectId, Vec<LinkUrl>> = sqlx::query!(
"
@@ -725,8 +692,7 @@ impl DBProject {
});
async move { Ok(acc) }
}
)
.await?;
).await?;
#[derive(Default)]
struct VersionLoaderData {
@@ -777,9 +743,7 @@ impl DBProject {
(project_id, version_loader_data)
}
)
.try_collect()
.await?;
).try_collect().await?;
let loader_fields: Vec<QueryLoaderField> = sqlx::query!(
"
@@ -802,8 +766,8 @@ impl DBProject {
.try_collect()
.await?;
let project_rows = sqlx::query!(
r#"
let projects = sqlx::query!(
"
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,
@@ -813,48 +777,24 @@ 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,
m.components AS "components: sqlx::types::Json<exp::ProjectSerial>"
ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories
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_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> {
.fetch(&mut exec)
.try_fold(DashMap::new(), |acc, m| {
let id = m.id;
let project_id = DBProjectId(id);
let VersionLoaderData {
loaders,
mut project_types,
project_types,
games,
loader_loader_field_ids,
} = loaders_ptypes_games.remove(&project_id).map(|x|x.1).unwrap_or_default();
@@ -869,17 +809,6 @@ 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),
@@ -916,7 +845,6 @@ 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(),
@@ -930,19 +858,16 @@ 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));
Ok(acc)
async move { Ok(acc) }
})
?;
.await?;
Ok(projects)
},
)
.await
.wrap_internal_err("failed to fetch cached projects")?;
).await?;
Ok(val)
}
@@ -1058,6 +983,4 @@ pub struct ProjectQueryResult {
pub gallery_items: Vec<DBGalleryItem>,
pub thread_id: DBThreadId,
pub aggregate_version_fields: Vec<VersionField>,
#[serde(flatten)]
pub components: exp::ProjectQuery,
}
@@ -6,7 +6,6 @@ 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};
@@ -38,7 +37,6 @@ pub struct VersionBuilder {
pub status: VersionStatus,
pub requested_status: Option<VersionStatus>,
pub ordering: Option<i32>,
pub components: exp::VersionSerial,
}
#[derive(Clone)]
@@ -208,7 +206,6 @@ impl VersionBuilder {
status: self.status,
requested_status: self.requested_status,
ordering: self.ordering,
components: self.components,
};
version.insert(transaction).await?;
@@ -288,7 +285,7 @@ impl DBLoaderVersion {
}
}
#[derive(Clone, Deserialize, Serialize)]
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct DBVersion {
pub id: DBVersionId,
pub project_id: DBProjectId,
@@ -303,17 +300,8 @@ 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,
@@ -324,14 +312,12 @@ impl DBVersion {
INSERT INTO versions (
id, mod_id, author_id, name, version_number,
changelog, date_published, downloads,
version_type, featured, status, ordering,
components
version_type, featured, status, ordering
)
VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8,
$9, $10, $11, $12,
$13
$9, $10, $11, $12
)
",
self.id as DBVersionId,
@@ -345,9 +331,7 @@ impl DBVersion {
&self.version_type,
self.featured,
self.status.as_str(),
self.ordering,
serde_json::to_value(&self.components)
.expect("serialization shouldn't fail"),
self.ordering
)
.execute(&mut *transaction)
.await?;
@@ -735,14 +719,13 @@ 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.components AS "components: sqlx::types::Json<exp::VersionSerial>"
v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering
FROM versions v
WHERE v.id = ANY($1);
"#,
",
&version_ids
)
.fetch(&mut exec)
@@ -763,7 +746,6 @@ 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),
@@ -780,7 +762,6 @@ 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| {
@@ -823,8 +804,6 @@ impl DBVersion {
project_types,
games,
dependencies,
// TODO populate
components: exp::VersionQuery::default(),
};
acc.insert(v.id, query_version);
@@ -958,7 +937,7 @@ impl DBVersion {
}
}
#[derive(Clone, Deserialize, Serialize)]
#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)]
pub struct VersionQueryResult {
pub inner: DBVersion,
@@ -968,8 +947,6 @@ 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)]
@@ -1016,14 +993,6 @@ 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) {
@@ -1092,7 +1061,6 @@ mod tests {
featured: false,
status: VersionStatus::Listed,
requested_status: None,
components: exp::VersionSerial::default(),
}
}
}

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