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:
@@ -49,6 +49,9 @@ const modalConfirmUnpair = ref()
|
|||||||
const modalConfirmReinstall = ref()
|
const modalConfirmReinstall = ref()
|
||||||
|
|
||||||
const props = defineProps<InstanceSettingsTabProps>()
|
const props = defineProps<InstanceSettingsTabProps>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
unlinked: []
|
||||||
|
}>()
|
||||||
|
|
||||||
const loader = ref(props.instance.loader)
|
const loader = ref(props.instance.loader)
|
||||||
const gameVersion = ref(props.instance.game_version)
|
const gameVersion = ref(props.instance.game_version)
|
||||||
@@ -273,7 +276,7 @@ async function unpairProfile() {
|
|||||||
modpackProject.value = null
|
modpackProject.value = null
|
||||||
modpackVersion.value = null
|
modpackVersion.value = null
|
||||||
modpackVersions.value = null
|
modpackVersions.value = null
|
||||||
modalConfirmUnpair.value.hide()
|
emit('unlinked')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function repairModpack() {
|
async function repairModpack() {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ConfirmModal } from '@modrinth/ui'
|
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 { hide_ads_window, show_ads_window } from '@/helpers/ads.js'
|
||||||
import { useTheming } from '@/store/theme.ts'
|
import { useTheming } from '@/store/theme.ts'
|
||||||
@@ -49,16 +49,16 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits(['proceed'])
|
const emit = defineEmits(['proceed'])
|
||||||
const modal = ref(null)
|
const modal = useTemplateRef('modal')
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show: () => {
|
show: () => {
|
||||||
hide_ads_window()
|
hide_ads_window()
|
||||||
modal.value.show()
|
modal.value?.show()
|
||||||
},
|
},
|
||||||
hide: () => {
|
hide: () => {
|
||||||
onModalHide()
|
onModalHide()
|
||||||
modal.value.hide()
|
modal.value?.hide()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -32,8 +32,12 @@ import type { InstanceSettingsTabProps } from '../../../helpers/types'
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const props = defineProps<InstanceSettingsTabProps>()
|
const props = defineProps<InstanceSettingsTabProps>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
unlinked: []
|
||||||
|
}>()
|
||||||
|
|
||||||
const isMinecraftServer = ref(false)
|
const isMinecraftServer = ref(false)
|
||||||
|
const handleUnlinked = () => emit('unlinked')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.instance,
|
() => props.instance,
|
||||||
@@ -121,7 +125,14 @@ defineExpose({ show })
|
|||||||
|
|
||||||
<TabbedModal
|
<TabbedModal
|
||||||
:tabs="
|
:tabs="
|
||||||
tabs.map((tab) => ({ ...tab, props: { ...props, isMinecraftServer: isMinecraftServer } }))
|
tabs.map((tab) => ({
|
||||||
|
...tab,
|
||||||
|
props: {
|
||||||
|
...props,
|
||||||
|
isMinecraftServer,
|
||||||
|
onUnlinked: handleUnlinked,
|
||||||
|
},
|
||||||
|
}))
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
|
|||||||
@@ -187,10 +187,11 @@ type ProjectInfo = {
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const installStore = useInstall()
|
const installStore = useInstall()
|
||||||
|
type UpdateCompleteCallback = () => void | Promise<void>
|
||||||
|
|
||||||
const modal = ref<InstanceType<typeof NewModal>>()
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
const instance = ref<GameInstance | null>(null)
|
const instance = ref<GameInstance | null>(null)
|
||||||
const onUpdateComplete = ref<() => void>(() => {})
|
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
|
||||||
const diffs = ref<DependencyDiff[]>([])
|
const diffs = ref<DependencyDiff[]>([])
|
||||||
const modpackVersionId = ref<string | null>(null)
|
const modpackVersionId = ref<string | null>(null)
|
||||||
const modpackVersion = ref<Version | null>(null)
|
const modpackVersion = ref<Version | null>(null)
|
||||||
@@ -316,6 +317,7 @@ async function computeDependencyDiffs(
|
|||||||
|
|
||||||
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
|
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
|
||||||
if (!inst.linked_data) return null
|
if (!inst.linked_data) return null
|
||||||
|
if (!modpackVersionId.value || !inst.linked_data.version_id) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For server projects, linked_data.project_id is the server project but
|
// 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
|
// Compute dependency diffs between current and latest version
|
||||||
if (instanceModpackVersion && modpackVersion.value) {
|
if (instanceModpackVersion && modpackVersion.value) {
|
||||||
return await computeDependencyDiffs(
|
return await computeDependencyDiffs(
|
||||||
modpackVersion.value.dependencies || [],
|
|
||||||
instanceModpackVersion.dependencies || [],
|
instanceModpackVersion.dependencies || [],
|
||||||
|
modpackVersion.value.dependencies || [],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -355,7 +357,7 @@ async function handleUpdate() {
|
|||||||
try {
|
try {
|
||||||
if (modpackVersionId.value && instance.value) {
|
if (modpackVersionId.value && instance.value) {
|
||||||
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
|
await update_managed_modrinth_version(instance.value.path, modpackVersionId.value)
|
||||||
onUpdateComplete.value()
|
await onUpdateComplete.value()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating instance:', error)
|
console.error('Error updating instance:', error)
|
||||||
@@ -379,7 +381,7 @@ function handleDecline() {
|
|||||||
function show(
|
function show(
|
||||||
instanceVal: GameInstance,
|
instanceVal: GameInstance,
|
||||||
modpackVersionIdVal: string | null = null,
|
modpackVersionIdVal: string | null = null,
|
||||||
callback: () => void = () => {},
|
callback: UpdateCompleteCallback = () => {},
|
||||||
e?: MouseEvent,
|
e?: MouseEvent,
|
||||||
) {
|
) {
|
||||||
instance.value = instanceVal
|
instance.value = instanceVal
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ import type {
|
|||||||
SingleplayerWorld,
|
SingleplayerWorld,
|
||||||
World,
|
World,
|
||||||
} from '@/helpers/worlds.ts'
|
} 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'
|
import { LockIcon } from '../../../../../../packages/assets/generated-icons'
|
||||||
|
|
||||||
@@ -81,6 +81,8 @@ const props = withDefaults(
|
|||||||
message: MessageDescriptor
|
message: MessageDescriptor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
managed?: boolean
|
||||||
|
|
||||||
// Instance
|
// Instance
|
||||||
instancePath?: string
|
instancePath?: string
|
||||||
instanceName?: string
|
instanceName?: string
|
||||||
@@ -99,6 +101,7 @@ const props = withDefaults(
|
|||||||
renderedMotd: undefined,
|
renderedMotd: undefined,
|
||||||
|
|
||||||
gameMode: undefined,
|
gameMode: undefined,
|
||||||
|
managed: false,
|
||||||
|
|
||||||
instancePath: undefined,
|
instancePath: undefined,
|
||||||
instanceName: undefined,
|
instanceName: undefined,
|
||||||
@@ -120,7 +123,7 @@ const serverIncompatible = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
const locked = computed(() => props.world.type === 'singleplayer' && props.world.locked)
|
||||||
const linked = computed(() => isLinkedWorld(props.world))
|
const managed = computed(() => props.managed)
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
hardcore: {
|
hardcore: {
|
||||||
@@ -209,7 +212,7 @@ const messages = defineMessages({
|
|||||||
{{ world.name }}
|
{{ world.name }}
|
||||||
</div>
|
</div>
|
||||||
<TagItem
|
<TagItem
|
||||||
v-if="linked"
|
v-if="managed"
|
||||||
v-tooltip="formatMessage(messages.linkedServer)"
|
v-tooltip="formatMessage(messages.linkedServer)"
|
||||||
class="border !border-solid border-blue bg-highlight-blue text-xs"
|
class="border !border-solid border-blue bg-highlight-blue text-xs"
|
||||||
:style="`--_color: var(--color-blue)`"
|
:style="`--_color: var(--color-blue)`"
|
||||||
@@ -412,10 +415,10 @@ const messages = defineMessages({
|
|||||||
id: 'edit',
|
id: 'edit',
|
||||||
action: () => emit('edit'),
|
action: () => emit('edit'),
|
||||||
shown: !instancePath,
|
shown: !instancePath,
|
||||||
disabled: locked || linked,
|
disabled: locked || managed,
|
||||||
tooltip: locked
|
tooltip: locked
|
||||||
? formatMessage(messages.worldInUse)
|
? formatMessage(messages.worldInUse)
|
||||||
: linked
|
: managed
|
||||||
? formatMessage(messages.linkedServer)
|
? formatMessage(messages.linkedServer)
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
@@ -452,10 +455,10 @@ const messages = defineMessages({
|
|||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
action: () => emit('delete'),
|
action: () => emit('delete'),
|
||||||
shown: !instancePath,
|
shown: !instancePath,
|
||||||
disabled: locked || linked,
|
disabled: locked || managed,
|
||||||
tooltip: locked
|
tooltip: locked
|
||||||
? formatMessage(messages.worldInUse)
|
? formatMessage(messages.worldInUse)
|
||||||
: linked
|
: managed
|
||||||
? formatMessage(messages.linkedServer)
|
? formatMessage(messages.linkedServer)
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ export type ServerWorld = BaseWorld & {
|
|||||||
index: number
|
index: number
|
||||||
address: string
|
address: string
|
||||||
pack_status: ServerPackStatus
|
pack_status: ServerPackStatus
|
||||||
linked_project_id?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type World = SingleplayerWorld | ServerWorld
|
export type World = SingleplayerWorld | ServerWorld
|
||||||
@@ -141,14 +140,12 @@ export async function add_server_to_profile(
|
|||||||
name: string,
|
name: string,
|
||||||
address: string,
|
address: string,
|
||||||
packStatus: ServerPackStatus,
|
packStatus: ServerPackStatus,
|
||||||
linkedProjectId?: string,
|
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
return await invoke('plugin:worlds|add_server_to_profile', {
|
return await invoke('plugin:worlds|add_server_to_profile', {
|
||||||
path,
|
path,
|
||||||
name,
|
name,
|
||||||
address,
|
address,
|
||||||
packStatus,
|
packStatus,
|
||||||
linkedProjectId,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +155,6 @@ export async function edit_server_in_profile(
|
|||||||
name: string,
|
name: string,
|
||||||
address: string,
|
address: string,
|
||||||
packStatus: ServerPackStatus,
|
packStatus: ServerPackStatus,
|
||||||
linkedProjectId?: string,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return await invoke('plugin:worlds|edit_server_in_profile', {
|
return await invoke('plugin:worlds|edit_server_in_profile', {
|
||||||
path,
|
path,
|
||||||
@@ -166,7 +162,6 @@ export async function edit_server_in_profile(
|
|||||||
name,
|
name,
|
||||||
address,
|
address,
|
||||||
packStatus,
|
packStatus,
|
||||||
linkedProjectId,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,11 +199,6 @@ export function getWorldIdentifier(world: World) {
|
|||||||
|
|
||||||
export function sortWorlds(worlds: World[]) {
|
export function sortWorlds(worlds: World[]) {
|
||||||
worlds.sort((a, b) => {
|
worlds.sort((a, b) => {
|
||||||
const aLinked = isLinkedWorld(a)
|
|
||||||
const bLinked = isLinkedWorld(b)
|
|
||||||
if (aLinked !== bLinked) {
|
|
||||||
return aLinked ? -1 : 1
|
|
||||||
}
|
|
||||||
if (!a.last_played) {
|
if (!a.last_played) {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
@@ -227,8 +217,129 @@ export function isServerWorld(world: World): world is ServerWorld {
|
|||||||
return world.type === 'server'
|
return world.type === 'server'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLinkedWorld(world: World): boolean {
|
const DEFAULT_MINECRAFT_SERVER_PORT = 25565
|
||||||
return world.type === 'server' && !!world.linked_project_id
|
|
||||||
|
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(
|
export async function getServerLatency(
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import type { ProjectType, SortType, Tags } from '@modrinth/ui'
|
import type { ProjectType, SortType, Tags } from '@modrinth/ui'
|
||||||
import {
|
import {
|
||||||
|
Admonition,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
@@ -724,6 +725,10 @@ previousFilterState.value = JSON.stringify({
|
|||||||
<template v-if="instance">
|
<template v-if="instance">
|
||||||
<InstanceIndicator :instance="instance" />
|
<InstanceIndicator :instance="instance" />
|
||||||
<h1 class="m-0 mb-1 text-xl">Install content to instance</h1>
|
<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>
|
</template>
|
||||||
<NavTabs :links="selectableProjectTypes" />
|
<NavTabs :links="selectableProjectTypes" />
|
||||||
<StyledInput
|
<StyledInput
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
<div v-if="instance">
|
<div v-if="instance">
|
||||||
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
|
<div class="p-6 pr-2 pb-4" @contextmenu.prevent.stop="(event) => handleRightClick(event)">
|
||||||
<ExportModal ref="exportModal" :instance="instance" />
|
<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" />
|
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
|
||||||
<ContentPageHeader>
|
<ContentPageHeader>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
@@ -55,27 +60,26 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ServerOnlinePlayers
|
<template v-if="loadingServerPing">
|
||||||
v-if="playersOnline !== undefined"
|
<ServerOnlinePlayers
|
||||||
:online="playersOnline"
|
v-if="playersOnline !== undefined"
|
||||||
:status-online="statusOnline"
|
:online="playersOnline"
|
||||||
hide-label
|
:status-online="statusOnline"
|
||||||
/>
|
hide-label
|
||||||
|
/>
|
||||||
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
|
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
|
||||||
|
<div
|
||||||
<div
|
v-if="
|
||||||
v-if="
|
(playersOnline !== undefined || recentPlays !== undefined) &&
|
||||||
(playersOnline !== undefined || recentPlays !== undefined) &&
|
(minecraftServer?.region || ping)
|
||||||
(minecraftServer?.region || ping)
|
"
|
||||||
"
|
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
></div>
|
||||||
></div>
|
<ServerPing v-if="ping" :ping="ping" />
|
||||||
|
</template>
|
||||||
|
|
||||||
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
|
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
|
||||||
|
|
||||||
<ServerPing v-if="ping" :ping="ping" />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="minecraftServer?.region || ping"
|
v-if="minecraftServer?.region || ping"
|
||||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
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 { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
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 { handleSevereError } from '@/store/error.js'
|
||||||
import { playServerProject } from '@/store/install.js'
|
import { playServerProject } from '@/store/install.js'
|
||||||
import { useBreadcrumbs, useLoading } from '@/store/state'
|
import { useBreadcrumbs, useLoading } from '@/store/state'
|
||||||
@@ -370,6 +374,7 @@ const recentPlays = computed(
|
|||||||
)
|
)
|
||||||
const playersOnline = ref<number | undefined>(undefined)
|
const playersOnline = ref<number | undefined>(undefined)
|
||||||
const ping = ref<number | undefined>(undefined)
|
const ping = ref<number | undefined>(undefined)
|
||||||
|
const loadingServerPing = ref(false)
|
||||||
|
|
||||||
async function fetchInstance() {
|
async function fetchInstance() {
|
||||||
isServerInstance.value = false
|
isServerInstance.value = false
|
||||||
@@ -377,6 +382,7 @@ async function fetchInstance() {
|
|||||||
modrinthVersions.value = []
|
modrinthVersions.value = []
|
||||||
ping.value = undefined
|
ping.value = undefined
|
||||||
playersOnline.value = undefined
|
playersOnline.value = undefined
|
||||||
|
loadingServerPing.value = false
|
||||||
|
|
||||||
instance.value = await get(route.params.id as string).catch(handleError)
|
instance.value = await get(route.params.id as string).catch(handleError)
|
||||||
|
|
||||||
@@ -409,14 +415,19 @@ async function fetchInstance() {
|
|||||||
function fetchDeferredData() {
|
function fetchDeferredData() {
|
||||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||||
if (isServerInstance.value && serverAddress) {
|
if (isServerInstance.value && serverAddress) {
|
||||||
Promise.all([get_server_status(serverAddress), getServerLatency(serverAddress)])
|
get_server_status(serverAddress)
|
||||||
.then(([status, latency]) => {
|
.then((status) => {
|
||||||
ping.value = latency
|
|
||||||
playersOnline.value = status.players?.online
|
playersOnline.value = status.players?.online
|
||||||
|
ping.value = status.ping
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((error) => {
|
||||||
console.error(`Failed to ping server ${serverAddress}:`, err)
|
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
||||||
})
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loadingServerPing.value = true
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
loadingServerPing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePlayState()
|
updatePlayState()
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
|
:description="`'${worldToDelete?.name}' will be **permanently deleted**, and there will be no way to recover it.`"
|
||||||
@proceed="proceedDeleteWorld"
|
@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">
|
<div class="flex flex-wrap gap-2 items-center">
|
||||||
<StyledInput
|
<StyledInput
|
||||||
v-model="searchFilter"
|
v-model="searchFilter"
|
||||||
@@ -62,6 +62,7 @@
|
|||||||
v-for="world in filteredWorlds"
|
v-for="world in filteredWorlds"
|
||||||
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
:key="`world-${world.type}-${world.type == 'singleplayer' ? world.path : `${world.address}-${world.index}`}`"
|
||||||
:world="world"
|
:world="world"
|
||||||
|
:managed="world.type === 'server' ? isManagedServerWorld(world) : false"
|
||||||
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
:highlighted="highlightedWorld === getWorldIdentifier(world)"
|
||||||
:supports-server-quick-play="supportsServerQuickPlay"
|
:supports-server-quick-play="supportsServerQuickPlay"
|
||||||
:supports-world-quick-play="supportsWorldQuickPlay"
|
:supports-world-quick-play="supportsWorldQuickPlay"
|
||||||
@@ -80,13 +81,13 @@
|
|||||||
@refresh="() => refreshServer((world as ServerWorld).address)"
|
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||||
@edit="
|
@edit="
|
||||||
() =>
|
() =>
|
||||||
isLinkedWorld(world)
|
world.type === 'singleplayer'
|
||||||
? undefined
|
? editWorldModal?.show(world)
|
||||||
: world.type === 'server'
|
: isManagedServerWorld(world)
|
||||||
? editServerModal?.show(world)
|
? undefined
|
||||||
: editWorldModal?.show(world)
|
: editServerModal?.show(world)
|
||||||
"
|
"
|
||||||
@delete="() => !isLinkedWorld(world) && promptToRemoveWorld(world)"
|
@delete="() => !isManagedServerWorld(world) && promptToRemoveWorld(world)"
|
||||||
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
|
@open-folder="(world: SingleplayerWorld) => showWorldInFolder(instance.path, world.path)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,17 +145,19 @@ import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
|||||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||||
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
|
import EditWorldModal from '@/components/ui/world/modal/EditSingleplayerWorldModal.vue'
|
||||||
import WorldItem from '@/components/ui/world/WorldItem.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 { profile_listener } from '@/helpers/events'
|
||||||
import { get_game_versions } from '@/helpers/tags'
|
import { get_game_versions } from '@/helpers/tags'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import {
|
import {
|
||||||
delete_world,
|
delete_world,
|
||||||
get_profile_protocol_version,
|
get_profile_protocol_version,
|
||||||
|
getServerDomainKey,
|
||||||
getWorldIdentifier,
|
getWorldIdentifier,
|
||||||
handleDefaultProfileUpdateEvent,
|
handleDefaultProfileUpdateEvent,
|
||||||
hasServerQuickPlaySupport,
|
hasServerQuickPlaySupport,
|
||||||
hasWorldQuickPlaySupport,
|
hasWorldQuickPlaySupport,
|
||||||
isLinkedWorld,
|
normalizeServerAddress,
|
||||||
type ProfileEvent,
|
type ProfileEvent,
|
||||||
type ProtocolVersion,
|
type ProtocolVersion,
|
||||||
refreshServerData,
|
refreshServerData,
|
||||||
@@ -162,6 +165,7 @@ import {
|
|||||||
refreshWorld,
|
refreshWorld,
|
||||||
refreshWorlds,
|
refreshWorlds,
|
||||||
remove_server_from_profile,
|
remove_server_from_profile,
|
||||||
|
resolveManagedServerWorld,
|
||||||
type ServerData,
|
type ServerData,
|
||||||
type ServerWorld,
|
type ServerWorld,
|
||||||
showWorldInFolder,
|
showWorldInFolder,
|
||||||
@@ -171,7 +175,11 @@ import {
|
|||||||
start_join_singleplayer_world,
|
start_join_singleplayer_world,
|
||||||
type World,
|
type World,
|
||||||
} from '@/helpers/worlds.ts'
|
} from '@/helpers/worlds.ts'
|
||||||
import { playServerProject } from '@/store/install'
|
import {
|
||||||
|
ensureManagedServerWorldExists,
|
||||||
|
getServerAddress,
|
||||||
|
playServerProject,
|
||||||
|
} from '@/store/install'
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -225,6 +233,69 @@ const linuxRefreshCount = ref(0)
|
|||||||
const protocolVersion = ref<ProtocolVersion | null>(
|
const protocolVersion = ref<ProtocolVersion | null>(
|
||||||
await get_profile_protocol_version(instance.value.path),
|
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) => {
|
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
|
||||||
if (e.profile_path_id !== instance.value.path) return
|
if (e.profile_path_id !== instance.value.path) return
|
||||||
@@ -257,6 +328,7 @@ async function refreshAllWorlds() {
|
|||||||
console.log(`Already refreshing, cancelling refresh.`)
|
console.log(`Already refreshing, cancelling refresh.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await refreshManagedServerMetadata()
|
||||||
|
|
||||||
refreshingAll.value = true
|
refreshingAll.value = true
|
||||||
|
|
||||||
@@ -334,8 +406,11 @@ async function joinWorld(world: World) {
|
|||||||
startingInstance.value = true
|
startingInstance.value = true
|
||||||
worldPlaying.value = world
|
worldPlaying.value = world
|
||||||
if (world.type === 'server') {
|
if (world.type === 'server') {
|
||||||
if (isLinkedWorld(world)) {
|
const managedProjectId = instance.value.linked_data?.project_id
|
||||||
playServerProject(world.linked_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)
|
await start_join_server(instance.value.path, world.address).catch(handleJoinError)
|
||||||
} else if (world.type === 'singleplayer') {
|
} else if (world.type === 'singleplayer') {
|
||||||
@@ -379,12 +454,48 @@ const supportsWorldQuickPlay = computed(() =>
|
|||||||
hasWorldQuickPlaySupport(gameVersions.value, instance.value.game_version),
|
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 filterOptions = computed(() => {
|
||||||
const options: FilterBarOption[] = []
|
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({
|
options.push({
|
||||||
id: 'singleplayer',
|
id: 'singleplayer',
|
||||||
message: messages.singleplayer,
|
message: messages.singleplayer,
|
||||||
@@ -398,13 +509,13 @@ const filterOptions = computed(() => {
|
|||||||
if (hasServer) {
|
if (hasServer) {
|
||||||
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
|
// add available filter if there's any offline ("unavailable") servers AND there's any singleplayer worlds or available servers
|
||||||
if (
|
if (
|
||||||
worlds.value.some(
|
dedupedWorlds.value.some(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === 'server' &&
|
x.type === 'server' &&
|
||||||
!serverData.value[x.address]?.status &&
|
!serverData.value[x.address]?.status &&
|
||||||
!serverData.value[x.address]?.refreshing,
|
!serverData.value[x.address]?.refreshing,
|
||||||
) &&
|
) &&
|
||||||
worlds.value.some(
|
dedupedWorlds.value.some(
|
||||||
(x) =>
|
(x) =>
|
||||||
x.type === 'singleplayer' ||
|
x.type === 'singleplayer' ||
|
||||||
(x.type === 'server' &&
|
(x.type === 'server' &&
|
||||||
@@ -423,7 +534,7 @@ const filterOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const filteredWorlds = computed(() =>
|
const filteredWorlds = computed(() =>
|
||||||
worlds.value.filter((x) => {
|
dedupedWorlds.value.filter((x) => {
|
||||||
const availableFilter = filters.value.includes('available')
|
const availableFilter = filters.value.includes('available')
|
||||||
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
|
const typeFilter = filters.value.includes('server') || filters.value.includes('singleplayer')
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
:project-v3="projectV3"
|
:project-v3="projectV3"
|
||||||
class="project-sidebar-section"
|
class="project-sidebar-section"
|
||||||
/>
|
/>
|
||||||
|
<ProjectSidebarTags :project="data" class="project-sidebar-section" />
|
||||||
<ProjectSidebarCreators
|
<ProjectSidebarCreators
|
||||||
:organization="null"
|
:organization="null"
|
||||||
:members="members"
|
:members="members"
|
||||||
@@ -34,7 +35,6 @@
|
|||||||
link-target="_blank"
|
link-target="_blank"
|
||||||
class="project-sidebar-section"
|
class="project-sidebar-section"
|
||||||
/>
|
/>
|
||||||
<ProjectSidebarTags :project="data" class="project-sidebar-section" />
|
|
||||||
<ProjectSidebarDetails
|
<ProjectSidebarDetails
|
||||||
:project="data"
|
:project="data"
|
||||||
:has-versions="versions.length > 0"
|
:has-versions="versions.length > 0"
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
}}
|
}}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled size="large" circular type="transparent">
|
<ButtonStyled size="large" circular>
|
||||||
<button v-tooltip="'Add server to instance'" @click="handleAddServerToInstance">
|
<button v-tooltip="'Add server to instance'" @click="handleAddServerToInstance">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import {
|
|||||||
} from '@/helpers/profile.js'
|
} from '@/helpers/profile.js'
|
||||||
import {
|
import {
|
||||||
add_server_to_profile,
|
add_server_to_profile,
|
||||||
edit_server_in_profile,
|
|
||||||
get_profile_worlds,
|
get_profile_worlds,
|
||||||
|
resolveManagedServerWorld,
|
||||||
start_join_server,
|
start_join_server,
|
||||||
} from '@/helpers/worlds.ts'
|
} from '@/helpers/worlds.ts'
|
||||||
import router from '@/routes.js'
|
import router from '@/routes.js'
|
||||||
@@ -328,7 +328,7 @@ export const installServerProject = async (serverProjectId) => {
|
|||||||
})
|
})
|
||||||
await edit_icon(profilePath, originalIconPath)
|
await edit_icon(profilePath, originalIconPath)
|
||||||
|
|
||||||
await syncServerProjectAsWorld(profilePath, project.title, serverAddress, serverProjectId)
|
await ensureManagedServerWorldExists(profilePath, project.title, serverAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getServerAddress = (javaServer) => {
|
export const getServerAddress = (javaServer) => {
|
||||||
@@ -337,61 +337,16 @@ export const getServerAddress = (javaServer) => {
|
|||||||
return address
|
return address
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncServerProjectAsWorld = async (
|
export const ensureManagedServerWorldExists = async (profilePath, serverName, serverAddress) => {
|
||||||
profilePath,
|
|
||||||
serverName,
|
|
||||||
serverAddress,
|
|
||||||
serverProjectId = null,
|
|
||||||
) => {
|
|
||||||
if (!profilePath || !serverAddress) return
|
if (!profilePath || !serverAddress) return
|
||||||
try {
|
try {
|
||||||
const worlds = await get_profile_worlds(profilePath)
|
const worlds = await get_profile_worlds(profilePath)
|
||||||
|
const managedWorld = resolveManagedServerWorld(worlds, serverName, serverAddress)
|
||||||
if (serverProjectId) {
|
if (!managedWorld) {
|
||||||
// Check if a linked world for this project already exists
|
await add_server_to_profile(profilePath, serverName, serverAddress, 'prompt')
|
||||||
const linkedWorld = worlds.find(
|
|
||||||
(w) => w.type === 'server' && w.linked_project_id === serverProjectId,
|
|
||||||
)
|
|
||||||
if (linkedWorld) {
|
|
||||||
// Sync linked world data with project details
|
|
||||||
if (linkedWorld.address !== serverAddress || linkedWorld.name !== serverName) {
|
|
||||||
await edit_server_in_profile(
|
|
||||||
profilePath,
|
|
||||||
linkedWorld.index,
|
|
||||||
serverName,
|
|
||||||
serverAddress,
|
|
||||||
linkedWorld.pack_status,
|
|
||||||
serverProjectId,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingServer = worlds.find((w) => w.type === 'server' && w.address === serverAddress)
|
|
||||||
if (existingServer) {
|
|
||||||
// Re-link and sync existing server (link may have been lost by Minecraft rewriting servers.dat)
|
|
||||||
if (serverProjectId || existingServer.name !== serverName) {
|
|
||||||
await edit_server_in_profile(
|
|
||||||
profilePath,
|
|
||||||
existingServer.index,
|
|
||||||
serverName,
|
|
||||||
serverAddress,
|
|
||||||
existingServer.pack_status,
|
|
||||||
serverProjectId ?? undefined,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await add_server_to_profile(
|
|
||||||
profilePath,
|
|
||||||
serverName,
|
|
||||||
serverAddress,
|
|
||||||
'prompt',
|
|
||||||
serverProjectId ?? undefined,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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 ensureManagedServerWorldExists(profilePath, project.title, serverAddress)
|
||||||
await syncServerProjectAsWorld(profilePath, project.title, serverAddress, project.id)
|
|
||||||
|
|
||||||
return profilePath
|
return profilePath
|
||||||
}
|
}
|
||||||
@@ -552,7 +506,7 @@ export const playServerProject = async (projectId) => {
|
|||||||
|
|
||||||
if (!instance) return
|
if (!instance) return
|
||||||
|
|
||||||
await syncServerProjectAsWorld(instance.path, project.title, serverAddress, project.id)
|
await ensureManagedServerWorldExists(instance.path, project.title, serverAddress)
|
||||||
|
|
||||||
// Update existing instance if needed
|
// Update existing instance if needed
|
||||||
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {
|
if (isModpack && instance.linked_data?.version_id !== modpackVersionId) {
|
||||||
|
|||||||
@@ -151,17 +151,12 @@ pub async fn add_server_to_profile(
|
|||||||
name: String,
|
name: String,
|
||||||
address: String,
|
address: String,
|
||||||
pack_status: ServerPackStatus,
|
pack_status: ServerPackStatus,
|
||||||
linked_project_id: Option<String>,
|
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let path = get_full_path(path).await?;
|
let path = get_full_path(path).await?;
|
||||||
Ok(worlds::add_server_to_profile(
|
Ok(
|
||||||
&path,
|
worlds::add_server_to_profile(&path, name, address, pack_status)
|
||||||
name,
|
.await?,
|
||||||
address,
|
|
||||||
pack_status,
|
|
||||||
linked_project_id,
|
|
||||||
)
|
)
|
||||||
.await?)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -171,18 +166,10 @@ pub async fn edit_server_in_profile(
|
|||||||
name: String,
|
name: String,
|
||||||
address: String,
|
address: String,
|
||||||
pack_status: ServerPackStatus,
|
pack_status: ServerPackStatus,
|
||||||
linked_project_id: Option<String>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let path = get_full_path(path).await?;
|
let path = get_full_path(path).await?;
|
||||||
worlds::edit_server_in_profile(
|
worlds::edit_server_in_profile(&path, index, name, address, pack_status)
|
||||||
&path,
|
.await?;
|
||||||
index,
|
|
||||||
name,
|
|
||||||
address,
|
|
||||||
pack_status,
|
|
||||||
linked_project_id,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,15 +56,17 @@ const search = async (query: string) => {
|
|||||||
options.value = [...resultsByProjectId.hits, ...results.hits].map((hit) => ({
|
options.value = [...resultsByProjectId.hits, ...results.hits].map((hit) => ({
|
||||||
label: hit.title,
|
label: hit.title,
|
||||||
value: hit.project_id,
|
value: hit.project_id,
|
||||||
icon: defineAsyncComponent(() =>
|
icon: markRaw(
|
||||||
Promise.resolve({
|
defineAsyncComponent(() =>
|
||||||
setup: () => () =>
|
Promise.resolve({
|
||||||
h('img', {
|
setup: () => () =>
|
||||||
src: hit.icon_url,
|
h('img', {
|
||||||
alt: hit.title,
|
src: hit.icon_url,
|
||||||
class: 'h-5 w-5 rounded',
|
alt: hit.title,
|
||||||
}),
|
class: 'h-5 w-5 rounded',
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -323,15 +323,17 @@ const userOption = computed(() => ({
|
|||||||
value: 'self',
|
value: 'self',
|
||||||
label: auth.value.user?.username || 'Unknown user',
|
label: auth.value.user?.username || 'Unknown user',
|
||||||
icon: auth.value.user?.avatar_url
|
icon: auth.value.user?.avatar_url
|
||||||
? defineAsyncComponent(() =>
|
? markRaw(
|
||||||
Promise.resolve({
|
defineAsyncComponent(() =>
|
||||||
setup: () => () =>
|
Promise.resolve({
|
||||||
h('img', {
|
setup: () => () =>
|
||||||
src: auth.value.user?.avatar_url,
|
h('img', {
|
||||||
alt: 'User Avatar',
|
src: auth.value.user?.avatar_url,
|
||||||
class: 'h-5 w-5 rounded',
|
alt: 'User Avatar',
|
||||||
}),
|
class: 'h-5 w-5 rounded-full',
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
}))
|
}))
|
||||||
@@ -352,15 +354,17 @@ async function fetchOrganizations() {
|
|||||||
value: org.id,
|
value: org.id,
|
||||||
label: org.name,
|
label: org.name,
|
||||||
icon: org.icon_url
|
icon: org.icon_url
|
||||||
? defineAsyncComponent(() =>
|
? markRaw(
|
||||||
Promise.resolve({
|
defineAsyncComponent(() =>
|
||||||
setup: () => () =>
|
Promise.resolve({
|
||||||
h('img', {
|
setup: () => () =>
|
||||||
src: org.icon_url,
|
h('img', {
|
||||||
alt: `${org.name} Icon`,
|
src: org.icon_url,
|
||||||
class: 'h-5 w-5 rounded',
|
alt: `${org.name} Icon`,
|
||||||
}),
|
class: 'h-5 w-5 rounded',
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
: undefined,
|
: undefined,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
search-placeholder="Search by name or paste ID..."
|
search-placeholder="Search by name or paste ID..."
|
||||||
loading-message="Loading..."
|
loading-message="Loading..."
|
||||||
no-results-message="No results found"
|
no-results-message="No results found"
|
||||||
|
include-user-unlisted-projects
|
||||||
|
:user-id="auth?.user?.id"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -85,6 +87,7 @@ const currentProjectId = computed(() => projectV3.value?.id)
|
|||||||
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
|
const { selectedProjectId, selectedVersionId } = injectServerCompatibilityContext()
|
||||||
const { labrinth } = injectModrinthClient()
|
const { labrinth } = injectModrinthClient()
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const auth = (await useAuth()) as { user?: { id: string } }
|
||||||
|
|
||||||
interface VersionInfo {
|
interface VersionInfo {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
11
apps/frontend/src/composables/queries/version.ts
Normal file
11
apps/frontend/src/composables/queries/version.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -2132,6 +2132,12 @@
|
|||||||
"project-type.datapack.singular": {
|
"project-type.datapack.singular": {
|
||||||
"message": "Data Pack"
|
"message": "Data Pack"
|
||||||
},
|
},
|
||||||
|
"project-type.minecraft_java_server.plural": {
|
||||||
|
"message": "Servers"
|
||||||
|
},
|
||||||
|
"project-type.minecraft_java_server.singular": {
|
||||||
|
"message": "Server"
|
||||||
|
},
|
||||||
"project-type.mod.plural": {
|
"project-type.mod.plural": {
|
||||||
"message": "Mods"
|
"message": "Mods"
|
||||||
},
|
},
|
||||||
@@ -2990,6 +2996,9 @@
|
|||||||
"settings.display.project-list-layouts.resourcepack": {
|
"settings.display.project-list-layouts.resourcepack": {
|
||||||
"message": "Resource Packs page"
|
"message": "Resource Packs page"
|
||||||
},
|
},
|
||||||
|
"settings.display.project-list-layouts.server": {
|
||||||
|
"message": "Servers page"
|
||||||
|
},
|
||||||
"settings.display.project-list-layouts.shader": {
|
"settings.display.project-list-layouts.shader": {
|
||||||
"message": "Shaders page"
|
"message": "Shaders page"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #default>
|
<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
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
project.project_type !== 'plugin' ||
|
project.project_type !== 'plugin' ||
|
||||||
@@ -443,7 +443,27 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<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
|
<nuxt-link
|
||||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
|
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
|
||||||
class="!font-bold"
|
class="!font-bold"
|
||||||
@@ -501,7 +521,11 @@
|
|||||||
v-if="!isServerProject"
|
v-if="!isServerProject"
|
||||||
size="large"
|
size="large"
|
||||||
circular
|
circular
|
||||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
:color="
|
||||||
|
route.name === 'type-id-version-version' || (auth.user && currentMember)
|
||||||
|
? `standard`
|
||||||
|
: `brand`
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:aria-label="formatMessage(commonMessages.downloadButton)"
|
:aria-label="formatMessage(commonMessages.downloadButton)"
|
||||||
@@ -515,7 +539,11 @@
|
|||||||
v-else
|
v-else
|
||||||
size="large"
|
size="large"
|
||||||
circular
|
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">
|
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
|
||||||
<PlayIcon aria-hidden="true" />
|
<PlayIcon aria-hidden="true" />
|
||||||
@@ -1070,6 +1098,7 @@ import NavTabs from '~/components/ui/NavTabs.vue'
|
|||||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||||
|
import { versionQueryOptions } from '~/composables/queries/version'
|
||||||
import { userCollectProject, userFollowProject } from '~/composables/user.js'
|
import { userCollectProject, userFollowProject } from '~/composables/user.js'
|
||||||
import { useModerationStore } from '~/store/moderation.ts'
|
import { useModerationStore } from '~/store/moderation.ts'
|
||||||
import { reportProject } from '~/utils/report-helpers.ts'
|
import { reportProject } from '~/utils/report-helpers.ts'
|
||||||
@@ -1630,52 +1659,41 @@ const serverModpackVersionId = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const { data: serverModpackVersion, isPending: serverModpackVersionPending } = useQuery({
|
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),
|
queryFn: () => client.labrinth.versions_v3.getVersion(serverModpackVersionId.value),
|
||||||
staleTime: STALE_TIME,
|
staleTime: STALE_TIME,
|
||||||
enabled: computed(() => !!serverModpackVersionId.value),
|
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(() => {
|
const serverDataLoaded = computed(() => {
|
||||||
if (!projectV3.value) return false
|
if (!projectV3.value) return false
|
||||||
if (serverModpackVersionId.value && serverModpackVersionPending.value) return false
|
if (serverModpackVersionId.value && serverModpackVersionPending.value) return false
|
||||||
if (serverModpackProjectId.value && serverModpackProjectPending.value) return false
|
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
const serverRequiredContent = computed(() => {
|
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 =
|
const primaryFile =
|
||||||
serverModpackVersion.value?.files?.find((f) => f.primary) ??
|
serverModpackVersion.value?.files?.find((f) => f.primary) ??
|
||||||
serverModpackVersion.value?.files?.[0]
|
serverModpackVersion.value?.files?.[0]
|
||||||
return {
|
return {
|
||||||
name: serverModpackProject.value.name,
|
name: content.project_name ?? '',
|
||||||
versionNumber: serverModpackVersion.value?.version_number ?? '',
|
versionNumber: serverModpackVersion.value?.version_number ?? '',
|
||||||
icon: serverModpackProject.value.icon_url,
|
icon: content.project_icon,
|
||||||
onclickName:
|
onclickName:
|
||||||
serverModpackProject.value.id !== projectId.value
|
content.project_id && content.project_id !== projectId.value
|
||||||
? () => navigateTo(`/modpack/${serverModpackProject.value.slug}`)
|
? () => navigateTo(`/project/${content.project_id}`)
|
||||||
: undefined,
|
: undefined,
|
||||||
onclickVersion:
|
onclickVersion:
|
||||||
serverModpackProject.value.id !== projectId.value
|
content.project_id && content.project_id !== projectId.value
|
||||||
? () =>
|
? () =>
|
||||||
navigateTo(
|
navigateTo(`/project/${content.project_id}/version/${serverModpackVersion.value?.id}`)
|
||||||
`/modpack/${serverModpackProject.value.slug}/version/${serverModpackVersion.value?.id}`,
|
|
||||||
)
|
|
||||||
: undefined,
|
: undefined,
|
||||||
onclickDownload: primaryFile?.url
|
onclickDownload: primaryFile?.url
|
||||||
? () => navigateTo(primaryFile.url, { external: true })
|
? () => navigateTo(primaryFile.url, { external: true })
|
||||||
: undefined,
|
: 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 ?? []
|
return serverModpackVersion.value.mrpack_loaders ?? []
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(serverModpackVersionId, (versionId) => {
|
||||||
|
if (!versionId) return
|
||||||
|
queryClient.prefetchQuery(versionQueryOptions.v3(versionId, client))
|
||||||
|
})
|
||||||
|
|
||||||
// Members
|
// Members
|
||||||
const { data: allMembersRaw, error: _membersError } = useQuery({
|
const { data: allMembersRaw, error: _membersError } = useQuery({
|
||||||
queryKey: computed(() => ['project', projectId.value, 'members']),
|
queryKey: computed(() => ['project', projectId.value, 'members']),
|
||||||
|
|||||||
@@ -84,7 +84,7 @@
|
|||||||
<div class="input-stack">
|
<div class="input-stack">
|
||||||
<FileInput
|
<FileInput
|
||||||
id="project-icon"
|
id="project-icon"
|
||||||
:max-size="262144"
|
:max-size="262144000"
|
||||||
:show-icon="true"
|
:show-icon="true"
|
||||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||||
class="choose-image iconified-button"
|
class="choose-image iconified-button"
|
||||||
|
|||||||
@@ -8,6 +8,16 @@
|
|||||||
<span class="label__title">Website</span>
|
<span class="label__title">Website</span>
|
||||||
<span class="label__description">Your server's official website.</span>
|
<span class="label__description">Your server's official website.</span>
|
||||||
</label>
|
</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
|
<input
|
||||||
id="server-website"
|
id="server-website"
|
||||||
v-model="siteUrl"
|
v-model="siteUrl"
|
||||||
@@ -22,6 +32,16 @@
|
|||||||
<span class="label__title">Store</span>
|
<span class="label__title">Store</span>
|
||||||
<span class="label__description">A link to your server's store or shop.</span>
|
<span class="label__description">A link to your server's store or shop.</span>
|
||||||
</label>
|
</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
|
<input
|
||||||
id="server-store"
|
id="server-store"
|
||||||
v-model="storeUrl"
|
v-model="storeUrl"
|
||||||
@@ -41,6 +61,16 @@
|
|||||||
>A page containing information, documentation, and help for the server.</span
|
>A page containing information, documentation, and help for the server.</span
|
||||||
>
|
>
|
||||||
</label>
|
</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
|
<input
|
||||||
id="server-wiki"
|
id="server-wiki"
|
||||||
v-model="serverWikiUrl"
|
v-model="serverWikiUrl"
|
||||||
@@ -55,6 +85,16 @@
|
|||||||
<span class="label__title">Discord</span>
|
<span class="label__title">Discord</span>
|
||||||
<span class="label__description">An invitation link to your Discord server.</span>
|
<span class="label__description">An invitation link to your Discord server.</span>
|
||||||
</label>
|
</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
|
<input
|
||||||
id="server-discord"
|
id="server-discord"
|
||||||
v-model="serverDiscordUrl"
|
v-model="serverDiscordUrl"
|
||||||
@@ -337,6 +377,32 @@ const isDiscordLinkShortener = computed(() => {
|
|||||||
return isLinkShortener(discordUrl.value)
|
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))
|
const rawDonationLinks = JSON.parse(JSON.stringify(project.value.donation_urls))
|
||||||
rawDonationLinks.push({
|
rawDonationLinks.push({
|
||||||
id: null,
|
id: null,
|
||||||
|
|||||||
@@ -75,8 +75,8 @@
|
|||||||
>
|
>
|
||||||
<div class="category-selector__label">
|
<div class="category-selector__label">
|
||||||
<component
|
<component
|
||||||
:is="getCategoryIcon(category.name)"
|
:is="getTagIcon(category.name)"
|
||||||
v-if="header !== 'resolutions' && getCategoryIcon(category.name)"
|
v-if="header !== 'resolutions' && getTagIcon(category.name)"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="icon"
|
class="icon"
|
||||||
/>
|
/>
|
||||||
@@ -111,8 +111,8 @@
|
|||||||
>
|
>
|
||||||
<div class="category-selector__label">
|
<div class="category-selector__label">
|
||||||
<component
|
<component
|
||||||
:is="getCategoryIcon(category.name)"
|
:is="getTagIcon(category.name)"
|
||||||
v-if="category.header !== 'resolutions' && getCategoryIcon(category.name)"
|
v-if="category.header !== 'resolutions' && getTagIcon(category.name)"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="icon"
|
class="icon"
|
||||||
/>
|
/>
|
||||||
@@ -135,7 +135,12 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { getCategoryIcon, StarIcon, TriangleAlertIcon } from '@modrinth/assets'
|
import {
|
||||||
|
getCategoryIcon,
|
||||||
|
SERVER_CATEGORY_ICON_MAP,
|
||||||
|
StarIcon,
|
||||||
|
TriangleAlertIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Checkbox,
|
Checkbox,
|
||||||
formatCategory,
|
formatCategory,
|
||||||
@@ -167,9 +172,20 @@ const formatCategoryName = (categoryName: string) => {
|
|||||||
|
|
||||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||||
|
|
||||||
const matchesProjectType = (x: Category) =>
|
const getTagIcon = (categoryName: string) => {
|
||||||
x.project_type === project.value.actualProjectType ||
|
const iconName = isServerProject.value
|
||||||
(x.project_type === 'minecraft_java_server' && 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(
|
const { saved, current, saving, reset, save } = useSavable(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -232,6 +248,14 @@ const categoryLists = computed(() => {
|
|||||||
lists[header].push(x)
|
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
|
return lists
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -333,6 +357,7 @@ const toggleFeaturedCategory = (category: Category) => {
|
|||||||
.category-selector__label {
|
.category-selector__label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
height: 1rem;
|
height: 1rem;
|
||||||
|
|||||||
@@ -207,12 +207,8 @@
|
|||||||
<div class="grid-table__row grid-table__header">
|
<div class="grid-table__row grid-table__header">
|
||||||
<div>
|
<div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="selectedProjects === projects"
|
:model-value="allBulkEditableProjectsSelected"
|
||||||
@update:model-value="
|
@update:model-value="toggleAllBulkEditableProjects()"
|
||||||
selectedProjects === projects
|
|
||||||
? (selectedProjects = [])
|
|
||||||
: (selectedProjects = projects)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>Icon</div>
|
<div>Icon</div>
|
||||||
@@ -225,13 +221,10 @@
|
|||||||
<div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
|
<div v-for="project in projects" :key="`project-${project.id}`" class="grid-table__row">
|
||||||
<div>
|
<div>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:disabled="(project.permissions & EDIT_DETAILS) === EDIT_DETAILS"
|
v-tooltip="getBulkEditDisabledTooltip(project)"
|
||||||
|
:disabled="isProjectBulkEditDisabled(project)"
|
||||||
:model-value="selectedProjects.includes(project)"
|
:model-value="selectedProjects.includes(project)"
|
||||||
@update:model-value="
|
@update:model-value="toggleProjectSelection(project)"
|
||||||
selectedProjects.includes(project)
|
|
||||||
? (selectedProjects = selectedProjects.filter((it) => it !== project))
|
|
||||||
: selectedProjects.push(project)
|
|
||||||
"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -375,6 +368,50 @@ const editLinks = reactive({
|
|||||||
const editLinksModal = ref(null)
|
const editLinksModal = ref(null)
|
||||||
const modal_creation = 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) {
|
function updateSort(list, sort, desc) {
|
||||||
let sortedArray = list
|
let sortedArray = list
|
||||||
switch (sort) {
|
switch (sort) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import { computed, type Reactive, watch } from 'vue'
|
|||||||
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
||||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||||
import { projectQueryOptions } from '~/composables/queries/project'
|
import { projectQueryOptions } from '~/composables/queries/project'
|
||||||
|
import { versionQueryOptions } from '~/composables/queries/version'
|
||||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||||
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
|
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
|
||||||
@@ -77,6 +78,25 @@ const handleProjectMouseEnter = (result: Labrinth.Search.v2.ResultSearchProject)
|
|||||||
prefetchTimeout.start()
|
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 = () => {
|
const handleProjectHoverEnd = () => {
|
||||||
if (prefetchTimeout) prefetchTimeout.stop()
|
if (prefetchTimeout) prefetchTimeout.stop()
|
||||||
}
|
}
|
||||||
@@ -810,6 +830,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
|
|||||||
:max-tags="2"
|
:max-tags="2"
|
||||||
is-server-project
|
is-server-project
|
||||||
exclude-loaders
|
exclude-loaders
|
||||||
|
@mouseenter="handleServerProjectMouseEnter(project)"
|
||||||
|
@mouseleave="handleProjectHoverEnd"
|
||||||
>
|
>
|
||||||
</ProjectCard>
|
</ProjectCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -144,7 +144,7 @@
|
|||||||
<div class="card flex-card">
|
<div class="card flex-card">
|
||||||
<h2>Members</h2>
|
<h2>Members</h2>
|
||||||
<div class="details-list">
|
<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
|
<nuxt-link
|
||||||
class="details-list__item details-list__item--type-large"
|
class="details-list__item details-list__item--type-large"
|
||||||
:to="`/user/${member?.user?.username}`"
|
:to="`/user/${member?.user?.username}`"
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
<NavTabs :links="navLinks" />
|
<NavTabs :links="navLinks" />
|
||||||
</div>
|
</div>
|
||||||
<ProjectCardList v-if="projects && projects.length > 0">
|
<ProjectCardList v-if="projects && projects.length > 0">
|
||||||
<ProjectCard
|
<template
|
||||||
v-for="project in (route.params.projectType !== undefined
|
v-for="project in (route.params.projectType !== undefined
|
||||||
? (projects ?? []).filter((x) =>
|
? (projects ?? []).filter((x) =>
|
||||||
x.project_types.includes(
|
x.project_types.includes(
|
||||||
@@ -204,28 +204,55 @@
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => b.downloads - a.downloads)"
|
.sort((a, b) => b.downloads - a.downloads)"
|
||||||
:key="project.id"
|
:key="project.id"
|
||||||
:link="`/${project.project_types[0] ?? 'project'}/${project.slug || project.id}`"
|
>
|
||||||
:title="project.name"
|
<ProjectCard
|
||||||
:icon-url="project.icon_url"
|
v-if="isProjectServer(project)"
|
||||||
:banner="project.gallery.find((element) => element.featured)?.url"
|
:link="`/server/${project.slug || project.id}`"
|
||||||
:summary="project.summary"
|
:title="project.name"
|
||||||
:date-updated="project.updated"
|
:icon-url="project.icon_url"
|
||||||
:downloads="project.downloads"
|
:summary="project.summary"
|
||||||
:followers="project.followers"
|
:tags="project.categories"
|
||||||
:tags="project.categories"
|
:server-online-players="
|
||||||
:environment="{
|
project.minecraft_java_server?.ping?.data?.players_online ?? 0
|
||||||
clientSide: project.client_side,
|
"
|
||||||
serverSide: project.server_side,
|
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
|
||||||
}"
|
:server-region="project.minecraft_server?.region"
|
||||||
:status="
|
:server-status-online="!!project.minecraft_java_server?.ping?.data"
|
||||||
auth.user &&
|
:server-modpack-content="getServerModpackContent(project)"
|
||||||
(auth.user.id! === (user as any).id || tags.staffRoles.includes(auth.user.role))
|
:status="
|
||||||
? (project.status as ProjectStatus)
|
auth.user && (auth.user.id! === user.id || tags.staffRoles.includes(auth.user.role))
|
||||||
: undefined
|
? (project.status as ProjectStatus)
|
||||||
"
|
: undefined
|
||||||
:color="project.color"
|
"
|
||||||
layout="list"
|
: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>
|
</ProjectCardList>
|
||||||
<div v-else-if="true" class="error">
|
<div v-else-if="true" class="error">
|
||||||
<UpToDate class="icon" />
|
<UpToDate class="icon" />
|
||||||
@@ -234,7 +261,7 @@
|
|||||||
This organization doesn't have any projects yet.
|
This organization doesn't have any projects yet.
|
||||||
<template v-if="isPermission(currentMember?.permissions, 1 << 4)">
|
<template v-if="isPermission(currentMember?.permissions, 1 << 4)">
|
||||||
Would you like to
|
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>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -244,6 +271,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
BoxIcon,
|
BoxIcon,
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
@@ -267,7 +295,7 @@ import {
|
|||||||
ProjectCardList,
|
ProjectCardList,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} 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 { formatNumber } from '@modrinth/utils'
|
||||||
|
|
||||||
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
|
import UpToDate from '~/assets/images/illustrations/up_to_date.svg?component'
|
||||||
@@ -282,6 +310,11 @@ import {
|
|||||||
} from '~/providers/organization-context.ts'
|
} from '~/providers/organization-context.ts'
|
||||||
import { isPermission } from '~/utils/permissions.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 vintl = useVIntl()
|
||||||
const { formatMessage } = vintl
|
const { formatMessage } = vintl
|
||||||
|
|
||||||
@@ -294,6 +327,7 @@ const route = useNativeRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const tags = useGeneratedState()
|
const tags = useGeneratedState()
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
const modal_creation = useTemplateRef('modal_creation')
|
||||||
|
|
||||||
const orgId = useRouteId()
|
const orgId = useRouteId()
|
||||||
|
|
||||||
@@ -397,6 +431,30 @@ const projectTypes = computed(() => {
|
|||||||
|
|
||||||
return Object.keys(obj)
|
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(() => {
|
const sumDownloads = computed(() => {
|
||||||
let sum = 0
|
let sum = 0
|
||||||
|
|
||||||
|
|||||||
@@ -257,6 +257,10 @@ const projectListLayouts = defineMessages({
|
|||||||
id: 'settings.display.project-list-layouts.modpack',
|
id: 'settings.display.project-list-layouts.modpack',
|
||||||
defaultMessage: 'Modpacks page',
|
defaultMessage: 'Modpacks page',
|
||||||
},
|
},
|
||||||
|
server: {
|
||||||
|
id: 'settings.display.project-list-layouts.server',
|
||||||
|
defaultMessage: 'Servers page',
|
||||||
|
},
|
||||||
user: {
|
user: {
|
||||||
id: 'settings.display.project-list-layouts.user',
|
id: 'settings.display.project-list-layouts.user',
|
||||||
defaultMessage: 'User profile pages',
|
defaultMessage: 'User profile pages',
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ const projectTypeMessages = defineMessages({
|
|||||||
id: 'project-type.server.plural',
|
id: 'project-type.server.plural',
|
||||||
defaultMessage: 'Servers',
|
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: {
|
shader: {
|
||||||
id: 'project-type.shader.singular',
|
id: 'project-type.shader.singular',
|
||||||
defaultMessage: 'Shader',
|
defaultMessage: 'Shader',
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { LabrinthServerPingInternalModule } from './labrinth/server-ping/interna
|
|||||||
import { LabrinthStateModule } from './labrinth/state'
|
import { LabrinthStateModule } from './labrinth/state'
|
||||||
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
|
import { LabrinthTechReviewInternalModule } from './labrinth/tech-review/internal'
|
||||||
import { LabrinthThreadsV3Module } from './labrinth/threads/v3'
|
import { LabrinthThreadsV3Module } from './labrinth/threads/v3'
|
||||||
|
import { LabrinthUsersV2Module } from './labrinth/users/v2'
|
||||||
|
|
||||||
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
|
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ export const MODULE_REGISTRY = {
|
|||||||
labrinth_state: LabrinthStateModule,
|
labrinth_state: LabrinthStateModule,
|
||||||
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
|
labrinth_tech_review_internal: LabrinthTechReviewInternalModule,
|
||||||
labrinth_threads_v3: LabrinthThreadsV3Module,
|
labrinth_threads_v3: LabrinthThreadsV3Module,
|
||||||
|
labrinth_users_v2: LabrinthUsersV2Module,
|
||||||
labrinth_versions_v3: LabrinthVersionsV3Module,
|
labrinth_versions_v3: LabrinthVersionsV3Module,
|
||||||
} as const satisfies Record<string, ModuleConstructor>
|
} as const satisfies Record<string, ModuleConstructor>
|
||||||
|
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ export * from './server-ping/internal'
|
|||||||
export * from './state'
|
export * from './state'
|
||||||
export * from './tech-review/internal'
|
export * from './tech-review/internal'
|
||||||
export * from './threads/v3'
|
export * from './threads/v3'
|
||||||
|
export * from './users/v2'
|
||||||
export * from './versions/v3'
|
export * from './versions/v3'
|
||||||
|
|||||||
27
packages/api-client/src/modules/labrinth/users/v2.ts
Normal file
27
packages/api-client/src/modules/labrinth/users/v2.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { AbstractModule } from '../../../core/abstract-module'
|
||||||
|
import type { Labrinth } from '../types'
|
||||||
|
|
||||||
|
export class LabrinthUsersV2Module extends AbstractModule {
|
||||||
|
public getModuleID(): string {
|
||||||
|
return 'labrinth_users_v2'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a user's projects
|
||||||
|
*
|
||||||
|
* @param idOrUsername - The user's ID or username
|
||||||
|
* @returns Promise resolving to an array of the user's projects
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const projects = await client.labrinth.users_v2.getProjects('my_user')
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public async getProjects(idOrUsername: string): Promise<Labrinth.Projects.v2.Project[]> {
|
||||||
|
return this.client.request<Labrinth.Projects.v2.Project[]>(`/user/${idOrUsername}/projects`, {
|
||||||
|
api: 'labrinth',
|
||||||
|
version: 2,
|
||||||
|
method: 'GET',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,8 +142,6 @@ pub enum WorldDetails {
|
|||||||
index: usize,
|
index: usize,
|
||||||
address: String,
|
address: String,
|
||||||
pack_status: ServerPackStatus,
|
pack_status: ServerPackStatus,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
linked_project_id: Option<String>,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -428,7 +426,6 @@ async fn get_server_worlds_in_profile(
|
|||||||
index,
|
index,
|
||||||
address: server.ip,
|
address: server.ip,
|
||||||
pack_status: server.accept_textures.into(),
|
pack_status: server.accept_textures.into(),
|
||||||
linked_project_id: server.linked_project_id,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
worlds.push(world);
|
worlds.push(world);
|
||||||
@@ -718,7 +715,6 @@ pub async fn add_server_to_profile(
|
|||||||
name: String,
|
name: String,
|
||||||
address: String,
|
address: String,
|
||||||
pack_status: ServerPackStatus,
|
pack_status: ServerPackStatus,
|
||||||
linked_project_id: Option<String>,
|
|
||||||
) -> Result<usize> {
|
) -> Result<usize> {
|
||||||
let mut servers = servers_data::read(profile_path).await?;
|
let mut servers = servers_data::read(profile_path).await?;
|
||||||
let insert_index = servers
|
let insert_index = servers
|
||||||
@@ -733,7 +729,6 @@ pub async fn add_server_to_profile(
|
|||||||
accept_textures: pack_status.into(),
|
accept_textures: pack_status.into(),
|
||||||
hidden: false,
|
hidden: false,
|
||||||
icon: None,
|
icon: None,
|
||||||
linked_project_id,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
servers_data::write(profile_path, &servers).await?;
|
servers_data::write(profile_path, &servers).await?;
|
||||||
@@ -746,7 +741,6 @@ pub async fn edit_server_in_profile(
|
|||||||
name: String,
|
name: String,
|
||||||
address: String,
|
address: String,
|
||||||
pack_status: ServerPackStatus,
|
pack_status: ServerPackStatus,
|
||||||
linked_project_id: Option<String>,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let mut servers = servers_data::read(profile_path).await?;
|
let mut servers = servers_data::read(profile_path).await?;
|
||||||
let server =
|
let server =
|
||||||
@@ -762,9 +756,6 @@ pub async fn edit_server_in_profile(
|
|||||||
server.name = name;
|
server.name = name;
|
||||||
server.ip = address;
|
server.ip = address;
|
||||||
server.accept_textures = pack_status.into();
|
server.accept_textures = pack_status.into();
|
||||||
if let Some(id) = linked_project_id {
|
|
||||||
server.linked_project_id = Some(id);
|
|
||||||
}
|
|
||||||
servers_data::write(profile_path, &servers).await?;
|
servers_data::write(profile_path, &servers).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -804,8 +795,6 @@ mod servers_data {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub accept_textures: Option<bool>,
|
pub accept_textures: Option<bool>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub linked_project_id: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn read(instance_dir: &Path) -> Result<Vec<ServerData>> {
|
pub async fn read(instance_dir: &Path) -> Result<Vec<ServerData>> {
|
||||||
|
|||||||
@@ -149,3 +149,52 @@ export function getTagIcon(tagName: string): IconComponent | undefined {
|
|||||||
}
|
}
|
||||||
return getLoaderIcon(tagName) ?? getCategoryIcon(tagName)
|
return getLoaderIcon(tagName) ?? getCategoryIcon(tagName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SERVER_CATEGORY_ICON_MAP: Record<string, string> = {
|
||||||
|
'adventure-mode': 'compass',
|
||||||
|
anarchy: 'skull',
|
||||||
|
'battle-royale': 'target',
|
||||||
|
bedwars: 'bed-double',
|
||||||
|
bosses: 'crown',
|
||||||
|
classes: 'badge',
|
||||||
|
competitive: 'trophy',
|
||||||
|
'creative-mode': 'palette',
|
||||||
|
'creator-community': 'clapperboard',
|
||||||
|
crossplay: 'gamepad-2',
|
||||||
|
'custom-content': 'blocks',
|
||||||
|
dungeons: 'castle',
|
||||||
|
factions: 'flag',
|
||||||
|
gens: 'pickaxe',
|
||||||
|
'hardcore-mode': 'heart-crack',
|
||||||
|
'keep-inventory': 'backpack',
|
||||||
|
kitpvp: 'sword',
|
||||||
|
lifesteal: 'heart-pulse',
|
||||||
|
media: 'film',
|
||||||
|
microgames: 'grid-3x3',
|
||||||
|
minigames: 'dices',
|
||||||
|
mmo: 'globe',
|
||||||
|
network: 'network',
|
||||||
|
'offline-mode': 'wifi-off',
|
||||||
|
oneblock: 'square',
|
||||||
|
op: 'zap',
|
||||||
|
parkour: 'footprints',
|
||||||
|
'personal-worlds': 'house',
|
||||||
|
plots: 'map-pinned',
|
||||||
|
pokemon: 'paw-print',
|
||||||
|
prison: 'lock',
|
||||||
|
pve: 'shield',
|
||||||
|
pvp: 'swords',
|
||||||
|
questing: 'scroll-text',
|
||||||
|
racing: 'gauge',
|
||||||
|
'recording-smp': 'camera',
|
||||||
|
roleplay: 'theater',
|
||||||
|
rpg: 'wand-sparkles',
|
||||||
|
skyblock: 'cloud',
|
||||||
|
smp: 'users',
|
||||||
|
'survival-mode': 'tree-pine',
|
||||||
|
teams: 'handshake',
|
||||||
|
technical: 'terminal',
|
||||||
|
towns: 'building-2',
|
||||||
|
whitelisted: 'badge-check',
|
||||||
|
'world-resets': 'refresh-ccw',
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,36 +1,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
|
||||||
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="grid grid-cols-[1fr_auto] gap-y-6">
|
||||||
>
|
<div class="flex gap-4 w-full">
|
||||||
<div class="flex gap-4 w-full">
|
<slot name="icon" />
|
||||||
<slot name="icon" />
|
<div class="flex flex-col gap-2 justify-center w-full">
|
||||||
<div class="flex flex-col gap-2 justify-center w-full">
|
<div class="flex justify-between items-start gap-2">
|
||||||
<div class="flex justify-between items-start gap-2">
|
<div class="flex flex-col gap-1.5 justify-center">
|
||||||
<div class="flex flex-col gap-1.5 justify-center">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
|
||||||
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
|
<slot name="title" />
|
||||||
<slot name="title" />
|
</h1>
|
||||||
</h1>
|
<slot name="title-suffix" />
|
||||||
<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>
|
</div>
|
||||||
<p v-if="$slots.summary" class="m-0 line-clamp-2 max-w-[40rem] empty:hidden">
|
|
||||||
<slot name="summary" />
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$slots.summary" class="flex gap-2 items-start max-lg:hidden">
|
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
|
||||||
<slot name="actions" />
|
<slot name="stats" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden">
|
</div>
|
||||||
<slot name="stats" />
|
<div v-if="!$slots.summary" class="flex gap-2 items-start max-md:hidden">
|
||||||
</div>
|
<slot name="actions" />
|
||||||
<div class="flex gap-2 items-start lg:hidden">
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!$slots.summary" class="flex gap-2 items-start max-lg:hidden">
|
<div class="flex justify-between">
|
||||||
<slot name="actions" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
disableLineClamp?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const { disableLineClamp } = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button @click="modal.hide()">
|
<button @click="hide()">
|
||||||
<XIcon />
|
<XIcon />
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -124,6 +124,9 @@ function proceed() {
|
|||||||
function show() {
|
function show() {
|
||||||
modal.value.show()
|
modal.value.show()
|
||||||
}
|
}
|
||||||
|
function hide() {
|
||||||
|
modal.value.hide()
|
||||||
|
}
|
||||||
|
|
||||||
defineExpose({ show })
|
defineExpose({ show, hide })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,7 +16,8 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { PackageIcon } from '@modrinth/assets'
|
import { PackageIcon } from '@modrinth/assets'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
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 { injectModrinthClient, injectNotificationManager } from '../../providers'
|
||||||
import type { ComboboxOption } from '../base/Combobox.vue'
|
import type { ComboboxOption } from '../base/Combobox.vue'
|
||||||
@@ -57,6 +58,10 @@ const props = withDefaults(
|
|||||||
limit?: number
|
limit?: number
|
||||||
/** Project IDs to exclude from results */
|
/** Project IDs to exclude from results */
|
||||||
excludeProjectIds?: string[]
|
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',
|
placeholder: 'Select project',
|
||||||
@@ -78,22 +83,67 @@ const searchResultsCache = ref<Map<string, SearchHit>>(new Map())
|
|||||||
|
|
||||||
const { labrinth } = injectModrinthClient()
|
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> {
|
function hitToOption(hit: SearchHit): ComboboxOption<string> {
|
||||||
return {
|
return {
|
||||||
label: hit.title,
|
label: hit.title,
|
||||||
value: hit.project_id,
|
value: hit.project_id,
|
||||||
icon: hit.icon_url
|
icon: hit.icon_url
|
||||||
? defineAsyncComponent(() =>
|
? markRaw(
|
||||||
Promise.resolve({
|
defineAsyncComponent(() =>
|
||||||
setup: () => () =>
|
Promise.resolve({
|
||||||
h('img', {
|
setup: () => () =>
|
||||||
src: hit.icon_url,
|
h('img', {
|
||||||
alt: hit.title,
|
src: hit.icon_url,
|
||||||
class: 'h-5 w-5 rounded',
|
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, '')}`]],
|
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 seenIds = new Set<string>()
|
||||||
const excludeSet = new Set(props.excludeProjectIds ?? [])
|
const excludeSet = new Set(props.excludeProjectIds ?? [])
|
||||||
const uniqueHits: SearchHit[] = []
|
const uniqueHits: SearchHit[] = []
|
||||||
@@ -169,7 +223,6 @@ const search = async (query: string) => {
|
|||||||
if (!seenIds.has(hit.project_id) && !excludeSet.has(hit.project_id)) {
|
if (!seenIds.has(hit.project_id) && !excludeSet.has(hit.project_id)) {
|
||||||
seenIds.add(hit.project_id)
|
seenIds.add(hit.project_id)
|
||||||
uniqueHits.push(hit)
|
uniqueHits.push(hit)
|
||||||
// Cache the hit for later lookup
|
|
||||||
searchResultsCache.value.set(hit.project_id, hit)
|
searchResultsCache.value.set(hit.project_id, hit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
{{ project.description }}
|
{{ project.description }}
|
||||||
</template>
|
</template>
|
||||||
<template #stats>
|
<template #stats>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 flex-wrap gap-y-0">
|
||||||
<div
|
<div
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
capitalizeString(
|
capitalizeString(
|
||||||
|
|||||||
@@ -58,8 +58,12 @@
|
|||||||
<section v-if="props.ping !== undefined || region" class="flex flex-col gap-2">
|
<section v-if="props.ping !== undefined || region" class="flex flex-col gap-2">
|
||||||
<h3 class="text-primary text-base m-0">Region</h3>
|
<h3 class="text-primary text-base m-0">Region</h3>
|
||||||
<div class="flex flex-wrap gap-1.5 items-center">
|
<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" />
|
<ServerRegion v-if="region" :region="region" />
|
||||||
<ServerPing :ping="props.ping" :status-online="props.statusOnline" />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section v-if="languages.length > 0" class="flex flex-col gap-2">
|
<section v-if="languages.length > 0" class="flex flex-col gap-2">
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
<div v-if="allTags.length > 0" class="flex flex-col gap-3">
|
<div v-if="allTags.length > 0" class="flex flex-col gap-3">
|
||||||
<h2 class="text-lg m-0">Tags</h2>
|
<h2 class="text-lg m-0">Tags</h2>
|
||||||
<div class="flex flex-wrap gap-1">
|
<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" />
|
<FormattedTag :tag="tag" />
|
||||||
</TagItem>
|
</TagItem>
|
||||||
</div>
|
</div>
|
||||||
@@ -10,14 +14,31 @@
|
|||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import FormattedTag from '../base/FormattedTag.vue'
|
import FormattedTag from '../base/FormattedTag.vue'
|
||||||
import TagItem from '../base/TagItem.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<{
|
const props = defineProps<{
|
||||||
project: {
|
project: {
|
||||||
categories: string[]
|
categories: string[]
|
||||||
additional_categories: string[]
|
additional_categories: string[]
|
||||||
|
actualProjectType?: string
|
||||||
}
|
}
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<ContentPageHeader>
|
<ContentPageHeader disable-line-clamp>
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
|
<Avatar :src="project.icon_url" :alt="project.title" size="108px" />
|
||||||
</template>
|
</template>
|
||||||
<template #title>
|
<template #title>
|
||||||
{{ project.title }}
|
{{ project.title }}
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
<template #stats>
|
<template #stats>
|
||||||
<div class="flex items-center gap-3 gap-y-1 flex-wrap">
|
<div class="flex items-center gap-3 gap-y-1 flex-wrap">
|
||||||
<ServerDetails
|
<ServerDetails
|
||||||
|
v-if="projectV3?.status !== 'draft'"
|
||||||
:online-players="playersOnline"
|
:online-players="playersOnline"
|
||||||
:status-online="statusOnline"
|
:status-online="statusOnline"
|
||||||
:recent-plays="javaServer?.verified_plays_2w ?? 0"
|
:recent-plays="javaServer?.verified_plays_2w ?? 0"
|
||||||
@@ -24,7 +25,7 @@
|
|||||||
<TagItem
|
<TagItem
|
||||||
v-for="(category, index) in project.categories"
|
v-for="(category, index) in project.categories"
|
||||||
:key="index"
|
:key="index"
|
||||||
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
|
:action="() => router.push(`/discover/servers?sc=${category}`)"
|
||||||
>
|
>
|
||||||
<FormattedTag :tag="category" />
|
<FormattedTag :tag="category" />
|
||||||
</TagItem>
|
</TagItem>
|
||||||
|
|||||||
@@ -49,16 +49,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-auto flex flex-col gap-3 flex-wrap overflow-hidden justify-between grow">
|
<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">
|
<div class="flex items-center gap-1 flex-wrap overflow-hidden">
|
||||||
<ServerDetails
|
<template v-if="isServerProject">
|
||||||
v-if="isServerProject"
|
<ServerOnlinePlayers
|
||||||
:region="serverRegion"
|
v-if="serverOnlinePlayers !== undefined"
|
||||||
:online-players="serverOnlinePlayers"
|
:online="serverOnlinePlayers"
|
||||||
:recent-plays="serverRecentPlays"
|
:status-online="serverStatusOnline"
|
||||||
:ping="serverPing"
|
:hide-label="true"
|
||||||
:status-online="serverStatusOnline"
|
/>
|
||||||
:hide-online-players-label="true"
|
<ServerRecentPlays
|
||||||
:hide-recent-plays-label="true"
|
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
|
<ProjectCardEnvironment
|
||||||
v-if="environment"
|
v-if="environment"
|
||||||
:client-side="environment.clientSide"
|
:client-side="environment.clientSide"
|
||||||
@@ -134,16 +139,21 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mt-auto flex items-center gap-3 grid-project-card-list__tags">
|
<div class="mt-auto flex items-center gap-3 grid-project-card-list__tags">
|
||||||
<div class="flex items-center gap-2 w-full">
|
<div class="flex items-center gap-2 w-full">
|
||||||
<ServerDetails
|
<template v-if="isServerProject">
|
||||||
v-if="isServerProject"
|
<ServerOnlinePlayers
|
||||||
:region="serverRegion"
|
v-if="serverOnlinePlayers !== undefined"
|
||||||
:online-players="serverOnlinePlayers"
|
:online="serverOnlinePlayers"
|
||||||
:status-online="serverStatusOnline"
|
:status-online="serverStatusOnline"
|
||||||
:recent-plays="serverRecentPlays"
|
:hide-label="true"
|
||||||
:ping="serverPing"
|
/>
|
||||||
:hide-online-players-label="true"
|
<ServerRecentPlays
|
||||||
:hide-recent-plays-label="true"
|
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">
|
<div class="flex items-center gap-1">
|
||||||
<ProjectCardEnvironment
|
<ProjectCardEnvironment
|
||||||
v-if="environment"
|
v-if="environment"
|
||||||
@@ -181,8 +191,11 @@ import { computed } from 'vue'
|
|||||||
import { AutoLink, Avatar } from '../../base'
|
import { AutoLink, Avatar } from '../../base'
|
||||||
import { SmartClickable } from '../../base/index.ts'
|
import { SmartClickable } from '../../base/index.ts'
|
||||||
import ProjectStatusBadge from '../ProjectStatusBadge.vue'
|
import ProjectStatusBadge from '../ProjectStatusBadge.vue'
|
||||||
import ServerDetails from '../server/ServerDetails.vue'
|
|
||||||
import ServerModpackContent from '../server/ServerModpackContent.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 ProjectCardAuthor from './ProjectCardAuthor.vue'
|
||||||
import ProjectCardDate from './ProjectCardDate.vue'
|
import ProjectCardDate from './ProjectCardDate.vue'
|
||||||
import ProjectCardEnvironment, {
|
import ProjectCardEnvironment, {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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
|
<ServerOnlinePlayers
|
||||||
v-if="onlinePlayers !== undefined"
|
v-if="onlinePlayers !== undefined"
|
||||||
:online="onlinePlayers"
|
:online="onlinePlayers"
|
||||||
@@ -33,8 +33,8 @@ defineProps<{
|
|||||||
:recent-plays="recentPlays"
|
:recent-plays="recentPlays"
|
||||||
:hide-label="hideRecentPlaysLabel"
|
:hide-label="hideRecentPlaysLabel"
|
||||||
/>
|
/>
|
||||||
<ServerRegion v-if="region" :region="region" />
|
|
||||||
<ServerPing v-if="ping && statusOnline" :ping="ping" />
|
<ServerPing v-if="ping && statusOnline" :ping="ping" />
|
||||||
|
<ServerRegion v-if="region" :region="region" />
|
||||||
<ServerModpackContent
|
<ServerModpackContent
|
||||||
v-if="modpackContent"
|
v-if="modpackContent"
|
||||||
:name="modpackContent.name"
|
:name="modpackContent.name"
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const pingClass = computed(() => {
|
|||||||
if (props.ping === undefined) {
|
if (props.ping === undefined) {
|
||||||
return 'border-brand bg-highlight-green text-brand'
|
return 'border-brand bg-highlight-green text-brand'
|
||||||
}
|
}
|
||||||
if (props.ping < 100) {
|
if (props.ping < 150) {
|
||||||
return 'border-brand bg-highlight-green text-brand'
|
return 'border-brand bg-highlight-green text-brand'
|
||||||
}
|
}
|
||||||
if (props.ping < 250) {
|
if (props.ping < 250) {
|
||||||
|
|||||||
@@ -21,5 +21,5 @@ const regionNames: Record<string, string> = {
|
|||||||
const regionName = computed(() => regionNames[region] ?? region)
|
const regionName = computed(() => regionNames[region] ?? region)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<TagItem>{{ regionName }}</TagItem>
|
<TagItem v-tooltip="`Server hosted in ${regionName}`">{{ regionName }}</TagItem>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getCategoryIcon } from '@modrinth/assets'
|
import { getCategoryIcon, SERVER_CATEGORY_ICON_MAP } from '@modrinth/assets'
|
||||||
import { computed, type Ref, ref } from 'vue'
|
import { computed, type Ref, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
@@ -6,55 +6,6 @@ import { useVIntl } from '../composables/i18n'
|
|||||||
import type { FilterType, FilterValue, SortType, Tags } from './search'
|
import type { FilterType, FilterValue, SortType, Tags } from './search'
|
||||||
import { formatCategory, formatCategoryHeader } from './tag-messages'
|
import { formatCategory, formatCategoryHeader } from './tag-messages'
|
||||||
|
|
||||||
const SERVER_CATEGORY_ICON_MAP: Record<string, string> = {
|
|
||||||
'adventure-mode': 'compass',
|
|
||||||
anarchy: 'skull',
|
|
||||||
'battle-royale': 'target',
|
|
||||||
bedwars: 'bed-double',
|
|
||||||
bosses: 'crown',
|
|
||||||
classes: 'badge',
|
|
||||||
competitive: 'trophy',
|
|
||||||
'creative-mode': 'palette',
|
|
||||||
'creator-community': 'clapperboard',
|
|
||||||
crossplay: 'gamepad-2',
|
|
||||||
'custom-content': 'blocks',
|
|
||||||
dungeons: 'castle',
|
|
||||||
factions: 'flag',
|
|
||||||
gens: 'pickaxe',
|
|
||||||
'hardcore-mode': 'heart-crack',
|
|
||||||
'keep-inventory': 'backpack',
|
|
||||||
kitpvp: 'sword',
|
|
||||||
lifesteal: 'heart-pulse',
|
|
||||||
media: 'film',
|
|
||||||
microgames: 'grid-3x3',
|
|
||||||
minigames: 'dices',
|
|
||||||
mmo: 'globe',
|
|
||||||
network: 'network',
|
|
||||||
'offline-mode': 'wifi-off',
|
|
||||||
oneblock: 'square',
|
|
||||||
op: 'zap',
|
|
||||||
parkour: 'footprints',
|
|
||||||
'personal-worlds': 'house',
|
|
||||||
plots: 'map-pinned',
|
|
||||||
pokemon: 'paw-print',
|
|
||||||
prison: 'lock',
|
|
||||||
pve: 'shield',
|
|
||||||
pvp: 'swords',
|
|
||||||
questing: 'scroll-text',
|
|
||||||
racing: 'gauge',
|
|
||||||
'recording-smp': 'camera',
|
|
||||||
roleplay: 'theater',
|
|
||||||
rpg: 'wand-sparkles',
|
|
||||||
skyblock: 'cloud',
|
|
||||||
smp: 'users',
|
|
||||||
'survival-mode': 'tree-pine',
|
|
||||||
teams: 'handshake',
|
|
||||||
technical: 'terminal',
|
|
||||||
towns: 'building-2',
|
|
||||||
whitelisted: 'badge-check',
|
|
||||||
'world-resets': 'refresh-ccw',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SERVER_REGIONS = [
|
export const SERVER_REGIONS = [
|
||||||
{ code: 'us_east', name: 'US East' },
|
{ code: 'us_east', name: 'US East' },
|
||||||
{ code: 'us_west', name: 'US West' },
|
{ code: 'us_west', name: 'US West' },
|
||||||
|
|||||||
@@ -60,8 +60,23 @@ export const computeVersions = (versions, members) => {
|
|||||||
.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published))
|
.sort((a, b) => dayjs(b.date_published) - dayjs(a.date_published))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SERVER_HEADER_ORDER = [
|
||||||
|
'minecraft_server_features',
|
||||||
|
'minecraft_server_gameplay',
|
||||||
|
'minecraft_server_meta',
|
||||||
|
'minecraft_server_community',
|
||||||
|
]
|
||||||
|
|
||||||
export const sortedCategories = (tags, formatCategoryName, locale) => {
|
export const sortedCategories = (tags, formatCategoryName, locale) => {
|
||||||
return tags.categories.slice().sort((a, b) => {
|
return tags.categories.slice().sort((a, b) => {
|
||||||
|
const aServerIdx = SERVER_HEADER_ORDER.indexOf(a.header)
|
||||||
|
const bServerIdx = SERVER_HEADER_ORDER.indexOf(b.header)
|
||||||
|
if (aServerIdx !== -1 || bServerIdx !== -1) {
|
||||||
|
return (
|
||||||
|
(aServerIdx === -1 ? Infinity : aServerIdx) - (bServerIdx === -1 ? Infinity : bServerIdx)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const headerCompare = a.header.localeCompare(b.header)
|
const headerCompare = a.header.localeCompare(b.header)
|
||||||
if (headerCompare !== 0) {
|
if (headerCompare !== 0) {
|
||||||
return headerCompare
|
return headerCompare
|
||||||
|
|||||||
Reference in New Issue
Block a user