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

@@ -1,36 +1,53 @@
<template>
<div
class="grid grid-cols-[1fr_auto] max-lg:gap-x-8 gap-y-6 border-0 border-b border-solid border-divider pb-4"
>
<div class="flex gap-4 w-full">
<slot name="icon" />
<div class="flex flex-col gap-2 justify-center w-full">
<div class="flex justify-between items-start gap-2">
<div class="flex flex-col gap-1.5 justify-center">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
<slot name="title" />
</h1>
<slot name="title-suffix" />
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
<div class="grid grid-cols-[1fr_auto] gap-y-6">
<div class="flex gap-4 w-full">
<slot name="icon" />
<div class="flex flex-col gap-2 justify-center w-full">
<div class="flex justify-between items-start gap-2">
<div class="flex flex-col gap-1.5 justify-center">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
<slot name="title" />
</h1>
<slot name="title-suffix" />
</div>
<p
v-if="$slots.summary"
class="m-0 max-w-[44rem] empty:hidden"
:class="[disableLineClamp ? '' : 'line-clamp-2']"
>
<slot name="summary" />
</p>
</div>
<div v-if="$slots.summary" class="flex gap-2 items-start max-md:hidden">
<slot name="actions" />
</div>
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
<slot name="summary" />
</p>
</div>
<div v-if="$slots.summary" class="flex gap-2 items-start max-lg:hidden">
<slot name="actions" />
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
<slot name="stats" />
</div>
</div>
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden">
<slot name="stats" />
</div>
<div class="flex gap-2 items-start lg:hidden">
<slot name="actions" />
</div>
</div>
<div v-if="!$slots.summary" class="flex gap-2 items-start max-md:hidden">
<slot name="actions" />
</div>
</div>
<div v-if="!$slots.summary" class="flex gap-2 items-start max-lg:hidden">
<slot name="actions" />
<div class="flex justify-between">
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden md:hidden">
<slot name="stats" />
</div>
<div class="flex gap-2 items-start self-end md:hidden">
<slot name="actions" />
</div>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
disableLineClamp?: boolean
}
const { disableLineClamp } = defineProps<Props>()
</script>

View File

