fix: servers misc fixes (#5475)

* fix: tags in project settings to have icons and ordered correctly

* fix copy in project list layout settings

* fix tag item in header navigation

* adjust ping ranges

* add handle click tag

* fix: dont show offline in project page for draft status

* move tags above creators in app

* preload server project page on load and optimize queries

* add server project card to organization page

* fix minecraft_java_server label

* pnpm prepr

* have user option in project create modal be circle

* feat: implement better mobile project page view

* disable summary line clamp for servers

* fix: unlink instance doesnt update instance

* increase icon upload size

* small fix on button size

* improve how server ping info loads

* remove unnecessary pings for instance page

* fix order of computing dependency diff

* remove linked_project_id from world, use name+address to match for managed world instead

* pnpm prepr

* hide duplicate worlds with same domain name in worlds list

* add install content warning for server instance

* increase summary max width

* add handling for server projects for bulk editing links

* implement include user unlisted projects in published modpack select

* pnpm prepr

* filter to only user unlisted status

* add bad link warnings

* fix modpack tags appearing in server

* cargo fmt
This commit is contained in:
Truman Gao
2026-03-06 18:11:45 -08:00
committed by GitHub
parent 98175a58a6
commit 83d53dafe7
44 changed files with 993 additions and 377 deletions

View File

@@ -49,6 +49,9 @@ const modalConfirmUnpair = ref()
const modalConfirmReinstall = ref()
const props = defineProps<InstanceSettingsTabProps>()
const emit = defineEmits<{
unlinked: []
}>()
const loader = ref(props.instance.loader)
const gameVersion = ref(props.instance.game_version)
@@ -273,7 +276,7 @@ async function unpairProfile() {
modpackProject.value = null
modpackVersion.value = null
modpackVersions.value = null
modalConfirmUnpair.value.hide()
emit('unlinked')
}
async function repairModpack() {

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { ConfirmModal } from '@modrinth/ui'
import { ref } from 'vue'
import { useTemplateRef } from 'vue'
import { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
import { useTheming } from '@/store/theme.ts'
@@ -49,16 +49,16 @@ const props = defineProps({
})
const emit = defineEmits(['proceed'])
const modal = ref(null)
const modal = useTemplateRef('modal')
defineExpose({
show: () => {
hide_ads_window()
modal.value.show()
modal.value?.show()
},
hide: () => {
onModalHide()
modal.value.hide()
modal.value?.hide()
},
})

View File

@@ -32,8 +32,12 @@ import type { InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<InstanceSettingsTabProps>()
const emit = defineEmits<{
unlinked: []
}>()
const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
watch(
() => props.instance,
@@ -121,7 +125,14 @@ defineExpose({ show })
<TabbedModal
:tabs="
tabs.map((tab) => ({ ...tab, props: { ...props, isMinecraftServer: isMinecraftServer } }))
tabs.map((tab) => ({
...tab,
props: {
...props,
isMinecraftServer,
onUnlinked: handleUnlinked,
},
}))
"
/>
</ModalWrapper>

View File

@@ -187,10 +187,11 @@ type ProjectInfo = {
const { formatMessage } = useVIntl()
const installStore = useInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const modal = ref<InstanceType<typeof NewModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<() => void>(() => {})
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
@@ -316,6 +317,7 @@ async function computeDependencyDiffs(
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
if (!inst.linked_data) return null
if (!modpackVersionId.value || !inst.linked_data.version_id) return null
try {
// For server projects, linked_data.project_id is the server project but
@@ -327,8 +329,8 @@ async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[
// Compute dependency diffs between current and latest version
if (instanceModpackVersion && modpackVersion.value) {
return await computeDependencyDiffs(
modpackVersion.value.dependencies || [],
instanceModpackVersion.dependencies || [],
modpackVersion.value.dependencies || [],
)
}
} catch (error) {
@@ -355,7 +357,7 @@ async function handleUpdate() {
try {
if (modpackVersionId.value && instance.value) {
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
onUpdateComplete.value()
await onUpdateComplete.value()
}
} catch (error) {
console.error('Error updating instance:', error)
@@ -379,7 +381,7 @@ function handleDecline() {
function show(
instanceVal: GameInstance,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
callback: UpdateCompleteCallback = () => {},
e?: MouseEvent,
) {
instance.value = instanceVal

View File

@@ -45,7 +45,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'
import { LockIcon } from '../../../../../../packages/assets/generated-icons'
@@ -81,6 +81,8 @@ const props = withDefaults(
message: MessageDescriptor
}
managed?: boolean
// Instance
instancePath?: string
instanceName?: string
@@ -99,6 +101,7 @@ const props = withDefaults(
renderedMotd: undefined,
gameMode: undefined,
managed: false,
instancePath: undefined,
instanceName: undefined,
@@ -120,7 +123,7 @@ const serverIncompatible = computed(
)
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
const linked = computed(() => isLinkedWorld(props.world))
const managed = computed(() => props.managed)
const messages = defineMessages({
hardcore: {
@@ -209,7 +212,7 @@ const messages = defineMessages({
{{ world.name }}
</div>
<TagItem
v-if="linked"
v-if="managed"
v-tooltip="formatMessage(messages.linkedServer)"
class="border !border-solid border-blue bg-highlight-blue text-xs"
:style="`--_color: var(--color-blue)`"
@@ -412,10 +415,10 @@ const messages = defineMessages({
id: 'edit',
action: () => emit('edit'),
shown: !instancePath,
disabled: locked || linked,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: linked
: managed
? formatMessage(messages.linkedServer)
: undefined,
},
@@ -452,10 +455,10 @@ const messages = defineMessages({
hoverFilled: true,
action: () => emit('delete'),
shown: !instancePath,
disabled: locked || linked,
disabled: locked || managed,
tooltip: locked
? formatMessage(messages.worldInUse)
: linked
: managed
? formatMessage(messages.linkedServer)
: undefined,
},

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,14 +140,12 @@ 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,
})
}
@@ -158,7 +155,6 @@ export async function edit_server_in_profile(
name: string,
address: string,
packStatus: ServerPackStatus,
linkedProjectId?: string,
): Promise<void> {
return await invoke('plugin:worlds|edit_server_in_profile', {
path,
@@ -166,7 +162,6 @@ export async function edit_server_in_profile(
name,
address,
packStatus,
linkedProjectId,
})
}
@@ -204,11 +199,6 @@ export function getWorldIdentifier(world: World) {
export function sortWorlds(worlds: World[]) {
worlds.sort((a, b) => {
const aLinked = isLinkedWorld(a)
const bLinked = isLinkedWorld(b)
if (aLinked !== bLinked) {
return aLinked ? -1 : 1
}
if (!a.last_played) {
return 1
}
@@ -227,8 +217,129 @@ 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
const DEFAULT_MINECRAFT_SERVER_PORT = 25565
function parseServerPort(port: string): number | null {
const parsed = Number.parseInt(port, 10)
return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : null
}
function parseServerHost(address: string): string {
const trimmedAddress = address.trim()
if (!trimmedAddress) return ''
if (trimmedAddress.startsWith('[')) {
const closingBracket = trimmedAddress.indexOf(']')
if (closingBracket > 0) {
return trimmedAddress.slice(1, closingBracket).trim().toLowerCase()
}
}
const firstColon = trimmedAddress.indexOf(':')
const lastColon = trimmedAddress.lastIndexOf(':')
if (firstColon !== -1 && firstColon === lastColon) {
return trimmedAddress.slice(0, firstColon).trim().toLowerCase()
}
return trimmedAddress.toLowerCase()
}
function isIPv4Host(host: string): boolean {
const segments = host.split('.')
if (segments.length !== 4) return false
return segments.every((segment) => {
if (!/^\d+$/.test(segment)) return false
const value = Number.parseInt(segment, 10)
return value >= 0 && value <= 255
})
}
/**
* Normalization converts addresses to a canonical form (lowercase-host:port, default port 25565)
*/
export function normalizeServerAddress(address: string): string {
const trimmedAddress = address.trim()
const host = parseServerHost(trimmedAddress)
if (!host) return ''
let port = DEFAULT_MINECRAFT_SERVER_PORT
// ipv6 address
if (trimmedAddress.startsWith('[')) {
const closingBracket = trimmedAddress.indexOf(']')
if (closingBracket > 0) {
const suffix = trimmedAddress.slice(closingBracket + 1)
if (suffix.startsWith(':')) {
const parsedPort = parseServerPort(suffix.slice(1))
if (parsedPort != null) {
port = parsedPort
}
}
}
// ipv4 address or hostname
} else {
const firstColon = trimmedAddress.indexOf(':')
const lastColon = trimmedAddress.lastIndexOf(':')
if (firstColon !== -1 && firstColon === lastColon) {
const parsedPort = parseServerPort(trimmedAddress.slice(firstColon + 1))
if (parsedPort != null) {
port = parsedPort
}
}
}
return `${host}:${port}`
}
/**
* Domain key used for deduping server entries by removing a single leading subdomain.
* Example: test.cobblemon.gg and cobblemon.gg map to cobblemon.gg
*/
export function getServerDomainKey(address: string): string {
const normalizedAddress = normalizeServerAddress(address)
if (!normalizedAddress) return ''
const separator = normalizedAddress.lastIndexOf(':')
if (separator <= 0 || separator === normalizedAddress.length - 1) return normalizedAddress
const host = normalizedAddress.slice(0, separator).replace(/\.+$/, '')
if (!host) return normalizedAddress
if (host.includes(':') || isIPv4Host(host)) return normalizedAddress
const segments = host.split('.').filter(Boolean)
if (segments.length <= 2) return host
return segments.slice(1).join('.')
}
export function resolveManagedServerWorld(
worlds: World[],
managedName: string | null | undefined,
managedAddress: string | null | undefined,
): ServerWorld | null {
if (!managedName || !managedAddress) return null
const normalizedManagedAddress = normalizeServerAddress(managedAddress)
if (!normalizedManagedAddress) return null
const servers = worlds
.filter(isServerWorld)
.slice()
.sort((a, b) => a.index - b.index)
const exactMatch = servers.find(
(server) =>
server.name === managedName &&
normalizeServerAddress(server.address) === normalizedManagedAddress,
)
if (exactMatch) return exactMatch
return (
servers.find((server) => normalizeServerAddress(server.address) === normalizedManagedAddress) ??
null
)
}
export async function getServerLatency(

View File

@@ -11,6 +11,7 @@ import {
} from '@modrinth/assets'
import type { ProjectType, SortType, Tags } from '@modrinth/ui'
import {
Admonition,
ButtonStyled,
Checkbox,
defineMessages,
@@ -724,6 +725,10 @@ previousFilterState.value = JSON.stringify({
<template v-if="instance">
<InstanceIndicator :instance="instance" />
<h1 class="m-0 mb-1 text-xl">Install content to instance</h1>
<Admonition v-if="isServerInstance" type="warning" class="mb-1">
Adding content can break compatibility when joining the server. Any added content will also
be lost when you update the server instance content.
</Admonition>
</template>
<NavTabs :links="selectableProjectTypes" />
<StyledInput

View File

@@ -2,7 +2,12 @@
<div v-if="instance">
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
<ExportModal ref="exportModal" :instance="instance" />
<InstanceSettingsModal ref="settingsModal" :instance="instance" :offline="offline" />
<InstanceSettingsModal
ref="settingsModal"
:instance="instance"
:offline="offline"
@unlinked="fetchInstance"
/>
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
<ContentPageHeader>
<template #icon>
@@ -55,27 +60,26 @@
</template>
<template v-else>
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<template v-if="loadingServerPing">
<ServerOnlinePlayers
v-if="playersOnline !== undefined"
:online="playersOnline"
:status-online="statusOnline"
hide-label
/>
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
<div
v-if="
(playersOnline !== undefined || recentPlays !== undefined) &&
(minecraftServer?.region || ping)
"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
></div>
<ServerPing v-if="ping" :ping="ping" />
</template>
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
<ServerPing v-if="ping" :ping="ping" />
<div
v-if="minecraftServer?.region || ping"
class="w-1.5 h-1.5 rounded-full bg-surface-5"
@@ -329,7 +333,7 @@ import { get_by_profile_path } from '@/helpers/process'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import type { GameInstance } from '@/helpers/types'
import { showProfileInFolder } from '@/helpers/utils.js'
import { get_server_status, getServerLatency } from '@/helpers/worlds'
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'
@@ -370,6 +374,7 @@ const recentPlays = computed(
)
const playersOnline = ref<number | undefined>(undefined)
const ping = ref<number | undefined>(undefined)
const loadingServerPing = ref(false)
async function fetchInstance() {
isServerInstance.value = false
@@ -377,6 +382,7 @@ async function fetchInstance() {
modrinthVersions.value = []
ping.value = undefined
playersOnline.value = undefined
loadingServerPing.value = false
instance.value = await get(route.params.id as string).catch(handleError)
@@ -409,14 +415,19 @@ async function fetchInstance() {
function fetchDeferredData() {
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
if (isServerInstance.value && serverAddress) {
Promise.all([get_server_status(serverAddress), getServerLatency(serverAddress)])
.then(([status, latency]) => {
ping.value = latency
get_server_status(serverAddress)
.then((status) => {
playersOnline.value = status.players?.online
ping.value = status.ping
})
.catch((err) => {
console.error(`Failed to ping server ${serverAddress}:`, err)
.catch((error) => {
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
})
.finally(() => {
loadingServerPing.value = true
})
} else {
loadingServerPing.value = true
}
updatePlayState()

View File

@@ -26,7 +26,7 @@
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
@proceed="proceedDeleteWorld"
/>
<div v-if="worlds.length > 0" class="flex flex-col gap-4">
<div v-if="dedupedWorlds.length > 0" class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2 items-center">
<StyledInput
v-model="searchFilter"
@@ -62,6 +62,7 @@
v-for="world in filteredWorlds"
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
:world="world"
:managed="world.type === 'server' ? isManagedServerWorld(world) : false"
:highlighted="highlightedWorld === getWorldIdentifier(world)"
:supports-server-quick-play="supportsServerQuickPlay"
:supports-world-quick-play="supportsWorldQuickPlay"
@@ -80,13 +81,13 @@
@refresh="() => refreshServer((world as ServerWorld).address)"
@edit="
() =>
isLinkedWorld(world)
? undefined
: world.type === 'server'
? editServerModal?.show(world)
: editWorldModal?.show(world)
world.type === 'singleplayer'
? editWorldModal?.show(world)
: isManagedServerWorld(world)
? undefined
: editServerModal?.show(world)
"
@delete="() => !isLinkedWorld(world) && promptToRemoveWorld(world)"
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
/>
</div>
@@ -144,17 +145,19 @@ import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
import WorldItem from '@/components/ui/world/WorldItem.vue'
import { get_project, get_project_v3 } from '@/helpers/cache.js'
import { profile_listener } from '@/helpers/events'
import { get_game_versions } from '@/helpers/tags'
import type { GameInstance } from '@/helpers/types'
import {
delete_world,
get_profile_protocol_version,
getServerDomainKey,
getWorldIdentifier,
handleDefaultProfileUpdateEvent,
hasServerQuickPlaySupport,
hasWorldQuickPlaySupport,
isLinkedWorld,
normalizeServerAddress,
type ProfileEvent,
type ProtocolVersion,
refreshServerData,
@@ -162,6 +165,7 @@ import {
refreshWorld,
refreshWorlds,
remove_server_from_profile,
resolveManagedServerWorld,
type ServerData,
type ServerWorld,
showWorldInFolder,
@@ -171,7 +175,11 @@ import {
start_join_singleplayer_world,
type World,
} from '@/helpers/worlds.ts'
import { playServerProject } from '@/store/install'
import {
ensureManagedServerWorldExists,
getServerAddress,
playServerProject,
} from '@/store/install'
const { handleError } = injectNotificationManager()
const route = useRoute()
@@ -225,6 +233,69 @@ const linuxRefreshCount = ref(0)
const protocolVersion = ref<ProtocolVersion | null>(
await get_profile_protocol_version(instance.value.path),
)
const managedServerName = ref<string | null>(null)
const managedServerAddress = ref<string | null>(null)
const managedServerWorld = computed(() =>
resolveManagedServerWorld(worlds.value, managedServerName.value, managedServerAddress.value),
)
function isManagedServerWorld(world: World): world is ServerWorld {
return world.type === 'server' && managedServerWorld.value?.index === world.index
}
async function refreshManagedServerMetadata() {
await ensureManagedServerWorldExists(
instance.value.path,
managedServerName.value,
managedServerAddress.value,
)
const projectId = instance.value.linked_data?.project_id
if (!projectId) {
managedServerName.value = null
managedServerAddress.value = null
return
}
try {
const [project, projectV3] = await Promise.all([
get_project(projectId, 'bypass'),
get_project_v3(projectId, 'bypass'),
])
if (projectV3?.minecraft_server == null) {
managedServerName.value = null
managedServerAddress.value = null
return
}
const serverAddress = getServerAddress(projectV3.minecraft_java_server)
if (!serverAddress) {
managedServerName.value = null
managedServerAddress.value = null
return
}
managedServerName.value = project.title
managedServerAddress.value = serverAddress
} catch (err) {
console.error(
`Failed to resolve managed server metadata for profile: ${instance.value.path}`,
err,
)
managedServerName.value = null
managedServerAddress.value = null
}
}
watch(
() => instance.value.linked_data?.project_id,
async () => {
await refreshManagedServerMetadata()
},
{ immediate: true },
)
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
@@ -257,6 +328,7 @@ async function refreshAllWorlds() {
console.log(`Already refreshing, cancelling refresh.`)
return
}
await refreshManagedServerMetadata()
refreshingAll.value = true
@@ -334,8 +406,11 @@ async function joinWorld(world: World) {
startingInstance.value = true
worldPlaying.value = world
if (world.type === 'server') {
if (isLinkedWorld(world)) {
playServerProject(world.linked_project_id)
const managedProjectId = instance.value.linked_data?.project_id
if (managedProjectId && isManagedServerWorld(world)) {
await playServerProject(managedProjectId).catch(handleJoinError)
startingInstance.value = false
return
}
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
} else if (world.type === 'singleplayer') {
@@ -379,12 +454,48 @@ const supportsWorldQuickPlay = computed(() =>
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
)
const dedupedWorlds = computed(() => {
const visibleWorlds: World[] = []
const serverIndexByDomain = new Map<string, number>()
for (const world of worlds.value) {
if (world.type !== 'server') {
visibleWorlds.push(world)
continue
}
const domainKey =
getServerDomainKey(world.address) ||
normalizeServerAddress(world.address) ||
`server-${world.index}`
const existingIndex = serverIndexByDomain.get(domainKey)
if (existingIndex == null) {
serverIndexByDomain.set(domainKey, visibleWorlds.length)
visibleWorlds.push(world)
continue
}
// replace world with managed world if applicable
const existingWorld = visibleWorlds[existingIndex]
if (
existingWorld?.type === 'server' &&
!isManagedServerWorld(existingWorld) &&
isManagedServerWorld(world)
) {
visibleWorlds[existingIndex] = world
}
}
return visibleWorlds
})
const filterOptions = computed(() => {
const options: FilterBarOption[] = []
const hasServer = worlds.value.some((x) => x.type === 'server')
const hasServer = dedupedWorlds.value.some((x) => x.type === 'server')
if (worlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
if (dedupedWorlds.value.some((x) => x.type === 'singleplayer') && hasServer) {
options.push({
id: 'singleplayer',
message: messages.singleplayer,
@@ -398,13 +509,13 @@ const filterOptions = computed(() => {
if (hasServer) {
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
if (
worlds.value.some(
dedupedWorlds.value.some(
(x) =>
x.type === 'server' &&
!serverData.value[x.address]?.status &&
!serverData.value[x.address]?.refreshing,
) &&
worlds.value.some(
dedupedWorlds.value.some(
(x) =>
x.type === 'singleplayer' ||
(x.type === 'server' &&
@@ -423,7 +534,7 @@ const filterOptions = computed(() => {
})
const filteredWorlds = computed(() =>
worlds.value.filter((x) => {
dedupedWorlds.value.filter((x) => {
const availableFilter = filters.value.includes('available')
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')

View File

@@ -26,6 +26,7 @@
:project-v3="projectV3"
class="project-sidebar-section"
/>
<ProjectSidebarTags :project="data" class="project-sidebar-section" />
<ProjectSidebarCreators
:organization="null"
:members="members"
@@ -34,7 +35,6 @@
link-target="_blank"
class="project-sidebar-section"
/>
<ProjectSidebarTags :project="data" class="project-sidebar-section" />
<ProjectSidebarDetails
:project="data"
:has-versions="versions.length > 0"
@@ -79,7 +79,7 @@
}}
</button>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<ButtonStyled size="large" circular>
<button v-tooltip="'Add server to instance'" @click="handleAddServerToInstance">
<PlusIcon />
</button>

View File

@@ -21,8 +21,8 @@ import {
} from '@/helpers/profile.js'
import {
add_server_to_profile,
edit_server_in_profile,
get_profile_worlds,
resolveManagedServerWorld,
start_join_server,
} from '@/helpers/worlds.ts'
import router from '@/routes.js'
@@ -328,7 +328,7 @@ export const installServerProject = async (serverProjectId) => {
})
await edit_icon(profilePath, originalIconPath)
await syncServerProjectAsWorld(profilePath, project.title, serverAddress, serverProjectId)
await ensureManagedServerWorldExists(profilePath, project.title, serverAddress)
}
export const getServerAddress = (javaServer) => {
@@ -337,61 +337,16 @@ export const getServerAddress = (javaServer) => {
return address
}
const syncServerProjectAsWorld = async (
profilePath,
serverName,
serverAddress,
serverProjectId = null,
) => {
export const ensureManagedServerWorldExists = async (profilePath, serverName, serverAddress) => {
if (!profilePath || !serverAddress) return
try {
const worlds = await get_profile_worlds(profilePath)
if (serverProjectId) {
// Check if a linked world for this project already exists
const linkedWorld = worlds.find(
(w) => w.type === 'server' && w.linked_project_id === serverProjectId,
)
if (linkedWorld) {
// Sync linked world data with project details
if (linkedWorld.address !== serverAddress || linkedWorld.name !== serverName) {
await edit_server_in_profile(
profilePath,
linkedWorld.index,
serverName,
serverAddress,
linkedWorld.pack_status,
serverProjectId,
)
}
return
}
}
const existingServer = worlds.find((w) => w.type === 'server' && w.address === serverAddress)
if (existingServer) {
// Re-link and sync existing server (link may have been lost by Minecraft rewriting servers.dat)
if (serverProjectId || existingServer.name !== serverName) {
await edit_server_in_profile(
profilePath,
existingServer.index,
serverName,
serverAddress,
existingServer.pack_status,
serverProjectId ?? undefined,
)
}
} else {
await add_server_to_profile(
profilePath,
serverName,
serverAddress,
'prompt',
serverProjectId ?? undefined,
)
const managedWorld = resolveManagedServerWorld(worlds, serverName, serverAddress)
if (!managedWorld) {
await add_server_to_profile(profilePath, serverName, serverAddress, 'prompt')
}
} catch (err) {
console.error('Failed to add server to instance worlds:', err)
console.error('Failed to ensure managed server world exists:', err)
}
}
@@ -420,8 +375,7 @@ const createVanillaServerInstance = async (project, gameVersion, serverAddress)
},
)
//
await syncServerProjectAsWorld(profilePath, project.title, serverAddress, project.id)
await ensureManagedServerWorldExists(profilePath, project.title, serverAddress)
return profilePath
}
@@ -552,7 +506,7 @@ export const playServerProject = async (projectId) => {
if (!instance) return
await syncServerProjectAsWorld(instance.path, project.title, serverAddress, project.id)
await ensureManagedServerWorldExists(instance.path, project.title, serverAddress)
// Update existing instance if needed
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {

View File

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

View File

@@ -56,15 +56,17 @@ const search = async (query: string) => {
options.value = [...resultsByProjectId.hits, ...results.hits].map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
icon: markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
),
}))
} catch (error: any) {

View File

@@ -323,15 +323,17 @@ 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',
}),
}),
? markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: auth.value.user?.avatar_url,
alt: 'User Avatar',
class: 'h-5 w-5 rounded-full',
}),
}),
),
)
: undefined,
}))
@@ -352,15 +354,17 @@ async function fetchOrganizations() {
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',
}),
}),
? markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: org.icon_url,
alt: `${org.name} Icon`,
class: 'h-5 w-5 rounded',
}),
}),
),
)
: undefined,
}))

View File

@@ -15,6 +15,8 @@
search-placeholder="Search by name or paste ID..."
loading-message="Loading..."
no-results-message="No results found"
include-user-unlisted-projects
:user-id="auth?.user?.id"
/>
</div>
@@ -85,6 +87,7 @@ const currentProjectId = computed(() => projectV3.value?.id)
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
const { labrinth } = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const auth = (await useAuth()) as { user?: { id: string } }
interface VersionInfo {
id: string

View File

@@ -0,0 +1,11 @@
import type { AbstractModrinthClient } from '@modrinth/api-client'
import { STALE_TIME } from './project'
export const versionQueryOptions = {
v3: (versionId: string, client: AbstractModrinthClient) => ({
queryKey: ['version', 'v3', versionId] as const,
queryFn: () => client.labrinth.versions_v3.getVersion(versionId),
staleTime: STALE_TIME,
}),
}

View File

@@ -2132,6 +2132,12 @@
"project-type.datapack.singular": {
"message": "Data Pack"
},
"project-type.minecraft_java_server.plural": {
"message": "Servers"
},
"project-type.minecraft_java_server.singular": {
"message": "Server"
},
"project-type.mod.plural": {
"message": "Mods"
},
@@ -2990,6 +2996,9 @@
"settings.display.project-list-layouts.resourcepack": {
"message": "Resource Packs page"
},
"settings.display.project-list-layouts.server": {
"message": "Servers page"
},
"settings.display.project-list-layouts.shader": {
"message": "Shaders page"
},

View File

@@ -116,7 +116,7 @@
</div>
</template>
<template #default>
<div class="mx-auto flex max-w-[40rem] flex-col gap-4 md:w-[30rem]">
<div class="mx-auto flex max-w-[44rem] flex-col gap-4 md:w-[30rem]">
<div
v-if="
project.project_type !== 'plugin' ||
@@ -443,7 +443,27 @@
"
>
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
<ButtonStyled
v-if="auth.user && currentMember"
size="large"
color="brand"
class="lg:!hidden"
circular
>
<nuxt-link
v-tooltip="'Edit project'"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="!font-bold"
>
<SettingsIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled
v-if="auth.user && currentMember"
size="large"
color="brand"
class="max-lg:!hidden"
>
<nuxt-link
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="!font-bold"
@@ -501,7 +521,11 @@
v-if="!isServerProject"
size="large"
circular
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
:color="
route.name === 'type-id-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
>
<button
:aria-label="formatMessage(commonMessages.downloadButton)"
@@ -515,7 +539,11 @@
v-else
size="large"
circular
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
:color="
route.name === 'type-id-version-version' || (auth.user && currentMember)
? `standard`
: `brand`
"
>
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
<PlayIcon aria-hidden="true" />
@@ -1070,6 +1098,7 @@ import NavTabs from '~/components/ui/NavTabs.vue'
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import { userCollectProject, userFollowProject } from '~/composables/user.js'
import { useModerationStore } from '~/store/moderation.ts'
import { reportProject } from '~/utils/report-helpers.ts'
@@ -1630,52 +1659,41 @@ const serverModpackVersionId = computed(() => {
})
const { data: serverModpackVersion, isPending: serverModpackVersionPending } = useQuery({
queryKey: computed(() => ['sidebar-modpack-version', serverModpackVersionId.value]),
queryKey: computed(() => ['version', 'v3', serverModpackVersionId.value]),
queryFn: () => client.labrinth.versions_v3.getVersion(serverModpackVersionId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackVersionId.value),
})
const serverModpackProjectId = computed(() => serverModpackVersion.value?.project_id ?? null)
const { data: serverModpackProject, isPending: serverModpackProjectPending } = useQuery({
queryKey: computed(() => ['sidebar-modpack-project', serverModpackProjectId.value]),
queryFn: () => client.labrinth.projects_v3.get(serverModpackProjectId.value),
staleTime: STALE_TIME,
enabled: computed(() => !!serverModpackProjectId.value),
})
const serverDataLoaded = computed(() => {
if (!projectV3.value) return false
if (serverModpackVersionId.value && serverModpackVersionPending.value) return false
if (serverModpackProjectId.value && serverModpackProjectPending.value) return false
return true
})
const serverRequiredContent = computed(() => {
if (!serverModpackProject.value) return null
const content = projectV3.value?.minecraft_java_server?.content
if (!content || content.kind !== 'modpack') return null
const primaryFile =
serverModpackVersion.value?.files?.find((f) => f.primary) ??
serverModpackVersion.value?.files?.[0]
return {
name: serverModpackProject.value.name,
name: content.project_name ?? '',
versionNumber: serverModpackVersion.value?.version_number ?? '',
icon: serverModpackProject.value.icon_url,
icon: content.project_icon,
onclickName:
serverModpackProject.value.id !== projectId.value
? () => navigateTo(`/modpack/${serverModpackProject.value.slug}`)
content.project_id && content.project_id !== projectId.value
? () => navigateTo(`/project/${content.project_id}`)
: undefined,
onclickVersion:
serverModpackProject.value.id !== projectId.value
content.project_id && content.project_id !== projectId.value
? () =>
navigateTo(
`/modpack/${serverModpackProject.value.slug}/version/${serverModpackVersion.value?.id}`,
)
navigateTo(`/project/${content.project_id}/version/${serverModpackVersion.value?.id}`)
: undefined,
onclickDownload: primaryFile?.url
? () => navigateTo(primaryFile.url, { external: true })
: undefined,
showCustomModpackTooltip: serverModpackProject.value.id === projectId.value,
showCustomModpackTooltip: content.project_id === projectId.value,
}
})
@@ -1710,6 +1728,11 @@ const serverModpackLoaders = computed(() => {
return serverModpackVersion.value.mrpack_loaders ?? []
})
watch(serverModpackVersionId, (versionId) => {
if (!versionId) return
queryClient.prefetchQuery(versionQueryOptions.v3(versionId, client))
})
// Members
const { data: allMembersRaw, error: _membersError } = useQuery({
queryKey: computed(() => ['project', projectId.value, 'members']),

View File

@@ -84,7 +84,7 @@
<div class="input-stack">
<FileInput
id="project-icon"
:max-size="262144"
:max-size="262144000"
:show-icon="true"
accept="image/png,image/jpeg,image/gif,image/webp"
class="choose-image iconified-button"

View File

@@ -8,6 +8,16 @@
<span class="label__title">Website</span>
<span class="label__description">Your server's official website.</span>
</label>
<TriangleAlertIcon
v-if="isServerSiteLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isServerSiteDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="server-website"
v-model="siteUrl"
@@ -22,6 +32,16 @@
<span class="label__title">Store</span>
<span class="label__description">A link to your server's store or shop.</span>
</label>
<TriangleAlertIcon
v-if="isServerStoreLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isServerStoreDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="server-store"
v-model="storeUrl"
@@ -41,6 +61,16 @@
>A page containing information, documentation, and help for the server.</span
>
</label>
<TriangleAlertIcon
v-if="isServerWikiLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="isServerWikiDiscordUrl"
v-tooltip="`Discord invites are not appropriate for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="server-wiki"
v-model="serverWikiUrl"
@@ -55,6 +85,16 @@
<span class="label__title">Discord</span>
<span class="label__description">An invitation link to your Discord server.</span>
</label>
<TriangleAlertIcon
v-if="isServerDiscordLinkShortener"
v-tooltip="`Use of link shorteners is prohibited.`"
class="size-6 animate-pulse text-orange"
/>
<TriangleAlertIcon
v-else-if="!isServerDiscordUrlCommon"
v-tooltip="`You're using a link which isn't common for this link type.`"
class="size-6 animate-pulse text-orange"
/>
<input
id="server-discord"
v-model="serverDiscordUrl"
@@ -337,6 +377,32 @@ const isDiscordLinkShortener = computed(() => {
return isLinkShortener(discordUrl.value)
})
const isServerSiteDiscordUrl = computed(() => {
return isDiscordUrl(siteUrl.value)
})
const isServerStoreDiscordUrl = computed(() => {
return isDiscordUrl(storeUrl.value)
})
const isServerWikiDiscordUrl = computed(() => {
return isDiscordUrl(serverWikiUrl.value)
})
const isServerSiteLinkShortener = computed(() => {
return isLinkShortener(siteUrl.value)
})
const isServerStoreLinkShortener = computed(() => {
return isLinkShortener(storeUrl.value)
})
const isServerWikiLinkShortener = computed(() => {
return isLinkShortener(serverWikiUrl.value)
})
const isServerDiscordLinkShortener = computed(() => {
return isLinkShortener(serverDiscordUrl.value)
})
const isServerDiscordUrlCommon = computed(() => {
if (!serverDiscordUrl.value || serverDiscordUrl.value.trim().length === 0) return true
return isCommonUrl(serverDiscordUrl.value, commonLinkDomains.discord)
})
const rawDonationLinks = JSON.parse(JSON.stringify(project.value.donation_urls))
rawDonationLinks.push({
id: null,

View File

@@ -75,8 +75,8 @@
>
<div class="category-selector__label">
<component
:is="getCategoryIcon(category.name)"
v-if="header !== 'resolutions' && getCategoryIcon(category.name)"
:is="getTagIcon(category.name)"
v-if="header !== 'resolutions' && getTagIcon(category.name)"
aria-hidden="true"
class="icon"
/>
@@ -111,8 +111,8 @@
>
<div class="category-selector__label">
<component
:is="getCategoryIcon(category.name)"
v-if="category.header !== 'resolutions' && getCategoryIcon(category.name)"
:is="getTagIcon(category.name)"
v-if="category.header !== 'resolutions' && getTagIcon(category.name)"
aria-hidden="true"
class="icon"
/>
@@ -135,7 +135,12 @@
</template>
<script setup lang="ts">
import { getCategoryIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets'
import {
getCategoryIcon,
SERVER_CATEGORY_ICON_MAP,
StarIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import {
Checkbox,
formatCategory,
@@ -167,9 +172,20 @@ const formatCategoryName = (categoryName: string) => {
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 getTagIcon = (categoryName: string) => {
const iconName = isServerProject.value
? (SERVER_CATEGORY_ICON_MAP[categoryName] ?? categoryName)
: categoryName
return getCategoryIcon(iconName)
}
const matchesProjectType = (x: Category) => {
if (isServerProject.value) {
return x.project_type === 'minecraft_java_server'
} else {
return x.project_type === project.value.actualProjectType
}
}
const { saved, current, saving, reset, save } = useSavable(
() => ({
@@ -232,6 +248,14 @@ const categoryLists = computed(() => {
lists[header].push(x)
}
})
const featuresKey = 'minecraft_server_features'
if (lists[featuresKey]) {
lists[featuresKey].sort((a, b) => {
if (a.name === 'pokemon') return -1
if (b.name === 'pokemon') return 1
return 0
})
}
return lists
})
@@ -333,6 +357,7 @@ const toggleFeaturedCategory = (category: Category) => {
.category-selector__label {
display: flex;
align-items: center;
text-align: left;
.icon {
height: 1rem;

View File

@@ -207,12 +207,8 @@
<div class="grid-table__row grid-table__header">
<div>
<Checkbox
:model-value="selectedProjects === projects"
@update:model-value="
selectedProjects === projects
? (selectedProjects = [])
: (selectedProjects = projects)
"
:model-value="allBulkEditableProjectsSelected"
@update:model-value="toggleAllBulkEditableProjects()"
/>
</div>
<div>Icon</div>
@@ -225,13 +221,10 @@
<div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
<div>
<Checkbox
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
v-tooltip="getBulkEditDisabledTooltip(project)"
:disabled="isProjectBulkEditDisabled(project)"
:model-value="selectedProjects.includes(project)"
@update:model-value="
selectedProjects.includes(project)
? (selectedProjects = selectedProjects.filter((it) => it !== project))
: selectedProjects.push(project)
"
@update:model-value="toggleProjectSelection(project)"
/>
</div>
<div>
@@ -375,6 +368,50 @@ const editLinks = reactive({
const editLinksModal = ref(null)
const modal_creation = ref(null)
function isProjectBulkEditDisabled(project) {
return (
(project.permissions & EDIT_DETAILS) === EDIT_DETAILS ||
project.project_type === 'minecraft_java_server'
)
}
const bulkEditableProjects = computed(() =>
projects.value.filter((project) => !isProjectBulkEditDisabled(project)),
)
const allBulkEditableProjectsSelected = computed(
() =>
bulkEditableProjects.value.length > 0 &&
bulkEditableProjects.value.every((project) => selectedProjects.value.includes(project)),
)
function toggleAllBulkEditableProjects() {
selectedProjects.value = allBulkEditableProjectsSelected.value
? []
: bulkEditableProjects.value.slice()
}
function toggleProjectSelection(project) {
if (isProjectBulkEditDisabled(project)) {
return
}
if (selectedProjects.value.includes(project)) {
selectedProjects.value = selectedProjects.value.filter((it) => it !== project)
return
}
selectedProjects.value = [...selectedProjects.value, project]
}
function getBulkEditDisabledTooltip(project) {
if (project.project_type === 'minecraft_java_server') {
return 'Server projects do not support bulk editing'
}
return ''
}
function updateSort(list, sort, desc) {
let sortedArray = list
switch (sort) {

View File

@@ -44,6 +44,7 @@ import { computed, type Reactive, watch } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import { projectQueryOptions } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
@@ -77,6 +78,25 @@ const handleProjectMouseEnter = (result: Labrinth.Search.v2.ResultSearchProject)
prefetchTimeout.start()
}
const handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const slug = result.slug || result.project_id
prefetchTimeout = useTimeoutFn(
async () => {
queryClient.prefetchQuery(projectQueryOptions.v2(slug, modrinthClient))
queryClient.prefetchQuery(projectQueryOptions.v3(slug, modrinthClient))
const content = result.minecraft_java_server?.content
if (content?.kind === 'modpack' && content.version_id) {
queryClient.prefetchQuery(versionQueryOptions.v3(content.version_id, modrinthClient))
}
},
HOVER_DURATION_TO_PREFETCH_MS,
{ immediate: false },
)
prefetchTimeout.start()
}
const handleProjectHoverEnd = () => {
if (prefetchTimeout) prefetchTimeout.stop()
}
@@ -810,6 +830,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
:max-tags="2"
is-server-project
exclude-loaders
@mouseenter="handleServerProjectMouseEnter(project)"
@mouseleave="handleProjectHoverEnd"
>
</ProjectCard>
</template>

View File

@@ -144,7 +144,7 @@
<div class="card flex-card">
<h2>Members</h2>
<div class="details-list">
<template v-for="member in acceptedMembers" :key="member.user.id">
<template v-for="member in acceptedMembers" :key="member?.user.id">
<nuxt-link
class="details-list__item details-list__item--type-large"
:to="`/user/${member?.user?.username}`"
@@ -187,7 +187,7 @@
<NavTabs :links="navLinks" />
</div>
<ProjectCardList v-if="projects && projects.length > 0">
<ProjectCard
<template
v-for="project in (route.params.projectType !== undefined
? (projects ?? []).filter((x) =>
x.project_types.includes(
@@ -204,28 +204,55 @@
.slice()
.sort((a, b) => b.downloads - a.downloads)"
:key="project.id"
:link="`/${project.project_types[0] ?? 'project'}/${project.slug || project.id}`"
:title="project.name"
:icon-url="project.icon_url"
:banner="project.gallery.find((element) => element.featured)?.url"
:summary="project.summary"
:date-updated="project.updated"
:downloads="project.downloads"
:followers="project.followers"
:tags="project.categories"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:status="
auth.user &&
(auth.user.id! === (user as any).id || tags.staffRoles.includes(auth.user.role))
? (project.status as ProjectStatus)
: undefined
"
:color="project.color"
layout="list"
/>
>
<ProjectCard
v-if="isProjectServer(project)"
:link="`/server/${project.slug || project.id}`"
:title="project.name"
:icon-url="project.icon_url"
:summary="project.summary"
:tags="project.categories"
: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="project.minecraft_server?.region"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:server-modpack-content="getServerModpackContent(project)"
:status="
auth.user && (auth.user.id! === user.id || tags.staffRoles.includes(auth.user.role))
? (project.status as ProjectStatus)
: undefined
"
:max-tags="2"
layout="list"
is-server-project
exclude-loaders
/>
<ProjectCard
v-else
:link="`/${project.project_types[0] ?? 'project'}/${project.slug || project.id}`"
:title="project.name"
:icon-url="project.icon_url"
:banner="project.gallery.find((element) => element.featured)?.url"
:summary="project.summary"
:date-updated="project.updated"
:downloads="project.downloads"
:followers="project.followers"
:tags="project.categories"
:environment="{
clientSide: project.client_side,
serverSide: project.server_side,
}"
:status="
auth.user && (auth.user.id! === user.id || tags.staffRoles.includes(auth.user.role))
? (project.status as ProjectStatus)
: undefined
"
:color="project.color"
layout="list"
/>
</template>
</ProjectCardList>
<div v-else-if="true" class="error">
<UpToDate class="icon" />
@@ -234,7 +261,7 @@
This organization doesn't have any projects yet.
<template v-if="isPermission(currentMember?.permissions, 1 << 4)">
Would you like to
<a class="link" @click="($refs as any).modal_creation?.show()">create one</a>?
<a class="link" @click="modal_creation?.show()">create one</a>?
</template>
</span>
</div>
@@ -244,6 +271,7 @@
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
BoxIcon,
ChartIcon,
@@ -267,7 +295,7 @@ import {
ProjectCardList,
useVIntl,
} from '@modrinth/ui'
import type { Organization, ProjectStatus, ProjectType, ProjectV3 } from '@modrinth/utils'
import type { Organization, ProjectStatus, ProjectType } from '@modrinth/utils'
import { formatNumber } from '@modrinth/utils'
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
@@ -282,6 +310,11 @@ import {
} from '~/providers/organization-context.ts'
import { isPermission } from '~/utils/permissions.ts'
type ProjectV3 = Labrinth.Projects.v3.Project & {
client_side: 'required' | 'optional' | 'unsupported'
server_side: 'required' | 'optional' | 'unsupported'
}
const vintl = useVIntl()
const { formatMessage } = vintl
@@ -294,6 +327,7 @@ const route = useNativeRoute()
const router = useRouter()
const tags = useGeneratedState()
const config = useRuntimeConfig()
const modal_creation = useTemplateRef('modal_creation')
const orgId = useRouteId()
@@ -397,6 +431,30 @@ const projectTypes = computed(() => {
return Object.keys(obj)
})
function isProjectServer(project: ProjectV3): boolean {
return project.minecraft_server != null
}
function getServerModpackContent(project: ProjectV3) {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick:
project_id !== project.id
? () => {
navigateTo(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === project.id,
}
}
return undefined
}
const sumDownloads = computed(() => {
let sum = 0

View File

@@ -257,6 +257,10 @@ const projectListLayouts = defineMessages({
id: 'settings.display.project-list-layouts.modpack',
defaultMessage: 'Modpacks page',
},
server: {
id: 'settings.display.project-list-layouts.server',
defaultMessage: 'Servers page',
},
user: {
id: 'settings.display.project-list-layouts.user',
defaultMessage: 'User profile pages',

View File

@@ -49,6 +49,14 @@ const projectTypeMessages = defineMessages({
id: 'project-type.server.plural',
defaultMessage: 'Servers',
},
minecraft_java_server: {
id: 'project-type.minecraft_java_server.singular',
defaultMessage: 'Server',
},
minecraft_java_servers: {
id: 'project-type.minecraft_java_server.plural',
defaultMessage: 'Servers',
},
shader: {
id: 'project-type.shader.singular',
defaultMessage: 'Shader',