@@ -38,7 +38,7 @@
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal.hide()">
<button @click="hide()">
<XIcon />
Cancel
</button>
@@ -124,6 +124,9 @@ function proceed() {
function show() {
modal.value.show()
}
function hide() {
modal.value.hide()
}
defineExpose({ show })
defineExpose({ show, hide })
</script>

View File

@@ -16,7 +16,8 @@
<script lang="ts" setup>
import { PackageIcon } from '@modrinth/assets'
import { useDebounceFn } from '@vueuse/core'
import { defineAsyncComponent, h, ref, watch } from 'vue'
import Fuse from 'fuse.js'
import { defineAsyncComponent, h, markRaw, ref, watch } from 'vue'
import { injectModrinthClient, injectNotificationManager } from '../../providers'
import type { ComboboxOption } from '../base/Combobox.vue'
@@ -57,6 +58,10 @@ const props = withDefaults(
limit?: number
/** Project IDs to exclude from results */
excludeProjectIds?: string[]
/** Include the user's own projects (including unlisted) in results via Fuse search */
includeUserUnlistedProjects?: boolean
/** User ID or username required when includeUserUnlistedProjects is true */
userId?: string
}>(),
{
placeholder: 'Select project',
@@ -78,22 +83,67 @@ const searchResultsCache = ref<Map<string, SearchHit>>(new Map())
const { labrinth } = injectModrinthClient()
const userProjectHits = ref<SearchHit[]>([])
const userProjectsFuse = ref<Fuse<SearchHit> | null>(null)
watch(
() => props.includeUserUnlistedProjects && props.userId,
async (shouldFetch) => {
if (!shouldFetch || !props.userId) {
userProjectHits.value = []
userProjectsFuse.value = null
return
}
try {
const projects = await labrinth.users_v2.getProjects(props.userId)
const projectTypeSet = props.projectTypes ? new Set(props.projectTypes) : null
userProjectHits.value = projects
.filter((p) => !projectTypeSet || projectTypeSet.has(p.project_type as ProjectType))
.filter((p) => p.status === 'unlisted')
.map((p) => ({
project_id: p.id,
title: p.title,
icon_url: p.icon_url ?? undefined,
project_type: p.project_type,
slug: p.slug,
}))
for (const hit of userProjectHits.value) {
searchResultsCache.value.set(hit.project_id, hit)
}
userProjectsFuse.value = new Fuse(userProjectHits.value, {
keys: ['title', 'slug'],
threshold: 0.4,
})
} catch {
userProjectHits.value = []
userProjectsFuse.value = null
}
},
{ immediate: true },
)
function hitToOption(hit: SearchHit): ComboboxOption<string> {
return {
label: hit.title,
value: hit.project_id,
icon: hit.icon_url
? defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
? markRaw(
defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
)
: PackageIcon,
: markRaw(PackageIcon),
}
}
@@ -160,7 +210,11 @@ const search = async (query: string) => {
facets: [[`project_id:${query.replace(/[^a-zA-Z0-9]/g, '')}`]],
})
const allHits = [...resultsByProjectId.hits, ...results.hits]
const userFuseHits: SearchHit[] = userProjectsFuse.value
? userProjectsFuse.value.search(query).map((r) => r.item)
: []
const allHits = [...userFuseHits, ...resultsByProjectId.hits, ...results.hits]
const seenIds = new Set<string>()
const excludeSet = new Set(props.excludeProjectIds ?? [])
const uniqueHits: SearchHit[] = []
@@ -169,7 +223,6 @@ const search = async (query: string) => {
if (!seenIds.has(hit.project_id) && !excludeSet.has(hit.project_id)) {
seenIds.add(hit.project_id)
uniqueHits.push(hit)
// Cache the hit for later lookup
searchResultsCache.value.set(hit.project_id, hit)
}
}

View File

@@ -13,7 +13,7 @@
{{ project.description }}
</template>
<template #stats>
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 flex-wrap gap-y-0">
<div
v-tooltip="
capitalizeString(

View File

@@ -58,8 +58,12 @@
<section v-if="props.ping !== undefined || region" class="flex flex-col gap-2">
<h3 class="text-primary text-base m-0">Region</h3>
<div class="flex flex-wrap gap-1.5 items-center">
<ServerPing
v-if="projectV3?.status !== 'draft'"
:ping="props.ping"
:status-online="props.statusOnline"
/>
<ServerRegion v-if="region" :region="region" />
<ServerPing :ping="props.ping" :status-online="props.statusOnline" />
</div>
</section>
<section v-if="languages.length > 0" class="flex flex-col gap-2">

View File

@@ -2,7 +2,11 @@
<div v-if="allTags.length > 0" class="flex flex-col gap-3">
<h2 class="text-lg m-0">Tags</h2>
<div class="flex flex-wrap gap-1">
<TagItem v-for="tag in allTags" :key="tag">
<TagItem
v-for="tag in allTags"
:key="tag"
:action="props.project.actualProjectType ? () => handleClickTag(tag) : undefined"
>
<FormattedTag :tag="tag" />
</TagItem>
</div>
@@ -10,14 +14,31 @@
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import FormattedTag from '../base/FormattedTag.vue'
import TagItem from '../base/TagItem.vue'
const router = useRouter()
const handleClickTag = (tag: string) => {
if (!props.project.actualProjectType) return
const projectType =
props.project.actualProjectType === 'minecraft_java_server'
? 'server'
: props.project.actualProjectType
const params = projectType === 'server' ? `sc=${tag}` : `f=categories:${tag}`
router.push(`/discover/${projectType}?${params}`)
}
const props = defineProps<{
project: {
categories: string[]
additional_categories: string[]
actualProjectType?: string
}
}>()

View File

@@ -1,7 +1,7 @@
<template>
<ContentPageHeader>
<ContentPageHeader disable-line-clamp>
<template #icon>
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
<Avatar :src="project.icon_url" :alt="project.title" size="108px" />
</template>
<template #title>
{{ project.title }}
@@ -15,6 +15,7 @@
<template #stats>
<div class="flex items-center gap-3 gap-y-1 flex-wrap">
<ServerDetails
v-if="projectV3?.status !== 'draft'"
:online-players="playersOnline"
:status-online="statusOnline"
:recent-plays="javaServer?.verified_plays_2w ?? 0"
@@ -24,7 +25,7 @@
<TagItem
v-for="(category, index) in project.categories"
:key="index"
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
:action="() => router.push(`/discover/servers?sc=${category}`)"
>
<FormattedTag :tag="category" />
</TagItem>

View File

@@ -49,16 +49,21 @@
</div>
<div class="mt-auto flex flex-col gap-3 flex-wrap overflow-hidden justify-between grow">
<div class="flex items-center gap-1 flex-wrap overflow-hidden">
<ServerDetails
v-if="isServerProject"
:region="serverRegion"
:online-players="serverOnlinePlayers"
:recent-plays="serverRecentPlays"
:ping="serverPing"
:status-online="serverStatusOnline"
:hide-online-players-label="true"
:hide-recent-plays-label="true"
/>
<template v-if="isServerProject">
<ServerOnlinePlayers
v-if="serverOnlinePlayers !== undefined"
:online="serverOnlinePlayers"
:status-online="serverStatusOnline"
:hide-label="true"
/>
<ServerRecentPlays
v-if="serverRecentPlays !== undefined"
:recent-plays="serverRecentPlays"
:hide-label="true"
/>
<ServerPing v-if="serverPing && serverStatusOnline" :ping="serverPing" />
<ServerRegion v-if="serverRegion" :region="serverRegion" />
</template>
<ProjectCardEnvironment
v-if="environment"
:client-side="environment.clientSide"
@@ -134,16 +139,21 @@
</div>
<div class="mt-auto flex items-center gap-3 grid-project-card-list__tags">
<div class="flex items-center gap-2 w-full">
<ServerDetails
v-if="isServerProject"
:region="serverRegion"
:online-players="serverOnlinePlayers"
:status-online="serverStatusOnline"
:recent-plays="serverRecentPlays"
:ping="serverPing"
:hide-online-players-label="true"
:hide-recent-plays-label="true"
/>
<template v-if="isServerProject">
<ServerOnlinePlayers
v-if="serverOnlinePlayers !== undefined"
:online="serverOnlinePlayers"
:status-online="serverStatusOnline"
:hide-label="true"
/>
<ServerRecentPlays
v-if="serverRecentPlays !== undefined"
:recent-plays="serverRecentPlays"
:hide-label="true"
/>
<ServerPing v-if="serverPing && serverStatusOnline" :ping="serverPing" />
<ServerRegion v-if="serverRegion" :region="serverRegion" />
</template>
<div class="flex items-center gap-1">
<ProjectCardEnvironment
v-if="environment"
@@ -181,8 +191,11 @@ import { computed } from 'vue'
import { AutoLink, Avatar } from '../../base'
import { SmartClickable } from '../../base/index.ts'
import ProjectStatusBadge from '../ProjectStatusBadge.vue'
import ServerDetails from '../server/ServerDetails.vue'
import ServerModpackContent from '../server/ServerModpackContent.vue'
import ServerOnlinePlayers from '../server/ServerOnlinePlayers.vue'
import ServerPing from '../server/ServerPing.vue'
import ServerRecentPlays from '../server/ServerRecentPlays.vue'
import ServerRegion from '../server/ServerRegion.vue'
import ProjectCardAuthor from './ProjectCardAuthor.vue'
import ProjectCardDate from './ProjectCardDate.vue'
import ProjectCardEnvironment, {

View File

@@ -21,7 +21,7 @@ defineProps<{
}>()
</script>
<template>
<div class="empty:hidden flex items-center gap-2">
<div class="empty:hidden flex items-center gap-2 flex-wrap gap-y-1">
<ServerOnlinePlayers
v-if="onlinePlayers !== undefined"
:online="onlinePlayers"
@@ -33,8 +33,8 @@ defineProps<{
:recent-plays="recentPlays"
:hide-label="hideRecentPlaysLabel"
/>
<ServerRegion v-if="region" :region="region" />
<ServerPing v-if="ping && statusOnline" :ping="ping" />
<ServerRegion v-if="region" :region="region" />
<ServerModpackContent
v-if="modpackContent"
:name="modpackContent.name"

View File

@@ -21,7 +21,7 @@ const pingClass = computed(() => {
if (props.ping === undefined) {
return 'border-brand bg-highlight-green text-brand'
}
if (props.ping < 100) {
if (props.ping < 150) {
return 'border-brand bg-highlight-green text-brand'
}
if (props.ping < 250) {

View File

@@ -21,5 +21,5 @@ const regionNames: Record<string, string> = {
const regionName = computed(() => regionNames[region] ?? region)
</script>
<template>
<TagItem>{{ regionName }}</TagItem>
<TagItem v-tooltip="`Server hosted in ${regionName}`">{{ regionName }}</TagItem>
</template>