feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 20:24:32 +00:00
committed by GitHub
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions

View File

@@ -5,7 +5,6 @@
title="Are you sure you want to remove this project from the organization?"
description="If you proceed, this project will no longer be managed by the organization."
proceed-label="Remove"
:noblur="!(cosmetics?.advancedRendering ?? true)"
@proceed="onRemoveFromOrg"
/>
<Card>
@@ -568,7 +567,6 @@ const {
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
const cosmetics = useCosmetics()
const auth = await useAuth()
const allTeamMembers = ref([])

View File

@@ -350,24 +350,16 @@
</template>
</ProjectCard>
</ProjectCardList>
<div v-else>
<div class="mx-auto flex flex-col justify-center gap-8 p-6 text-center">
<EmptyIllustration class="h-[120px] w-auto" />
<div class="-mt-4 flex flex-col gap-4">
<div class="flex flex-col items-center gap-1.5">
<span class="text-lg text-contrast md:text-2xl">{{
formatMessage(messages.noProjectsLabel)
}}</span>
</div>
<ButtonStyled v-if="auth.user && auth.user.id === creator.id" color="brand">
<nuxt-link class="mx-auto w-min" to="/discover/mods">
<CompassIcon class="size-5" />
Discover mods
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<EmptyState v-else type="empty-inbox" :heading="formatMessage(messages.noProjectsLabel)">
<template #actions>
<ButtonStyled v-if="auth.user && auth.user.id === creator.id" color="brand">
<nuxt-link class="mx-auto w-min" to="/discover/mods">
<CompassIcon class="size-5" />
Discover mods
</nuxt-link>
</ButtonStyled>
</template>
</EmptyState>
</NormalPage>
</template>
@@ -377,7 +369,6 @@ import {
ChevronLeftIcon,
CompassIcon,
EditIcon,
EmptyIllustration,
GlobeIcon,
HeartMinusIcon,
LinkIcon,
@@ -398,6 +389,7 @@ import {
ConfirmModal,
defineMessage,
defineMessages,
EmptyState,
FileInput,
HorizontalRule,
injectModrinthClient,

View File

@@ -72,14 +72,11 @@
</div>
</div>
</div>
<div v-else class="mx-auto flex flex-col justify-center p-6 text-center">
<span class="text-lg text-contrast md:text-xl">{{
formatMessage(messages.noTransactions)
}}</span>
<span class="max-w-[256px] text-base text-secondary md:text-lg">{{
formatMessage(messages.noTransactionsDesc)
}}</span>
</div>
<EmptyState
v-else
:heading="formatMessage(messages.noTransactions)"
:description="formatMessage(messages.noTransactionsDesc)"
/>
</div>
</template>
<script setup>
@@ -94,6 +91,7 @@ import {
ButtonStyled,
Combobox,
defineMessages,
EmptyState,
useFormatDateTime,
useFormatMoney,
useVIntl,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import type { Archon, Labrinth } from '@modrinth/api-client'
import {
BookmarkIcon,
CheckIcon,
@@ -12,6 +12,7 @@ import {
InfoIcon,
LeftArrowIcon,
ListIcon,
MinecraftServerIcon,
MoreVerticalIcon,
SearchIcon,
XIcon,
@@ -20,6 +21,8 @@ import {
Avatar,
ButtonStyled,
Checkbox,
type CreationFlowContextValue,
CreationFlowModal,
defineMessages,
DropdownSelect,
injectModrinthClient,
@@ -31,30 +34,31 @@ import {
SearchSidebarFilter,
type SortType,
StyledInput,
Toggle,
useDebugLogger,
useSearch,
useServerSearch,
useVIntl,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
import { useQueryClient } from '@tanstack/vue-query'
import { capitalizeString, cycleValue } from '@modrinth/utils'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import { useThrottleFn, useTimeoutFn } from '@vueuse/core'
import { computed, type Reactive, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
import { projectQueryOptions } from '~/composables/queries/project'
import { versionQueryOptions } from '~/composables/queries/version'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import type { DisplayLocation, DisplayMode } from '~/plugins/cosmetics.ts'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('Discover')
const client = injectModrinthClient()
const queryClient = useQueryClient()
const filtersMenuOpen = ref(false)
const route = useNativeRoute()
const router = useNativeRouter()
const route = useRoute()
const router = useRouter()
const cosmetics = useCosmetics()
const tags = useGeneratedState()
@@ -62,8 +66,6 @@ const flags = useFeatureFlags()
const auth = await useAuth()
const { handleError } = injectNotificationManager()
const modrinthClient = injectModrinthClient()
const queryClient = useQueryClient()
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
const HOVER_DURATION_TO_PREFETCH_MS = 500
@@ -71,14 +73,20 @@ const HOVER_DURATION_TO_PREFETCH_MS = 500
const handleProjectMouseEnter = (result: Labrinth.Search.v2.ResultSearchProject) => {
const slug = result.slug || result.project_id
prefetchTimeout = useTimeoutFn(
() => queryClient.prefetchQuery(projectQueryOptions.v2(slug, modrinthClient)),
() => {
queryClient.prefetchQuery(projectQueryOptions.v2(slug, client))
queryClient.prefetchQuery(projectQueryOptions.v3(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.members(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.dependencies(result.project_id, client))
queryClient.prefetchQuery(projectQueryOptions.versionsV3(result.project_id, client))
},
HOVER_DURATION_TO_PREFETCH_MS,
{ immediate: false },
)
prefetchTimeout.start()
}
const handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const _handleServerProjectMouseEnter = (result: Labrinth.Search.v3.ResultSearchProject) => {
const slug = result.slug || result.project_id
prefetchTimeout = useTimeoutFn(
@@ -105,10 +113,6 @@ const currentType = computed(() =>
queryAsStringOrEmpty(route.params.type).replaceAll(/^\/|s\/?$/g, ''),
)
watch(currentType, (newType) => {
console.log('currentType changed:', newType)
})
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
const projectTypes = computed(() => (projectType.value ? [projectType.value.id] : []))
@@ -121,58 +125,104 @@ const resultsDisplayMode = computed<DisplayMode>(() =>
: 'list',
)
const server = ref<Reactive<ModrinthServer>>()
const serverHideInstalled = ref(false)
const eraseDataOnInstall = ref(false)
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || undefined)
debug('currentServerId:', currentServerId.value)
const PERSISTENT_QUERY_PARAMS = ['sid', 'shi']
async function updateServerContext() {
const serverId = queryAsString(route.query.sid)
if (!serverId) {
server.value = undefined
return
}
try {
if (!auth.value.user) {
router.push('/auth/sign-in?redirect=' + encodeURIComponent(route.fullPath))
return
}
if (!server.value || server.value.serverId !== serverId) {
server.value = await useModrinthServers(serverId, ['general', 'content'])
}
if (route.query.shi && projectType.value?.id !== 'modpack' && server.value) {
serverHideInstalled.value = route.query.shi === 'true'
}
} catch (error) {
console.error('Failed to load server context:', error)
server.value = undefined
}
}
if (import.meta.client && route.query.sid) {
updateServerContext().catch((error) => {
console.error('Failed to initialize server context:', error)
})
}
watch(
() => route.query.sid,
() => {
updateServerContext().catch((error) => {
console.error('Failed to update server context:', error)
})
const {
data: serverData,
isLoading: serverDataLoading,
error: serverDataError,
} = useQuery({
queryKey: computed(() => ['servers', 'detail', currentServerId.value] as const),
queryFn: () => {
debug('serverData queryFn firing for:', currentServerId.value)
return client.archon.servers_v0.get(currentServerId.value!)
},
enabled: computed(() => {
const enabled = !!currentServerId.value
debug('serverData enabled:', enabled)
return enabled
}),
})
watch(serverData, (val) =>
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
)
watch(serverDataLoading, (val) => debug('serverData loading:', val))
watch(serverDataError, (val) => {
if (val) debug('serverData error:', val)
})
const serverIcon = computed(() => {
if (!currentServerId.value || !import.meta.client) return null
return localStorage.getItem(`server-icon-${currentServerId.value}`)
})
const serverHideInstalled = ref(false)
// TanStack Query for server content list
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
const { data: serverContentData, error: serverContentError } = useQuery({
queryKey: contentQueryKey,
queryFn: () => client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
})
// Watch for errors and notify user
watch(serverContentError, (error) => {
if (error) {
console.error('Failed to load server content:', error)
handleError(error)
}
})
// Re-run search when server content loads so "Hide installed" filter applies
watch(serverContentData, () => {
if (serverHideInstalled.value) {
updateSearchResults(1, false)
}
})
// Install content mutation
const installContentMutation = useMutation({
mutationFn: ({
serverId,
projectId,
versionId,
}: {
serverId: string
projectId: string
versionId: string
}) =>
client.archon.content_v1.addAddon(serverId, currentWorldId.value!, {
project_id: projectId,
version_id: versionId,
}),
onSuccess: () => {
if (currentServerId.value) {
queryClient.refetchQueries({ queryKey: ['content', 'list', currentServerId.value] })
}
},
})
const PERSISTENT_QUERY_PARAMS = ['sid', 'wid', 'shi', 'from']
if (route.query.shi && projectType.value?.id !== 'modpack') {
serverHideInstalled.value = route.query.shi === 'true'
}
const serverFilters = computed(() => {
debug(
'serverFilters recomputing, serverData:',
!!serverData.value,
'projectType:',
projectType.value?.id,
)
const filters = []
if (server.value && projectType.value?.id !== 'modpack') {
const gameVersion = server.value.general?.mc_version
if (serverData.value && projectType.value?.id !== 'modpack') {
const gameVersion = serverData.value.mc_version
if (gameVersion) {
filters.push({
type: 'game_version',
@@ -180,7 +230,7 @@ const serverFilters = computed(() => {
})
}
const platform = server.value.general?.loader?.toLowerCase()
const platform = serverData.value.loader?.toLowerCase()
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
@@ -200,13 +250,20 @@ const serverFilters = computed(() => {
})
}
if (serverHideInstalled.value) {
const installedMods = server.value.content?.data
.filter((x: InstallableMod) => x.project_id)
.map((x: InstallableMod) => x.project_id)
.filter((id): id is string => id !== undefined)
if (projectType.value?.id === 'mod') {
filters.push({
type: 'environment',
option: 'server',
})
}
installedMods
if (serverHideInstalled.value && serverContentData.value) {
const installedIds = (serverContentData.value.addons ?? [])
.filter((x) => x.project_id)
.map((x) => x.project_id)
.filter((id): id is string => id !== null)
installedIds
.map((x: string) => ({
type: 'project_id',
option: `project_id:${x}`,
@@ -215,6 +272,20 @@ const serverFilters = computed(() => {
.forEach((x) => filters.push(x))
}
}
if (currentServerId.value && projectType.value?.id === 'modpack') {
filters.push(
{
type: 'environment',
option: 'client',
},
{
type: 'environment',
option: 'server',
},
)
}
debug('serverFilters result:', filters)
return filters
})
@@ -256,6 +327,7 @@ const {
// Functions
createPageParams,
} = useSearch(projectTypes, tags, serverFilters)
debug('useSearch initialized, requestParams:', requestParams.value)
const selectedFilterTags = computed(() =>
currentFilters.value
@@ -315,42 +387,67 @@ interface InstallableSearchResult extends Labrinth.Search.v2.ResultSearchProject
}
async function serverInstall(project: InstallableSearchResult) {
if (!server.value) {
if (!serverData.value || !currentServerId.value) {
handleError(new Error('No server to install to.'))
return
}
project.installing = true
try {
const versions = (await useBaseFetch(
`project/${project.project_id}/version`,
{},
true,
)) as Labrinth.Versions.v2.Version[]
const version =
versions.find(
(x) =>
x.game_versions.includes(server.value!.general.mc_version) &&
x.loaders.includes(server.value!.general.loader.toLowerCase()),
) ?? versions[0]
if (projectType.value?.id === 'modpack') {
await server.value.general.reinstall(
false,
project.project_id,
version.id,
undefined,
eraseDataOnInstall.value,
)
project.installed = true
navigateTo(`/hosting/manage/${server.value.serverId}/options/loader`)
} else if (projectType.value?.id === 'mod') {
await server.value.content.install('mod', version.project_id, version.id)
await server.value.refresh(['content'])
project.installed = true
} else if (projectType.value?.id === 'plugin') {
await server.value.content.install('plugin', version.project_id, version.id)
await server.value.refresh(['content'])
// TODO: restore limit=1 once the backend fix for version ordering is deployed (limit is applied before sorting)
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const versionId = versions[0]?.id ?? project.latest_version
if (!versionId) {
handleError(new Error('No version found for this modpack'))
project.installing = false
return
}
const modalInstance = onboardingModalRef.value
if (modalInstance) {
onboardingInstallingProject.value = project
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: project.project_id,
versionId,
name: project.title,
iconUrl: project.icon_url ?? undefined,
}
ctx.modal.value?.setStage('final-config')
}
return
} else if (
projectType.value?.id === 'mod' ||
projectType.value?.id === 'plugin' ||
projectType.value?.id === 'datapack'
) {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id)
const isDatapack = projectType.value?.id === 'datapack'
const version = versions.find((x) => {
if (!x.game_versions.includes(serverData.value!.mc_version!)) return false
if (isDatapack) return true
return x.loaders.includes(serverData.value!.loader!.toLowerCase())
})
if (!version) {
handleError(
new Error(
isDatapack
? `No compatible version found for ${serverData.value!.mc_version}`
: `No compatible version found for ${serverData.value!.mc_version} / ${serverData.value!.loader}`,
),
)
project.installing = false
return
}
await installContentMutation.mutateAsync({
serverId: currentServerId.value,
projectId: version.project_id,
versionId: version.id,
})
project.installed = true
}
} catch (e) {
@@ -361,28 +458,6 @@ async function serverInstall(project: InstallableSearchResult) {
}
const noLoad = ref(false)
const {
serverCurrentSortType,
serverCurrentFilters,
serverToggledGroups,
serverSortTypes,
serverFilterTypes,
serverRequestParams,
createServerPageParams,
} = useServerSearch({ tags, query, maxResults, currentPage })
const effectiveSortType = computed({
get: () => (currentType.value === 'server' ? serverCurrentSortType.value : currentSortType.value),
set: (v: SortType) => {
if (currentType.value === 'server') serverCurrentSortType.value = v
else currentSortType.value = v
},
})
const effectiveSortTypes = computed(() =>
currentType.value === 'server' ? serverSortTypes : [...sortTypes],
)
const {
data: rawResults,
refresh: refreshSearch,
@@ -390,35 +465,26 @@ const {
} = useLazyFetch(
() => {
const config = useRuntimeConfig()
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
if (currentType.value === 'server') {
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
return `${base}search${serverRequestParams.value}`
}
return `${base}search${requestParams.value}`
const url = `${base}search${requestParams.value}`
debug('useLazyFetch URL:', url)
return url
},
{
watch: false,
transform: (
hits: Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults,
): Labrinth.Search.v2.SearchResults => {
transform: (hits) => {
debug('useLazyFetch transform, hits:', (hits as any)?.total_hits)
noLoad.value = false
if ('hits_per_page' in hits) {
return {
hits: hits.hits as unknown as Labrinth.Search.v2.ResultSearchProject[],
total_hits: hits.total_hits,
limit: hits.hits_per_page,
offset: (hits.page - 1) * hits.hits_per_page,
}
}
return hits
return hits as Labrinth.Search.v2.SearchResults
},
},
)
const results = shallowRef(toRaw(rawResults))
watch(searchLoading, (val) => debug('searchLoading:', val))
watch(rawResults, (val) => debug('rawResults changed, total_hits:', val?.total_hits))
const results = computed(() => rawResults.value)
const pageCount = computed(() =>
results.value ? Math.ceil(results.value.total_hits / results.value.limit) : 1,
)
@@ -428,6 +494,14 @@ function scrollToTop(behavior: ScrollBehavior = 'smooth') {
}
function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
debug(
'updateSearchResults called, page:',
pageNumber,
'query:',
query.value,
'requestParams:',
requestParams.value,
)
currentPage.value = pageNumber
if (resetScroll) {
scrollToTop()
@@ -435,9 +509,11 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
noLoad.value = true
if (query.value === null) {
debug('updateSearchResults: query is null, returning early')
return
}
debug('updateSearchResults: calling refreshSearch')
refreshSearch()
if (import.meta.client) {
@@ -457,7 +533,7 @@ function updateSearchResults(pageNumber: number = 1, resetScroll = true) {
const params = {
...persistentParams,
...(currentType.value === 'server' ? createServerPageParams() : createPageParams()),
...createPageParams(),
}
router.replace({ path: route.path, query: params })
@@ -468,12 +544,6 @@ watch([currentFilters], () => {
updateSearchResults(1, false)
})
watch([serverCurrentFilters, serverCurrentSortType], () => {
if (currentType.value === 'server') {
updateSearchResults(1, false)
}
})
const throttledSearch = useThrottleFn(() => updateSearchResults(), 500, true)
function cycleSearchDisplayMode() {
@@ -507,79 +577,116 @@ const description = computed(
`Search and browse thousands of Minecraft ${projectType.value?.display ?? 'project'}s on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft ${projectType.value?.display ?? 'project'}s.`,
)
const serverBackUrl = computed(() => {
if (!serverData.value) return ''
const id = serverData.value.server_id
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
if (fromContext.value === 'reset-server') return `/hosting/manage/${id}/options/loader`
return `/hosting/manage/${id}/content`
})
// Onboarding modpack flow: show creation flow modal overlay on discovery page
const onboardingModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
const onboardingInstallingProject = ref<InstallableSearchResult | null>(null)
function onOnboardingHide() {
if (onboardingInstallingProject.value) {
onboardingInstallingProject.value.installing = false
onboardingInstallingProject.value = null
}
}
function onOnboardingBack() {
onboardingModalRef.value?.hide()
}
async function onModpackFlowCreate(config: CreationFlowContextValue) {
if (!currentServerId.value || !config.modpackSelection.value) return
try {
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties: config.buildProperties(),
} satisfies Archon.Content.v1.InstallWorldContent)
if (fromContext.value === 'onboarding') {
await client.archon.servers_v1.endIntro(currentServerId.value)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
} else {
navigateTo(`/hosting/manage/${currentServerId.value}/options/loader`)
}
} catch (e) {
handleError(new Error(`Error installing modpack: ${e}`))
config.loading.value = false
}
}
useSeoMeta({
description,
ogTitle,
ogDescription: description,
})
const serverHits = computed(
() =>
((rawResults.value as unknown as Labrinth.Search.v3.SearchResults)
?.hits as Labrinth.Search.v3.ResultSearchProject[]) ?? [],
)
const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) => {
const content = hit.minecraft_java_server?.content
if (content?.kind === 'modpack') {
const { project_name, project_icon, project_id } = content
if (!project_name) return undefined
return {
name: project_name,
icon: project_icon,
onclick:
project_id !== hit.project_id
? () => {
navigateTo(`/project/${project_id}`)
}
: undefined,
showCustomModpackTooltip: project_id === hit.project_id,
}
}
return undefined
}
</script>
<template>
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
<div class="search-background"></div>
</Teleport>
<Teleport v-if="server" to="#discover-header-prefix">
<Teleport v-if="serverData" to="#discover-header-prefix" defer>
<div
class="mb-4 flex flex-wrap items-center justify-between gap-3 border-0 border-b border-solid border-divider pb-4"
>
<nuxt-link
:to="`/servers/manage/${server.serverId}/content`"
<button
tabindex="-1"
class="flex flex-col gap-4 text-primary"
class="flex cursor-pointer flex-col gap-4 bg-transparent text-primary"
@click="navigateTo(serverBackUrl)"
>
<span class="flex items-center gap-2">
<Avatar
:src="
server.general.is_medal
serverData.is_medal
? 'https://cdn-raw.modrinth.com/medal_icon.webp'
: server.general.image
: (serverIcon ?? MinecraftServerIcon)
"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="bold font-extrabold text-contrast">
{{ server.general.name }}
{{ serverData.name }}
</span>
<span class="flex items-center gap-2 font-semibold text-secondary">
<GameIcon class="h-5 w-5 text-secondary" />
{{ server.general.loader }} {{ server.general.mc_version }}
{{ serverData.loader }} {{ serverData.mc_version }}
</span>
</span>
</span>
</nuxt-link>
</button>
<ButtonStyled>
<nuxt-link :to="`/hosting/manage/${server.serverId}/content`">
<button @click="navigateTo(serverBackUrl)">
<LeftArrowIcon />
Back to server
</nuxt-link>
{{
fromContext === 'onboarding'
? 'Back to setup'
: fromContext === 'reset-server'
? 'Cancel reset'
: 'Back to server'
}}
</button>
</ButtonStyled>
</div>
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">Install content to server</h1>
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{
fromContext === 'reset-server'
? 'Select modpack to install after reset'
: 'Install content to server'
}}
</h1>
</Teleport>
<aside
@@ -588,7 +695,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
}"
aria-label="Filters"
>
<AdPlaceholder v-if="!auth.user && !server" />
<AdPlaceholder v-if="!auth.user && !serverData" />
<div v-if="filtersMenuOpen" class="fixed inset-0 z-40 bg-bg"></div>
<div
class="flex flex-col gap-3"
@@ -615,23 +722,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</ButtonStyled>
</div>
<div
v-if="server && projectType?.id === 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised"
>
<div class="flex flex-row items-center gap-2 px-6 py-4 text-contrast">
<h3 class="m-0 text-lg">Options</h3>
</div>
<div class="flex flex-row items-center justify-between gap-2 px-6">
<label for="erase-data-on-install"> Erase all data on install </label>
<Toggle id="erase-data-on-install" v-model="eraseDataOnInstall" class="flex-none" />
</div>
<div class="px-6 py-4 text-sm">
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the selected modpack.
</div>
</div>
<div
v-if="server && projectType?.id !== 'modpack'"
v-if="serverData && projectType?.id !== 'modpack'"
class="card-shadow rounded-2xl bg-bg-raised p-4"
>
<Checkbox
@@ -641,73 +732,41 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@update:model-value="updateSearchResults()"
/>
</div>
<template v-if="currentType === 'server'">
<SearchSidebarFilter
v-for="filterType in serverFilterTypes.filter((f) => f.options.length > 0)"
:key="`server-filter-${filterType.id}`"
v-model:selected-filters="serverCurrentFilters"
v-model:toggled-groups="serverToggledGroups"
:provided-filters="serverFilters"
:filter-type="filterType"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="
![
'server_category_minecraft_server_meta',
'server_category_minecraft_server_community',
'server_game_version',
'server_status',
].includes(filterType.id)
"
>
<template #header>
<h3 class="m-0 text-lg">{{ filterType.formatted_name }}</h3>
</template>
</SearchSidebarFilter>
</template>
<template v-else>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</template>
<SearchSidebarFilter
v-for="filter in filters.filter((f) => f.display !== 'none')"
:key="`filter-${filter.id}`"
v-model:selected-filters="currentFilters"
v-model:toggled-groups="toggledGroups"
v-model:overridden-provided-filter-types="overriddenProvidedFilterTypes"
:provided-filters="serverFilters"
:filter-type="filter"
:class="
filtersMenuOpen
? 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
: 'card-shadow rounded-2xl bg-bg-raised'
"
button-class="button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none"
content-class="mb-4 mx-3"
inner-panel-class="p-1"
:open-by-default="!(currentType === 'shader' && filter.id === 'game_version')"
>
<template #header>
<h3 class="m-0 text-lg">{{ filter.formatted_name }}</h3>
</template>
<template v-if="currentType === 'shader' && filter.id === 'game_version'" #prefix>
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
<InfoIcon class="mt-1 size-4" />
<span> {{ formatMessage(messages.gameVersionShaderMessage) }}</span>
</div>
</template>
<template #locked-game_version>
{{ formatMessage(messages.gameVersionProvidedByServer) }}
</template>
<template #locked-mod_loader>
{{ formatMessage(messages.modLoaderProvidedByServer) }}
</template>
<template #sync-button> {{ formatMessage(messages.syncFilterButton) }}</template>
</SearchSidebarFilter>
</div>
</aside>
<section class="normal-page__content">
@@ -727,10 +786,10 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
<div class="flex flex-wrap items-center gap-2">
<DropdownSelect
v-slot="{ selected }"
v-model="effectiveSortType"
v-model="currentSortType"
class="!w-auto flex-grow md:flex-grow-0"
name="Sort by"
:options="effectiveSortTypes"
:options="[...sortTypes]"
:display-name="(option?: SortType) => option?.display"
@change="updateSearchResults()"
>
@@ -776,14 +835,6 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
/>
</div>
<SearchFilterControl
v-if="currentType === 'server'"
v-model:selected-filters="serverCurrentFilters"
:filters="serverFilterTypes"
:provided-filters="[]"
:overridden-provided-filter-types="[]"
/>
<SearchFilterControl
v-else
v-model:selected-filters="currentFilters"
:filters="filters.filter((f) => f.display !== 'none')"
:provided-filters="serverFilters"
@@ -791,14 +842,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
:provided-message="messages.providedByServer"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div
v-else-if="
currentType === 'server'
? serverHits.length === 0
: results && results.hits && results.hits.length === 0
"
class="no-results"
>
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
</div>
<div v-else class="search-results-container">
@@ -808,37 +852,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
>
<template v-if="currentType === 'server'">
<template v-for="result in results?.hits" :key="result.project_id">
<ProjectCard
v-for="project in serverHits"
:key="`server-card-${project.project_id}`"
:title="project.name"
:icon-url="project.icon_url || undefined"
:summary="project.summary"
:tags="project.categories"
:link="`/server/${project.slug}`"
:server-online-players="
project.minecraft_java_server?.ping?.data?.players_online ?? 0
"
:server-recent-plays="project.minecraft_java_server?.verified_plays_2w ?? 0"
:server-region="project.minecraft_server?.region"
:server-status-online="!!project.minecraft_java_server?.ping?.data"
:server-modpack-content="getServerModpackContent(project)"
:layout="
resultsDisplayMode === 'grid' || resultsDisplayMode === 'gallery' ? 'grid' : 'list'
"
:max-tags="2"
is-server-project
exclude-loaders
@mouseenter="handleServerProjectMouseEnter(project)"
@mouseleave="handleProjectHoverEnd"
>
</ProjectCard>
</template>
<template v-else>
<ProjectCard
v-for="result in results?.hits"
:key="result.project_id"
:link="`/${projectType?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`"
:title="result.title"
:icon-url="result.icon_url"
@@ -858,8 +873,8 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
:environment="
['mod', 'modpack'].includes(currentType)
? {
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
clientSide: result.client_side,
serverSide: result.server_side,
}
: undefined
"
@@ -869,7 +884,7 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
@mouseenter="handleProjectMouseEnter(result)"
@mouseleave="handleProjectHoverEnd"
>
<template v-if="flags.showDiscoverProjectButtons || server" #actions>
<template v-if="flags.showDiscoverProjectButtons || serverData" #actions>
<template v-if="flags.showDiscoverProjectButtons">
<ButtonStyled color="brand">
<button>
@@ -893,16 +908,16 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</button>
</ButtonStyled>
</template>
<template v-else-if="server">
<template v-else-if="serverData">
<ButtonStyled color="brand" type="outlined">
<button
v-if="
(result as InstallableSearchResult).installed ||
(server?.content?.data &&
server.content.data.find(
(x: InstallableMod) => x.project_id === result.project_id,
(serverContentData &&
(serverContentData.addons ?? []).find(
(x) => x.project_id === result.project_id,
)) ||
server.general?.project?.id === result.project_id
serverData.upstream?.project_id === result.project_id
"
disabled
>
@@ -933,6 +948,18 @@ const getServerModpackContent = (hit: Labrinth.Search.v3.ResultSearchProject) =>
</div>
</div>
</section>
<CreationFlowModal
v-if="currentServerId && projectType?.id === 'modpack'"
ref="onboardingModalRef"
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
:show-snapshot-toggle="true"
:on-back="onOnboardingBack"
@hide="onOnboardingHide"
@browse-modpacks="() => {}"
@create="onModpackFlowCreate"
/>
</template>
<style lang="scss" scoped>
.normal-page__content {

View File

@@ -51,10 +51,7 @@
/>
</div>
<div
v-else-if="
server.moduleErrors?.general?.error.statusCode === 403 ||
server.moduleErrors?.general?.error.statusCode === 404
"
v-else-if="serverError?.statusCode === 403 || serverError?.statusCode === 404"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
@@ -67,7 +64,7 @@
/>
</div>
<div
v-else-if="server.moduleErrors?.general?.error || !nodeAccessible"
v-else-if="serverError || !nodeAccessible"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
@@ -95,39 +92,18 @@
</template>
</ErrorInformationCard>
</div>
<!-- <div
v-else-if="server.moduleErrors?.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<ErrorInformationCard
title="Connection lost"
description=""
:icon="TransferIcon"
icon-color="orange"
:action="connectionLostAction"
>
<template #description>
<div class="space-y-4">
<p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue.
</p>
</div>
</template>
</ErrorInformationCard>
</div> -->
<!-- SERVER START -->
<div
v-else-if="serverData"
data-pyro-server-manager-root
class="experimental-styles-within mobile-blurred-servericon relative mx-auto mb-12 box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-6 transition-all duration-300"
:style="{
'--server-bg-image': serverData.image
? `url(${serverData.image})`
'--server-bg-image': serverImage
? `url(${serverImage})`
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
}"
>
<div>
<div class="border-0 border-b border-solid border-divider pb-4">
<NuxtLink to="/hosting/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon />
All servers
@@ -135,7 +111,7 @@
<div class="flex w-full min-w-0 select-none flex-col items-center gap-4 pt-4 sm:flex-row">
<ServerIcon
:image="
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverData.image
serverData.is_medal ? 'https://cdn-raw.modrinth.com/medal_icon.webp' : serverImage
"
class="drop-shadow-lg sm:drop-shadow-none"
/>
@@ -163,7 +139,9 @@
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
:backup-in-progress="backupInProgress"
:busy-reason="
busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined
"
@action="sendPowerAction"
/>
</div>
@@ -188,26 +166,7 @@
</div>
</div>
<template v-if="serverData.flows?.intro">
<div
v-if="serverData?.status === 'installing'"
class="w-50 h-50 flex items-center justify-center gap-2 text-center text-lg font-bold"
>
<PanelSpinner class="size-10 animate-spin" /> Setting up your server...
</div>
<div v-else>
<h2 class="my-4 text-xl font-extrabold">
What would you like to install on your new server?
</h2>
<ServerInstallation
:server="server as ModrinthServer"
:backup-in-progress="backupInProgress"
ignore-current-installation
@reinstall="onReinstall"
/>
</div>
</template>
<ServerOnboardingPanelPage v-if="serverData.flows?.intro" />
<template v-else>
<div
@@ -309,7 +268,7 @@
</div>
<div v-if="serverData.is_medal" class="mb-4">
<MedalServerCountdown :server-id="server.serverId" />
<MedalServerCountdown :server-id="serverId" />
</div>
<div
@@ -330,21 +289,28 @@
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
<Transition
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-40"
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
leave-from-class="opacity-100 max-h-40"
leave-to-class="opacity-0 max-h-0"
>
<ServerIcon :image="serverData.image" class="!h-10 !w-10" />
<div class="flex flex-col gap-1">
<span class="text-lg font-bold"> We're preparing your server! </span>
<div class="flex flex-row items-center gap-2">
<PanelSpinner class="!h-3 !w-3" />
<InstallingTicker />
</div>
</div>
</div>
<InstallingBanner
v-if="
(serverData.status === 'installing' || isSyncingContent) &&
syncProgress?.phase !== 'Analyzing'
"
data-pyro-server-installing
class="mb-4"
:progress="syncProgress"
>
<template #icon>
<ServerIcon :image="serverImage" class="!h-6 !w-6" />
</template>
</InstallingBanner>
</Transition>
<NuxtPage
:route="route"
:is-connected="isConnected"
@@ -353,9 +319,8 @@
:stats="stats"
:server-power-state="serverPowerState"
:power-state-details="powerStateDetails"
:server="server"
:backup-in-progress="backupInProgress"
@reinstall="onReinstall"
@reinstall-failed="onReinstallFailed"
/>
</div>
</template>
@@ -366,7 +331,7 @@
>
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
safeStringify(server)
safeStringify(serverData)
}}</pre>
</div>
</template>
@@ -374,7 +339,7 @@
<script setup lang="ts">
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
import type { Archon } from '@modrinth/api-client'
import { clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client'
import { clearNodeAuthState, ModrinthApiError, setNodeAuthState } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
@@ -390,37 +355,41 @@ import {
SettingsIcon,
TransferIcon,
} from '@modrinth/assets'
import type { MessageDescriptor } from '@modrinth/ui'
import type { BusyReason } from '@modrinth/ui'
import {
ButtonStyled,
defineMessage,
ErrorInformationCard,
formatLoaderLabel,
injectModrinthClient,
injectNotificationManager,
InstallingBanner,
provideModrinthServerContext,
ServerIcon,
ServerInfoLabels,
ServerNotice,
ServerOnboardingPanelPage,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { PowerAction, Stats } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useTimeoutFn } from '@vueuse/core'
import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, type Reactive, reactive, ref } from 'vue'
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { reloadNuxtApp } from '#app'
import NavTabs from '~/components/ui/NavTabs.vue'
import PanelErrorIcon from '~/components/ui/servers/icons/PanelErrorIcon.vue'
import InstallingTicker from '~/components/ui/servers/InstallingTicker.vue'
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
import { useServerImage } from '~/composables/servers/use-server-image.ts'
import { useServerProject } from '~/composables/servers/use-server-project.ts'
import { useModrinthServersConsole } from '~/store/console.ts'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const isReconnecting = ref(false)
@@ -440,26 +409,46 @@ const createdAt = ref(
auth.value?.user?.created ? Math.floor(new Date(auth.value.user.created).getTime() / 1000) : null,
)
const debug = useDebugLogger('ServerManage')
const route = useNativeRoute()
const router = useRouter()
const serverId = route.params.id as string
// TODO: ditch useModrinthServers for this + ctx DI.
const { data: n_server } = useQuery({
const { data: serverData, error: serverQueryError } = useQuery({
queryKey: ['servers', 'detail', serverId],
queryFn: () => client.archon.servers_v0.get(serverId)!,
})
const server: Reactive<ModrinthServer> = await useModrinthServers(serverId, ['general', 'ws'])
function updateServerData(patch: Partial<Archon.Servers.v0.Server>) {
if (!serverData.value) return
queryClient.setQueryData(['servers', 'detail', serverId], {
...serverData.value,
...patch,
})
}
const loadModulesPromise = Promise.resolve().then(() => {
if (server.general?.status === 'suspended') {
return
}
return server.refresh(['content', 'backups', 'network', 'startup'])
const serverError = computed(() => {
const err = serverQueryError.value
if (err instanceof ModrinthApiError) return err
return err ? ModrinthApiError.fromUnknown(err) : null
})
provide('modulesLoaded', loadModulesPromise)
const { data: serverFull } = useQuery({
queryKey: ['servers', 'v1', 'detail', serverId],
queryFn: () => client.archon.servers_v1.get(serverId),
})
const worldId = computed(() => {
if (!serverFull.value) return null
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
})
const serverImage = useServerImage(
serverId,
computed(() => serverData.value?.upstream ?? null),
)
const { data: serverProject } = useServerProject(computed(() => serverData.value?.upstream ?? null))
const errorTitle = ref('Error')
const errorMessage = ref('An unexpected error occurred.')
@@ -483,7 +472,6 @@ function safeStringify(obj: unknown, indent = ' '): string {
)
}
const serverData = computed(() => server.general)
const isConnected = ref(false)
const isWSAuthIncorrect = ref(false)
const modrinthServersConsole = useModrinthServersConsole()
@@ -502,6 +490,70 @@ const markBackupCancelled = (backupId: string) => {
cancelledBackups.add(backupId)
}
// Parthenon state event
const syncProgress = ref<Archon.Websocket.v0.SyncContentProgress | null>(null)
const syncProgressActive = ref(false)
const isAwaitingPostInstallRefresh = ref(false)
const { start: startSyncHide, stop: cancelSyncHide } = useTimeoutFn(
() => (syncProgressActive.value = false),
1000,
{ immediate: false },
)
watch(syncProgress, (progress) => {
if (progress != null) {
cancelSyncHide()
syncProgressActive.value = true
} else if (syncProgressActive.value) {
startSyncHide()
}
})
const isSyncingContent = computed(
() => syncProgressActive.value || isAwaitingPostInstallRefresh.value,
)
const busyReasons = computed(() => {
const reasons: BusyReason[] = []
if (serverData.value?.status === 'installing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
defaultMessage: 'Server is installing',
}),
})
}
if (isSyncingContent.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.syncing-content',
defaultMessage: 'Content sync in progress',
}),
})
}
for (const entry of backupsState.values()) {
if (entry.create?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-creating',
defaultMessage: 'Backup creation in progress',
}),
})
break
}
if (entry.restore?.state === 'ongoing') {
reasons.push({
reason: defineMessage({
id: 'servers.busy.backup-restoring',
defaultMessage: 'Backup restore in progress',
}),
})
break
}
}
return reasons
})
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
@@ -520,12 +572,15 @@ setNodeAuthState(() => fsAuth.value, refreshFsAuth)
provideModrinthServerContext({
serverId,
server: n_server as Ref<Archon.Servers.v0.Server>,
worldId,
server: serverData as Ref<Archon.Servers.v0.Server>,
isConnected,
powerState: serverPowerState,
isServerRunning,
backupsState,
markBackupCancelled,
isSyncingContent,
busyReasons,
fsAuth,
fsOps,
fsQueuedOps,
@@ -665,8 +720,8 @@ const popupOptions = computed(
server_id: serverData.value?.server_id,
loader: serverData.value?.loader,
game_version: serverData.value?.mc_version,
modpack_id: serverData.value?.project?.id,
modpack_name: serverData.value?.project?.title,
modpack_id: serverProject.value?.id,
modpack_name: serverProject.value?.title,
},
onOpen: () => console.log(`Opened survey notice: ${surveyNotice.value?.id}`),
onClose: async () => await dismissSurvey(),
@@ -736,6 +791,57 @@ const handlePowerState = (data: Archon.Websocket.v0.WSPowerStateEvent) => {
}
}
const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
debug('[id.vue] handleState received:', {
power_variant: data.power_variant,
progress: data.progress,
serverStatus: serverData.value?.status,
})
syncProgress.value = data.progress
// Sync power state from the state event
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
{
not_ready: 'stopped',
starting: 'starting',
running: 'running',
stopping: 'stopping',
idle:
data.was_oom || (data.exit_code != null && data.exit_code !== 0) ? 'crashed' : 'stopped',
}
updatePowerState(powerMap[data.power_variant], {
exit_code: data.exit_code ?? undefined,
oom_killed: data.was_oom,
})
// Sync uptime
if (data.uptime > 0) {
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
startUptimeUpdates()
}
// Update installing status from progress presence
if (serverData.value) {
if (data.progress != null && serverData.value.status !== 'installing') {
debug('[id.vue] handleState: progress != null, setting status to installing')
hasSeenInstallProgress = true
updateServerData({ status: 'installing' })
} else if (data.progress != null) {
hasSeenInstallProgress = true
} else if (
data.progress == null &&
serverData.value.status === 'installing' &&
hasSeenInstallProgress
) {
debug('[id.vue] handleState: progress null + was installing, applying optimistic update')
hasSeenInstallProgress = false
applyOptimisticCompletion()
invalidateAfterInstall()
}
}
}
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
stopUptimeUpdates()
uptimeSeconds.value = data.uptime
@@ -847,21 +953,27 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
}
const handleNewMod = () => {
server.refresh(['content'])
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
const newLoader = ref<string | null>(null)
const newLoaderVersion = ref<string | null>(null)
const newMCVersion = ref<string | null>(null)
let hasSeenInstallProgress = false
const onReinstall = async (potentialArgs: any) => {
debug('[id.vue] onReinstall called with:', potentialArgs)
const onReinstall = (potentialArgs: any) => {
if (serverData.value?.flows?.intro) {
server.general?.endIntro()
await client.archon.servers_v1.endIntro(serverId)
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}
if (!serverData.value) return
serverData.value.status = 'installing'
debug('[id.vue] onReinstall: setting serverData.status to installing')
hasSeenInstallProgress = false
updateServerData({ status: 'installing' })
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader
@@ -873,52 +985,110 @@ const onReinstall = (potentialArgs: any) => {
newMCVersion.value = potentialArgs.mVersion
}
debug('[id.vue] onReinstall: stored refs:', {
newLoader: newLoader.value,
newLoaderVersion: newLoaderVersion.value,
newMCVersion: newMCVersion.value,
})
error.value = null
errorTitle.value = 'Error'
errorMessage.value = 'An unexpected error occurred.'
// Immediately refetch so loader.vue has fresh data (buttons stay locked via isSyncingContent)
debug('[id.vue] onReinstall: triggering immediate invalidation for loader.vue')
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
}
const onReinstallFailed = () => {
debug('[id.vue] onReinstallFailed: reverting status to available')
updateServerData({ status: 'available' })
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
}
function applyOptimisticCompletion() {
const patch: Partial<Archon.Servers.v0.Server> = { status: 'available' }
if (newLoader.value) patch.loader = formatLoaderLabel(newLoader.value) as Archon.Servers.v0.Loader
if (newLoaderVersion.value) patch.loader_version = newLoaderVersion.value
if (newMCVersion.value) patch.mc_version = newMCVersion.value
debug('[id.vue] applyOptimisticCompletion: patch:', patch)
updateServerData(patch)
const addonsQueries = queryClient.getQueriesData<Archon.Content.v1.Addons>({
queryKey: ['content', 'list', 'v1', serverId],
})
debug(
'[id.vue] applyOptimisticCompletion: found',
addonsQueries.length,
'addons queries to patch',
)
for (const [key, data] of addonsQueries) {
if (!data) continue
const addonsPatch: Record<string, string> = {}
if (newLoader.value) addonsPatch.modloader = newLoader.value
if (newLoaderVersion.value) addonsPatch.modloader_version = newLoaderVersion.value
if (newMCVersion.value) addonsPatch.game_version = newMCVersion.value
if (Object.keys(addonsPatch).length > 0) {
debug('[id.vue] applyOptimisticCompletion: patching addons cache:', addonsPatch)
queryClient.setQueryData(key, { ...data, ...addonsPatch })
}
}
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
}
async function invalidateAfterInstall() {
debug(
'[id.vue] invalidateAfterInstall: setting isAwaitingPostInstallRefresh=true, scheduling 2s delayed invalidation',
)
isAwaitingPostInstallRefresh.value = true
setTimeout(async () => {
debug('[id.vue] invalidateAfterInstall: delayed invalidation firing now')
try {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'startup', 'v1', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
])
debug('[id.vue] invalidateAfterInstall: delayed invalidation complete')
} catch (err: unknown) {
console.error('Error refreshing data after installation:', err)
} finally {
debug('[id.vue] invalidateAfterInstall: setting isAwaitingPostInstallRefresh=false')
isAwaitingPostInstallRefresh.value = false
}
}, 2000)
}
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
debug('[id.vue] handleInstallationResult received:', data)
switch (data.result) {
case 'ok': {
debug('[id.vue] handleInstallationResult: ok received')
if (!serverData.value) break
try {
await new Promise((resolve) => setTimeout(resolve, 2000))
debug('[id.vue] handleInstallationResult: stored refs:', {
newLoader: newLoader.value,
newLoaderVersion: newLoaderVersion.value,
newMCVersion: newMCVersion.value,
})
debug('[id.vue] handleInstallationResult: current serverData:', {
status: serverData.value.status,
loader: serverData.value.loader,
loader_version: serverData.value.loader_version,
mc_version: serverData.value.mc_version,
})
let attempts = 0
const maxAttempts = 3
let hasValidData = false
while (!hasValidData && attempts < maxAttempts) {
attempts++
await server.refresh(['general'], {
preserveConnection: true,
preserveInstallState: true,
})
if (serverData.value?.loader && serverData.value?.mc_version) {
hasValidData = true
serverData.value.status = 'available'
await server.refresh(['content', 'startup'])
break
}
await new Promise((resolve) => setTimeout(resolve, 2000))
}
if (!hasValidData) {
console.error('Failed to get valid server data after installation')
}
} catch (err: unknown) {
console.error('Error refreshing data after installation:', err)
}
newLoader.value = null
newLoaderVersion.value = null
newMCVersion.value = null
applyOptimisticCompletion()
error.value = null
invalidateAfterInstall()
break
}
case 'err': {
@@ -1010,7 +1180,7 @@ const sendPowerAction = async (action: PowerAction) => {
const actionName = action.charAt(0).toUpperCase() + action.slice(1)
try {
isActioning.value = true
await server.general?.power(action)
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error ${toAdverb(actionName)} server:`, error)
notifyError(
@@ -1030,46 +1200,24 @@ const notifyError = (title: string, text: string) => {
})
}
export type BackupInProgressReason = {
type: string
tooltip: MessageDescriptor
}
const restoreInProgressReason = {
type: 'restore',
tooltip: defineMessage({
id: 'servers.backup.restore.in-progress.tooltip',
defaultMessage: 'Backup restore in progress',
}),
} satisfies BackupInProgressReason
const backupInProgress = computed(() => {
for (const entry of backupsState.values()) {
if (entry.restore?.state === 'ongoing') {
return restoreInProgressReason
}
}
return undefined
})
const nodeUnavailableDetails = computed(() => [
{
label: 'Server ID',
value: server.serverId,
value: serverId,
type: 'inline' as const,
},
{
label: 'Node',
value:
(server.moduleErrors?.general?.error.responseData as any)?.hostname ??
server.general?.datacenter ??
(serverError.value?.responseData as any)?.hostname ??
serverData.value?.datacenter ??
'Unknown',
type: 'inline' as const,
},
{
label: 'Error message',
value: nodeAccessible.value
? (server.moduleErrors?.general?.error.message ?? 'Unknown')
? (serverError.value?.message ?? 'Unknown')
: 'Unable to reach node. Ping test failed.',
type: 'block' as const,
},
@@ -1088,38 +1236,38 @@ const suspendedDescription = computed(() => {
const generalErrorDetails = computed(() => [
{
label: 'Server ID',
value: server.serverId,
value: serverId,
type: 'inline' as const,
},
{
label: 'Timestamp',
value: String(server.moduleErrors?.general?.timestamp),
value: String(new Date().toISOString()),
type: 'inline' as const,
},
{
label: 'Error Name',
value: server.moduleErrors?.general?.error.name,
value: serverError.value?.name,
type: 'inline' as const,
},
{
label: 'Error Message',
value: server.moduleErrors?.general?.error.message,
value: serverError.value?.message,
type: 'block' as const,
},
...(server.moduleErrors?.general?.error.originalError
...(serverError.value?.originalError
? [
{
label: 'Original Error',
value: String(server.moduleErrors.general.error.originalError),
value: String(serverError.value.originalError),
type: 'hidden' as const,
},
]
: []),
...(server.moduleErrors?.general?.error.stack
...(serverError.value?.stack
? [
{
label: 'Stack Trace',
value: server.moduleErrors.general.error.stack,
value: serverError.value.stack,
type: 'hidden' as const,
},
]
@@ -1186,35 +1334,70 @@ const cleanup = () => {
}
async function dismissNotice(noticeId: number) {
await useServersFetch(`servers/${serverId}/notices/${noticeId}/dismiss`, {
method: 'POST',
}).catch((err) => {
await client.archon.servers_v0.dismissNotice(serverId, noticeId).catch((err) => {
addNotification({
title: 'Error dismissing notice',
text: err,
type: 'error',
})
})
await server.refresh(['general'])
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
}
const nodeAccessible = ref(true)
onMounted(() => {
isMounted.value = true
if (server.general?.status === 'suspended') {
async function testNodeReachability(): Promise<boolean> {
const nodeInstance = serverData.value?.node?.instance
if (!nodeInstance) {
console.warn('No node instance available for ping test')
return false
}
const wsUrl = `wss://${nodeInstance}/pingtest`
try {
return await new Promise((resolve) => {
const socket = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
socket.close()
resolve(false)
}, 5000)
socket.onopen = () => {
clearTimeout(timeout)
socket.send(performance.now().toString())
}
socket.onmessage = () => {
clearTimeout(timeout)
socket.close()
resolve(true)
}
socket.onerror = () => {
clearTimeout(timeout)
resolve(false)
}
})
} catch (error) {
console.error(`Failed to ping node ${wsUrl}:`, error)
return false
}
}
function initializeServer() {
if (serverData.value?.status === 'suspended') {
isLoading.value = false
return
}
// Skip node test if node is null (upgrading/provisioning)
if (server.general?.node === null) {
if (serverData.value?.node === null) {
isLoading.value = false
return
}
server
.testNodeReachability()
testNodeReachability()
.then((result) => {
nodeAccessible.value = result
if (!nodeAccessible.value) {
@@ -1227,7 +1410,7 @@ onMounted(() => {
isLoading.value = false
})
if (server.moduleErrors.general?.error) {
if (serverError.value) {
isLoading.value = false
} else {
client.archon.sockets
@@ -1244,6 +1427,7 @@ onMounted(() => {
unsubscribers.value = [
client.archon.sockets.on(serverId, 'log', handleLog),
client.archon.sockets.on(serverId, 'stats', handleStats),
client.archon.sockets.on(serverId, 'state', handleState),
client.archon.sockets.on(serverId, 'power-state', handlePowerState),
client.archon.sockets.on(serverId, 'uptime', handleUptime),
client.archon.sockets.on(serverId, 'auth-incorrect', handleAuthIncorrect),
@@ -1255,14 +1439,33 @@ onMounted(() => {
]
})
.catch((error) => {
console.error('Failed to connect WebSocket:', error)
debug('[id.vue] Failed to connect WebSocket:', error)
isConnected.value = false
isLoading.value = false
})
}
if (server.general?.flows?.intro && server.general?.project) {
server.general?.endIntro()
if (serverData.value?.flows?.intro && serverProject.value) {
client.archon.servers_v1.endIntro(serverId).then(() => {
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
})
}
}
onMounted(() => {
isMounted.value = true
// serverData comes from useQuery and may not be available yet at mount time.
// Wait for it before initializing WebSocket, node reachability, etc.
if (serverData.value) {
initializeServer()
} else {
const stopWatch = watch(serverData, (data) => {
if (data) {
stopWatch()
initializeServer()
}
})
}
if (username.value && email.value && userId.value && createdAt.value) {

View File

@@ -1,21 +1,14 @@
<template>
<div class="flex h-full w-full flex-col">
<NuxtPage :route="route" :server="props.server" />
</div>
</template>
<script setup lang="ts">
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { injectModrinthServerContext, ServersManageContentPage } from '@modrinth/ui'
const route = useNativeRoute()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
const { server } = injectModrinthServerContext()
const flags = useFeatureFlags()
useHead({
title: `Content - ${data.value?.name ?? 'Server'} - Modrinth`,
title: `Content - ${server.value?.name ?? 'Server'} - Modrinth`,
})
</script>
<template>
<ServersManageContentPage :show-client-only-filter="flags.developerMode" />
</template>

View File

@@ -1,704 +0,0 @@
<template>
<ContentVersionEditModal
v-if="!invalidModal"
ref="versionEditModal"
:type="type"
:mod-pack="Boolean(props.server.general?.upstream)"
:game-version="props.server.general?.mc_version ?? ''"
:loader="props.server.general?.loader?.toLowerCase() ?? ''"
:server-id="props.server.serverId"
@change-version="changeModVersion($event)"
/>
<div
v-if="server.moduleErrors.content"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.content.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col">
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
<div class="flex w-full flex-col-reverse items-center gap-2 sm:flex-row">
<div class="flex w-full items-center gap-2">
<div class="flex-1 text-sm">
<label class="sr-only" for="search">Search</label>
<StyledInput
id="search"
v-model="searchInput"
wrapper-class="w-full"
type="search"
:icon="SearchIcon"
name="search"
autocomplete="off"
:placeholder="`Search ${localMods.length} ${type.toLocaleLowerCase()}s...`"
@input="debouncedSearch"
/>
</div>
<ButtonStyled>
<TeleportOverflowMenu
position="bottom"
direction="left"
:aria-label="`Filter ${type}s`"
:options="[
{ id: 'all', action: () => (filterMethod = 'all') },
{ id: 'enabled', action: () => (filterMethod = 'enabled') },
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
]"
>
<span class="hidden whitespace-pre sm:block">
{{ filterMethodLabel }}
</span>
<FilterIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #all> All {{ type.toLocaleLowerCase() }}s </template>
<template #enabled> Only enabled </template>
<template #disabled> Only disabled </template>
</TeleportOverflowMenu></ButtonStyled
>
</div>
<div v-if="hasMods" class="flex w-full items-center gap-2 sm:w-fit">
<ButtonStyled>
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
<FileIcon />
Add file
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/discover/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<FilesUploadDropdown
ref="uploadDropdownRef"
class="rounded-xl bg-bg-raised"
:margin-bottom="16"
:file-type="type"
:current-path="`/${type.toLocaleLowerCase()}s`"
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
@upload-complete="() => props.server.refresh(['content'])"
/>
<FilesUploadDragAndDrop
v-if="server.general && localMods"
class="relative min-h-[50vh]"
overlay-class="rounded-xl border-2 border-dashed border-secondary"
:type="type"
@files-dropped="handleDroppedFiles"
>
<div v-if="hasFilteredMods" class="flex flex-col gap-2 transition-all">
<div ref="listContainer" class="relative w-full">
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
<div
:style="{
position: 'absolute',
top: `${visibleTop}px`,
width: '100%',
}"
>
<template v-for="mod in visibleItems.items" :key="getStableModKey(mod)">
<div
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
style="height: 64px"
>
<NuxtLink
:to="
mod.project_id
? `/project/${mod.project_id}/version/${mod.version_id}`
: `files?path=${type.toLocaleLowerCase()}s`
"
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
draggable="false"
>
<Avatar
:src="mod.icon_url"
size="sm"
alt="Server Icon"
:class="mod.disabled ? 'opacity-75 grayscale' : ''"
/>
<div class="flex min-w-0 flex-col gap-1">
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
<span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
<span
v-if="mod.disabled"
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
>Disabled</span
>
</span>
<div class="min-w-0 text-xs text-secondary">
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
<span class="block font-semibold sm:hidden">
{{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
</span>
</div>
</div>
</NuxtLink>
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
<div class="truncate font-semibold text-contrast">
<span v-tooltip="`${type} version`">{{
mod.version_number || `External ${type.toLocaleLowerCase()}`
}}</span>
</div>
<div class="truncate">
<span v-tooltip="`${type} file name`">
{{ mod.filename }}
</span>
</div>
</div>
<div
class="flex items-center justify-end gap-2 pr-4 font-semibold text-contrast sm:min-w-44"
>
<ButtonStyled color="red" type="transparent">
<button
v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
:disabled="mod.changing"
class="!hidden sm:!block"
@click="removeMod(mod)"
>
<TrashIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="
mod.project_id
? `Edit ${type.toLocaleLowerCase()} version`
: `External ${type.toLocaleLowerCase()}s cannot be edited`
"
:disabled="mod.changing || !mod.project_id"
class="!hidden sm:!block"
@click="showVersionModal(mod)"
>
<template v-if="mod.changing">
<LoadingIcon class="animate-spin" />
</template>
<template v-else>
<EditIcon />
</template>
</button>
</ButtonStyled>
<!-- Dropdown for mobile -->
<div class="mr-2 flex items-center sm:hidden">
<LoadingIcon
v-if="mod.changing"
class="mr-2 h-5 w-5 animate-spin"
style="color: var(--color-base)"
/>
<ButtonStyled v-else circular type="transparent">
<TeleportOverflowMenu
:options="[
{
id: 'edit',
action: () => showVersionModal(mod),
shown: !!(mod.project_id && !mod.changing),
},
{
id: 'delete',
action: () => removeMod(mod),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #edit>
<EditIcon class="h-5 w-5" />
<span>Edit</span>
</template>
<template #delete>
<TrashIcon class="h-5 w-5" />
<span>Delete</span>
</template>
</TeleportOverflowMenu></ButtonStyled
>
</div>
<Toggle
:id="`toggle-${getStableModKey(mod)}`"
:model-value="!mod.disabled"
:disabled="mod.changing"
@update:model-value="toggleMod(mod)"
/>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- no mods has platform -->
<div
v-else-if="
props.server.general?.loader &&
props.server.general?.loader.toLocaleLowerCase() !== 'vanilla'
"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<div
v-if="!hasFilteredMods && hasMods"
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<SearchIcon class="size-24" />
<p class="m-0 font-bold text-contrast">
No {{ type.toLocaleLowerCase() }}s found for your query!
</p>
<p class="m-0">Try another query, or show everything.</p>
<ButtonStyled>
<button @click="showAll">
<ListIcon />
Show everything
</button>
</ButtonStyled>
</div>
<div
v-else
class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center"
>
<PackageClosedIcon class="size-24" />
<p class="m-0 font-bold text-contrast">No {{ type.toLocaleLowerCase() }}s found!</p>
<p class="m-0">
Add some {{ type.toLocaleLowerCase() }}s to your server to manage them here.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled type="outlined">
<button class="w-full text-nowrap sm:w-fit" @click="initiateFileUpload">
<FileIcon />
Add file
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/discover/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<div v-else class="mt-4 flex h-full flex-col items-center justify-center gap-4 text-center">
<LoaderIcon loader="Vanilla" class="size-24" />
<p class="m-0 pt-3 font-bold text-contrast">Your server is running Vanilla Minecraft</p>
<p class="m-0">
Add content to your server by installing a modpack or choosing a different platform that
supports {{ type }}s.
</p>
<div class="flex flex-row items-center gap-4">
<ButtonStyled class="mt-8">
<NuxtLink :to="`/discover/modpacks?sid=${props.server.serverId}`">
<CompassIcon />
Find a modpack
</NuxtLink>
</ButtonStyled>
<div>or</div>
<ButtonStyled class="mt-8">
<NuxtLink :to="`/hosting/manage/${props.server.serverId}/options/loader`">
<WrenchIcon />
Change platform
</NuxtLink>
</ButtonStyled>
</div>
</div>
</FilesUploadDragAndDrop>
</div>
</div>
</template>
<script setup lang="ts">
import {
CompassIcon,
DropdownIcon,
EditIcon,
FileIcon,
FilterIcon,
IssuesIcon,
ListIcon,
MoreVerticalIcon,
PackageClosedIcon,
PlusIcon,
SearchIcon,
TrashIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
injectModrinthClient,
injectNotificationManager,
StyledInput,
Toggle,
} from '@modrinth/ui'
import type { Mod } from '@modrinth/utils'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ContentVersionEditModal from '~/components/ui/servers/ContentVersionEditModal.vue'
import FilesUploadDragAndDrop from '~/components/ui/servers/FilesUploadDragAndDrop.vue'
import FilesUploadDropdown from '~/components/ui/servers/FilesUploadDropdown.vue'
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
import LoadingIcon from '~/components/ui/servers/icons/LoadingIcon.vue'
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const props = defineProps<{
server: ModrinthServer
}>()
const type = computed(() => {
const loader = props.server.general?.loader?.toLowerCase()
return loader === 'paper' || loader === 'purpur' ? 'Plugin' : 'Mod'
})
interface ContentItem extends Mod {
changing?: boolean
}
const ITEM_HEIGHT = 72
const BUFFER_SIZE = 5
const listContainer = ref<HTMLElement | null>(null)
const windowScrollY = ref(0)
const windowHeight = ref(0)
const localMods = ref<ContentItem[]>([])
const searchInput = ref('')
const modSearchInput = ref('')
const filterMethod = ref('all')
const uploadDropdownRef = ref()
const versionEditModal = ref()
const currentEditMod = ref<ContentItem | null>(null)
const invalidModal = computed(
() => !props.server.general?.mc_version || !props.server.general?.loader,
)
async function changeModVersion(event: string) {
const mod = currentEditMod.value
if (mod) mod.changing = true
try {
versionEditModal.value.hide()
// This will be used instead once backend implementation is done
// await props.server.content?.reinstall(
// `/${type.value.toLowerCase()}s/${event.fileName}`,
// currentMod.value.project_id,
// currentVersion.value.id,
// );
await props.server.content?.install(
type.value.toLowerCase() as 'mod' | 'plugin',
mod?.project_id || '',
event,
)
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod?.filename}`)
await props.server.refresh(['general', 'content'])
} catch (error) {
const errmsg = `Error changing mod version: ${error}`
console.error(errmsg)
addNotification({
text: errmsg,
type: 'error',
})
return
}
if (mod) mod.changing = false
}
function showVersionModal(mod: ContentItem) {
if (invalidModal.value || !mod?.project_id || !mod?.filename) {
const errmsg = invalidModal.value
? 'Data required for changing mod version was not found.'
: `${!mod?.project_id ? 'No mod project ID found' : 'No mod filename found'} for ${friendlyModName(mod!)}`
console.error(errmsg)
addNotification({
text: errmsg,
type: 'error',
})
return
}
currentEditMod.value = mod
versionEditModal.value.show(mod)
}
const handleDroppedFiles = (files: File[]) => {
files.forEach((file) => {
uploadDropdownRef.value?.uploadFile(file)
})
}
const initiateFileUpload = () => {
const input = document.createElement('input')
input.type = 'file'
input.accept = acceptFileFromProjectType(type.value.toLowerCase())
input.multiple = true
input.onchange = () => {
if (input.files) {
Array.from(input.files).forEach((file) => {
uploadDropdownRef.value?.uploadFile(file)
})
}
}
input.click()
}
const showAll = () => {
searchInput.value = ''
modSearchInput.value = ''
filterMethod.value = 'all'
}
const filterMethodLabel = computed(() => {
switch (filterMethod.value) {
case 'disabled':
return 'Only disabled'
case 'enabled':
return 'Only enabled'
default:
return `All ${type.value.toLocaleLowerCase()}s`
}
})
const totalHeight = computed(() => {
const itemsHeight = filteredMods.value.length * ITEM_HEIGHT
return itemsHeight
})
const getVisibleRange = () => {
if (!listContainer.value) return { start: 0, end: 0 }
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
const scrollTop = Math.max(0, windowScrollY.value - containerTop)
const start = Math.floor(scrollTop / ITEM_HEIGHT)
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(filteredMods.value.length, start + visibleCount + BUFFER_SIZE * 2),
}
}
const visibleTop = computed(() => {
const range = getVisibleRange()
return range.start * ITEM_HEIGHT
})
const visibleItems = computed(() => {
const range = getVisibleRange()
const items = filteredMods.value
return {
items: items.slice(Math.max(0, range.start), Math.min(items.length, range.end)),
}
})
const handleScroll = () => {
windowScrollY.value = window.scrollY
}
const handleResize = () => {
windowHeight.value = window.innerHeight
}
onMounted(() => {
windowHeight.value = window.innerHeight
window.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
handleScroll()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
watch(
() => props.server.content?.data,
(newMods) => {
if (newMods) {
localMods.value = [...newMods]
}
},
{ immediate: true },
)
const debounce = <T extends (...args: any[]) => void>(
func: T,
wait: number,
): ((...args: Parameters<T>) => void) => {
let timeout: ReturnType<typeof setTimeout>
return function (...args: Parameters<T>): void {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}
const pyroContentSentinel = ref<HTMLElement | null>(null)
const debouncedSearch = debounce(() => {
modSearchInput.value = searchInput.value
if (pyroContentSentinel.value) {
const sentinelRect = pyroContentSentinel.value.getBoundingClientRect()
if (sentinelRect.top < 0 || sentinelRect.bottom > window.innerHeight) {
pyroContentSentinel.value.scrollIntoView({
// behavior: "smooth",
block: 'start',
})
}
}
}, 300)
function friendlyModName(mod: ContentItem) {
if (mod.name) return mod.name
// remove .disabled if at the end of the filename
let cleanName = mod.filename.endsWith('.disabled') ? mod.filename.slice(0, -9) : mod.filename
// remove everything after the last dot
const lastDotIndex = cleanName.lastIndexOf('.')
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex)
return cleanName
}
function getStableModKey(mod: ContentItem): string {
if (mod.project_id) {
return `project-${mod.project_id}`
}
// external file
const baseFilename = mod.filename.endsWith('.disabled') ? mod.filename.slice(0, -9) : mod.filename
return `file-${baseFilename}`
}
async function toggleMod(mod: ContentItem) {
mod.changing = true
const originalFilename = mod.filename
try {
const newFilename = mod.filename.endsWith('.disabled')
? mod.filename.slice(0, -9)
: `${mod.filename}.disabled`
const folder = `${type.value.toLocaleLowerCase()}s`
const sourcePath = `/${folder}/${mod.filename}`
const destinationPath = `/${folder}/${newFilename}`
mod.disabled = newFilename.endsWith('.disabled')
mod.filename = newFilename
await client.kyros.files_v0.moveFileOrFolder(sourcePath, destinationPath)
await props.server.refresh(['general', 'content'])
} catch (error) {
mod.filename = originalFilename
mod.disabled = originalFilename.endsWith('.disabled')
console.error('Error toggling mod:', error)
addNotification({
text: `Something went wrong toggling ${friendlyModName(mod)}`,
type: 'error',
})
}
mod.changing = false
}
async function removeMod(mod: ContentItem) {
mod.changing = true
try {
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`)
await props.server.refresh(['general', 'content'])
} catch (error) {
console.error('Error removing mod:', error)
addNotification({
text: `couldn't remove ${mod.name || mod.filename}`,
type: 'error',
})
}
mod.changing = false
}
const hasMods = computed(() => {
return localMods.value?.length > 0
})
const hasFilteredMods = computed(() => {
return filteredMods.value?.length > 0
})
const filteredMods = computed(() => {
const mods = modSearchInput.value.trim()
? localMods.value.filter(
(mod) =>
mod.name?.toLowerCase().includes(modSearchInput.value.toLowerCase()) ||
mod.filename.toLowerCase().includes(modSearchInput.value.toLowerCase()),
)
: localMods.value
const statusFilteredMods = (() => {
switch (filterMethod.value) {
case 'disabled':
return mods.filter((mod) => mod.disabled)
case 'enabled':
return mods.filter((mod) => !mod.disabled)
default:
return mods
}
})()
return statusFilteredMods.sort((a, b) => {
return friendlyModName(a).localeCompare(friendlyModName(b))
})
})
</script>
<style scoped>
.sentinel {
position: absolute;
top: -1rem;
left: 0;
right: 0;
height: 1px;
visibility: hidden;
}
</style>

View File

@@ -1,78 +1,58 @@
<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<div
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
Your server is still accessible during this time.
</Admonition>
<Admonition
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
data-pyro-servers-inspecting-error
class="flex justify-between rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
type="critical"
:header="`${serverData?.name} shut down unexpectedly.`"
dismissible
@dismiss="clearError"
>
<div class="flex w-full justify-between gap-2">
<div v-if="inspectingError.analysis.problems.length" class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">
{{ serverData?.name }} shut down unexpectedly. We've automatically analyzed the logs
and found the following problems:
</div>
<li
v-for="problem in inspectingError.analysis.problems"
:key="problem.message"
class="list-none"
>
<h4 class="m-0 text-sm font-normal sm:text-lg sm:font-semibold">
{{ problem.message }}
</h4>
<ul class="m-0 ml-6">
<li v-for="solution in problem.solutions" :key="solution.message">
<span class="m-0 text-sm font-normal">{{ solution.message }}</span>
</li>
</ul>
</li>
<template v-if="inspectingError.analysis.problems.length">
<p class="m-0 text-sm opacity-80">
We automatically analyzed the logs and found the following:
</p>
<div class="mt-2 flex flex-col gap-2">
<div
v-for="problem in inspectingError.analysis.problems"
:key="problem.message"
class="bg-raised-bg/30 rounded-xl px-3 py-2"
>
<p class="m-0 text-sm font-semibold">{{ problem.message }}</p>
<ul v-if="problem.solutions.length" class="m-0 ml-4 mt-1.5 flex flex-col gap-1">
<li
v-for="solution in problem.solutions"
:key="solution.message"
class="text-sm opacity-80"
>
{{ solution.message }}
</li>
</ul>
</div>
</div>
<div v-else-if="props.serverPowerState === 'crashed'" class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
<div class="font-normal">
<template v-if="props.powerStateDetails?.oom_killed">
The server stopped because it ran out of memory. There may be a memory leak caused
by a mod or plugin, or you may need to upgrade your Modrinth Server.
</template>
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
We could not automatically determine the specific cause of the crash, but your
server exited with code
{{ props.powerStateDetails.exit_code }}.
{{
props.powerStateDetails.exit_code === 1
? 'There may be a mod or plugin causing the issue, or an issue with your server configuration.'
: ''
}}
</template>
<template v-else> We could not determine the specific cause of the crash. </template>
<div class="mt-2">You can try restarting the server.</div>
</div>
</div>
</div>
<div v-else class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
<div class="font-normal">
We could not find any specific problems, but you can try restarting the server.
</div>
</div>
</div>
<ButtonStyled color="red" @click="clearError">
<button>
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
<template v-else-if="props.serverPowerState === 'crashed'">
<template v-if="props.powerStateDetails?.oom_killed">
The server stopped because it ran out of memory. There may be a memory leak caused by a
mod or plugin, or you may need to upgrade your Modrinth Server.
</template>
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
Your server exited with code {{ props.powerStateDetails.exit_code }}.
<template v-if="props.powerStateDetails.exit_code === 1">
There may be a mod or plugin causing the issue, or an issue with your server
configuration.
</template>
</template>
<template v-else> We could not determine the specific cause of the crash. </template>
<p class="m-0 mt-2">You can try restarting the server.</p>
</template>
<template v-else>
We could not find any specific problems, but you can try restarting the server.
</template>
</Admonition>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<ServerStats
@@ -181,14 +161,18 @@
</template>
<script setup lang="ts">
import { IssuesIcon, TerminalSquareIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient } from '@modrinth/ui'
import { TerminalSquareIcon } from '@modrinth/assets'
import {
Admonition,
injectModrinthClient,
injectModrinthServerContext,
useVIntl,
} from '@modrinth/ui'
import type { ServerState, Stats } from '@modrinth/utils'
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
import PanelTerminal from '~/components/ui/servers/PanelTerminal.vue'
import ServerStats from '~/components/ui/servers/ServerStats.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
type ServerProps = {
isConnected: boolean
@@ -200,13 +184,22 @@ type ServerProps = {
exit_code?: number
}
isServerRunning: boolean
server: ModrinthServer
}
const props = defineProps<ServerProps>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const serverId = props.server.serverId
const { server: serverData, serverId, busyReasons } = injectModrinthServerContext()
const backupBusyReason = computed(() => {
const reason = busyReasons.value.find(
(r) =>
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring',
)
return reason ? formatMessage(reason.reason) : null
})
interface ErrorData {
id: string
@@ -581,7 +574,6 @@ const commandInput = ref('')
const suggestions = ref<string[]>([])
const selectedSuggestionIndex = ref(0)
const serverData = computed(() => props.server.general)
// const serverIP = computed(() => serverData.value?.net.ip ?? "");
// const serverPort = computed(() => serverData.value?.net.port ?? 0);
// const serverDomain = computed(() => serverData.value?.net.domain ?? "");

View File

@@ -1,10 +1,10 @@
<template>
<ServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
:backup-in-progress="backupInProgress"
/>
<div class="flex flex-col gap-4">
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
Some options may not be editable while the operation is in progress.
</Admonition>
<ServerSidebar :route="route" :nav-links="navLinks" />
</div>
</template>
<script setup lang="ts">
import {
@@ -18,26 +18,32 @@ import {
VersionIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Admonition, injectModrinthServerContext, useVIntl } from '@modrinth/ui'
import { isAdmin as isUserAdmin, type User } from '@modrinth/utils'
import ServerSidebar from '~/components/ui/servers/ServerSidebar.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
const route = useRoute()
const serverId = route.params.id as string
const auth = await useAuth()
const props = defineProps<{
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const { formatMessage } = useVIntl()
const { server, busyReasons } = injectModrinthServerContext()
useHead({
title: `Options - ${props.server.general?.name ?? 'Server'} - Modrinth`,
const backupBusyReason = computed(() => {
const reason = busyReasons.value.find(
(r) =>
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring',
)
return reason ? formatMessage(reason.reason) : null
})
const ownerId = computed(() => props.server.general?.owner_id ?? 'Ghost')
useHead({
title: `Options - ${server.value?.name ?? 'Server'} - Modrinth`,
})
const ownerId = computed(() => server.value?.owner_id ?? 'Ghost')
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value)
const isAdmin = computed(() => isUserAdmin(auth.value?.user))
@@ -46,7 +52,12 @@ const navLinks = computed(() => [
{ icon: WrenchIcon, label: 'Platform', href: `/hosting/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: 'Startup', href: `/hosting/manage/${serverId}/options/startup` },
{ icon: VersionIcon, label: 'Network', href: `/hosting/manage/${serverId}/options/network` },
{ icon: ListIcon, label: 'Properties', href: `/hosting/manage/${serverId}/options/properties` },
{
icon: ListIcon,
label: 'Properties',
href: `/hosting/manage/${serverId}/options/properties`,
shown: server.value?.status !== 'installing',
},
{
icon: UserIcon,
label: 'Preferences',

View File

@@ -105,8 +105,8 @@
<div v-else />
<SaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
:save="saveGeneral"
:reset="resetGeneral"
/>
@@ -117,29 +117,28 @@
import { EditIcon, TransferIcon } from '@modrinth/assets'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
ServerIcon,
StyledInput,
} from '@modrinth/ui'
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
import { useQueryClient } from '@tanstack/vue-query'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { server, serverId, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
const data = server
const serverName = ref(data.value?.name)
const serverSubdomain = ref(data.value?.net?.domain ?? '')
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5)
const isValidCharsSubdomain = computed(() => /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value))
const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidCharsSubdomain.value)
const icon = computed(() => data.value?.image)
const icon = useState<string | undefined>(`server-icon-${serverId}`)
const isUpdating = ref(false)
const hasUnsavedChanges = computed(
@@ -161,14 +160,14 @@ const saveGeneral = async () => {
try {
isUpdating.value = true
if (serverName.value !== data.value?.name) {
await data.value?.updateName(serverName.value ?? '')
await client.archon.servers_v0.updateName(serverId, serverName.value ?? '')
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const available = await props.server.network?.checkSubdomainAvailability(
const result = await client.archon.servers_v0.checkSubdomainAvailability(
serverSubdomain.value,
)
const available = result.available
if (!available) {
addNotification({
@@ -179,7 +178,7 @@ const saveGeneral = async () => {
return
}
await props.server.network?.changeSubdomain(serverSubdomain.value)
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
} catch (error) {
console.error('Error checking subdomain availability:', error)
addNotification({
@@ -191,7 +190,7 @@ const saveGeneral = async () => {
}
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
addNotification({
type: 'success',
title: 'Server settings updated',
@@ -247,7 +246,7 @@ const uploadFile = async (e: Event) => {
})
try {
if (data.value?.image) {
if (icon.value) {
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
}
@@ -264,8 +263,7 @@ const uploadFile = async (e: Event) => {
canvas.height = 512
ctx?.drawImage(img, 0, 0, 512, 512)
const dataURL = canvas.toDataURL('image/png')
useState(`server-icon-${props.server.serverId}`).value = dataURL
if (data.value) data.value.image = dataURL
useState(`server-icon-${serverId}`).value = dataURL
resolve()
URL.revokeObjectURL(img.src)
}
@@ -288,15 +286,14 @@ const uploadFile = async (e: Event) => {
}
const resetIcon = async () => {
if (data.value?.image) {
if (icon.value) {
try {
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
useState(`server-icon-${props.server.serverId}`).value = undefined
if (data.value) data.value.image = undefined
useState(`server-icon-${serverId}`).value = undefined
await props.server.refresh(['general'])
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
addNotification({
type: 'success',

View File

@@ -119,16 +119,15 @@
<script setup lang="ts">
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, injectNotificationManager } from '@modrinth/ui'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import {
ButtonStyled,
CopyCode,
injectModrinthServerContext,
injectNotificationManager,
} from '@modrinth/ui'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
const { server: data, serverId } = injectModrinthServerContext()
const showPassword = ref(false)
const sftpUrl = computed(() => `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`)
@@ -146,7 +145,7 @@ const copyToClipboard = (name: string, textToCopy?: string) => {
}
const properties = [
{ name: 'Server ID', value: props.server.serverId ?? 'Unknown' },
{ name: 'Server ID', value: serverId ?? 'Unknown' },
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
{ name: 'Kind', value: data.value?.upstream?.kind ?? data.value?.loader ?? 'Unknown' },
{ name: 'Project ID', value: data.value?.upstream?.project_id ?? 'Unknown' },

View File

@@ -1,22 +1,593 @@
<template>
<ServerInstallation
:server="props.server"
:backup-in-progress="props.backupInProgress"
@reinstall="emit('reinstall')"
/>
<div class="flex flex-col gap-6 rounded-2xl bg-surface-3 p-6">
<InstallationSettingsLayout ref="installationSettingsLayout">
<template #extra>
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">{{
formatMessage(messages.resetServerTitle)
}}</span>
<span class="text-primary">
{{ formatMessage(messages.resetServerDescription) }}
</span>
<div>
<ButtonStyled color="red">
<button class="!shadow-none" :disabled="isInstalling" @click="setupModal?.show()">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.resetServerButton) }}
</button>
</ButtonStyled>
</div>
</div>
</template>
<template #extra-modals>
<ServerSetupModal
ref="setupModal"
@reinstall="onReinstall"
@browse-modpacks="onBrowseModpacks"
/>
</template>
</InstallationSettingsLayout>
</div>
</template>
<script setup lang="ts">
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import type { Archon, LauncherMeta } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
formatLoaderLabel,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
injectTags,
InstallationSettingsLayout,
provideInstallationSettings,
ServerSetupModal,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
const props = defineProps<{
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const debug = useDebugLogger('LoaderPage')
const client = injectModrinthClient()
const { server, serverId, worldId, isSyncingContent, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const tags = injectTags()
const { formatMessage } = useVIntl()
const messages = defineMessages({
resetServerTitle: {
id: 'hosting.loader.reset-server',
defaultMessage: 'Reset server',
},
resetServerDescription: {
id: 'hosting.loader.reset-server-description',
defaultMessage:
'Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored.',
},
loaderVersionLabel: {
id: 'hosting.loader.loader-version',
defaultMessage: '{loader, select, null {Loader} other {{loader}}} version',
},
failedToLoadVersions: {
id: 'hosting.loader.failed-to-load-versions',
defaultMessage: 'Failed to load versions',
},
failedToChangeVersion: {
id: 'hosting.loader.failed-to-change-version',
defaultMessage: 'Failed to change modpack version',
},
failedToSaveSettings: {
id: 'hosting.loader.failed-to-save-settings',
defaultMessage: 'Failed to save installation settings',
},
repairStartedTitle: {
id: 'hosting.loader.repair-started-title',
defaultMessage: 'Repair completed',
},
repairStartedText: {
id: 'hosting.loader.repair-started-text',
defaultMessage: 'Your server installation has been repaired.',
},
failedToRepair: {
id: 'hosting.loader.failed-to-repair',
defaultMessage: 'Failed to repair server',
},
failedToReinstall: {
id: 'hosting.loader.failed-to-reinstall',
defaultMessage: 'Failed to reinstall modpack',
},
failedToUnlink: {
id: 'hosting.loader.failed-to-unlink',
defaultMessage: 'Failed to unlink modpack',
},
})
const emit = defineEmits<{
reinstall: [any?]
'reinstall-failed': []
}>()
const isInstalling = computed(() => {
const val =
server.value?.status === 'installing' || isSyncingContent.value || busyReasons.value.length > 0
debug(
'isInstalling:',
val,
'server.status:',
server.value?.status,
'isSyncingContent:',
isSyncingContent.value,
)
return val
})
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
async function invalidateServerState() {
debug('invalidateServerState: starting')
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
])
debug('invalidateServerState: complete')
}
const addonsQuery = useQuery({
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
queryFn: () =>
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
enabled: computed(() => worldId.value !== null),
})
const modpack = computed(() => addonsQuery.data.value?.modpack ?? null)
const modpackVersionsQuery = useQuery({
queryKey: computed(() => ['labrinth', 'versions', 'v2', modpack.value?.spec.project_id]),
queryFn: () =>
client.labrinth.versions_v2.getProjectVersions(modpack.value!.spec.project_id, {
include_changelog: false,
}),
enabled: computed(() => !!modpack.value?.spec.project_id),
})
const editingPlatform = ref(server.value?.loader?.toLowerCase() ?? 'vanilla')
const editingGameVersion = ref(server.value?.mc_version ?? '')
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
function toApiLoaderName(loader: string): string {
return loader === 'neoforge' ? 'neo' : loader
}
const apiLoaderName = computed(() =>
modLoaders.includes(editingPlatform.value) ? toApiLoaderName(editingPlatform.value) : null,
)
const manifestQuery = useQuery({
queryKey: computed(() => ['loader-manifest', apiLoaderName.value] as const),
queryFn: () => client.launchermeta.manifest_v0.getManifest(apiLoaderName.value!),
enabled: computed(() => !!apiLoaderName.value),
staleTime: 5 * 60 * 1000,
})
const paperBuildsQuery = useQuery({
queryKey: computed(() => ['paper-builds', editingGameVersion.value] as const),
queryFn: () => client.paper.versions_v3.getBuilds(editingGameVersion.value),
enabled: computed(() => editingPlatform.value === 'paper' && !!editingGameVersion.value),
staleTime: 5 * 60 * 1000,
})
const purpurBuildsQuery = useQuery({
queryKey: computed(() => ['purpur-builds', editingGameVersion.value] as const),
queryFn: () => client.purpur.versions_v2.getBuilds(editingGameVersion.value),
enabled: computed(() => editingPlatform.value === 'purpur' && !!editingGameVersion.value),
staleTime: 5 * 60 * 1000,
})
type LoaderVersionEntry = LauncherMeta.Manifest.v0.LoaderVersion
function getLoaderVersionsForGameVersion(
loader: string,
gameVersion: string,
): LoaderVersionEntry[] {
if (loader === 'paper') {
return (paperBuildsQuery.data.value?.builds ?? [])
.toSorted((a, b) => b - a)
.map((b) => ({ id: String(b), stable: true }))
}
if (loader === 'purpur') {
return (purpurBuildsQuery.data.value?.builds.all ?? [])
.toSorted((a, b) => parseInt(b) - parseInt(a))
.map((b) => ({ id: b, stable: true }))
}
const manifest = manifestQuery.data.value?.gameVersions
if (!manifest) return []
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
if (placeholder) return placeholder.loaders
const entry = manifest.find((x) => x.id === gameVersion)
return entry?.loaders ?? []
}
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
if (loader === 'neoforge') return 'neo_forge'
return loader as Archon.Content.v1.Modloader
}
provideInstallationSettings({
loading: computed(() => !server.value || addonsQuery.isLoading.value),
installationInfo: computed(() => {
const addons = addonsQuery.data.value
const rawLoader = addons?.modloader ?? server.value?.loader ?? null
const loader = rawLoader ? formatLoaderLabel(rawLoader) : null
const gameVersion = addons?.game_version ?? server.value?.mc_version ?? null
const loaderVersion = addons?.modloader_version ?? server.value?.loader_version ?? null
debug('installationInfo computed:', {
'addons?.modloader': addons?.modloader,
'server.loader': server.value?.loader,
rawLoader,
loader,
'addons?.game_version': addons?.game_version,
'server.mc_version': server.value?.mc_version,
gameVersion,
'addons?.modloader_version': addons?.modloader_version,
'server.loader_version': server.value?.loader_version,
loaderVersion,
'addonsQuery.isLoading': addonsQuery.isLoading.value,
'addonsQuery.isFetching': addonsQuery.isFetching.value,
})
const rows = [
{ label: formatMessage(commonMessages.platformLabel), value: loader },
{ label: formatMessage(commonMessages.gameVersionLabel), value: gameVersion },
]
if (loader !== 'Vanilla') {
rows.push({
label: formatMessage(messages.loaderVersionLabel, { loader: loader ?? 'null' }),
value: loaderVersion,
})
}
return rows
}),
isLinked: computed(() => {
const val = !!modpack.value
debug('isLinked:', val, 'modpack:', modpack.value?.spec?.project_id)
return val
}),
isBusy: isInstalling,
modpack: computed(() => {
if (!modpack.value) return null
return {
iconUrl: modpack.value.icon_url,
title: modpack.value.title ?? modpack.value.spec.project_id,
link: `/project/${modpack.value.spec.project_id}`,
versionNumber: modpack.value.version_number,
owner: modpack.value.owner
? {
id: modpack.value.owner.id,
name: modpack.value.owner.name,
iconUrl: modpack.value.owner.icon_url,
type: modpack.value.owner.type as 'user' | 'organization',
}
: undefined,
}
}),
currentPlatform: computed(() => server.value?.loader?.toLowerCase() ?? 'vanilla'),
currentGameVersion: computed(() => server.value?.mc_version ?? ''),
currentLoaderVersion: computed(() => server.value?.loader_version ?? ''),
availablePlatforms: ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur'],
editingPlatformRef: editingPlatform,
editingGameVersionRef: editingGameVersion,
resolveGameVersions(loader, showSnapshots) {
const versions = showSnapshots
? tags.gameVersions.value
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
if (loader && loader !== 'vanilla' && !['paper', 'purpur'].includes(loader)) {
const manifest = manifestQuery.data.value?.gameVersions
if (manifest) {
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
if (!hasPlaceholder) {
const supportedVersions = new Set(
manifest.filter((x) => x.loaders.length > 0).map((x) => x.id),
)
return versions
.filter((v) => supportedVersions.has(v.version))
.map((v) => ({ value: v.version, label: v.version }))
}
}
}
return versions.map((v) => ({ value: v.version, label: v.version }))
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) return []
return getLoaderVersionsForGameVersion(loader, gameVersion)
},
resolveHasSnapshots(loader) {
if (loader === 'vanilla' || ['paper', 'purpur'].includes(loader)) {
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
}
const manifest = manifestQuery.data.value?.gameVersions
if (!manifest) return false
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
if (hasPlaceholder) {
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
}
const supportedVersions = new Set(manifest.filter((x) => x.loaders.length > 0).map((x) => x.id))
const supported = tags.gameVersions.value.filter((v) => supportedVersions.has(v.version))
return supported.some((v) => v.version_type !== 'release')
},
async save(platform, gameVersion, loaderVersionId) {
debug('save: called with', { platform, gameVersion, loaderVersionId })
const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla'
const platformChanged = platform !== currentPlatform
debug('save: emitting reinstall before API call')
emit(
'reinstall',
platformChanged
? { loader: platform, lVersion: loaderVersionId, mVersion: gameVersion }
: { mVersion: gameVersion },
)
try {
if (platformChanged) {
const request: Archon.Content.v1.InstallWorldContent = {
content_variant: 'bare',
loader: toApiLoader(platform),
version: loaderVersionId ?? '',
game_version: gameVersion || undefined,
soft_override: true,
}
debug('save: platform changed, calling installContent', request)
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
} else {
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
}
debug('save: succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('save: failed, emitting reinstall-failed', err)
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToSaveSettings),
})
throw err
}
},
async repair() {
debug('repair: called')
try {
await client.archon.content_v1.repair(serverId, worldId.value!)
debug('repair: API succeeded, invalidating')
await invalidateServerState()
addNotification({
type: 'success',
title: formatMessage(messages.repairStartedTitle),
text: formatMessage(messages.repairStartedText),
})
} catch (err) {
debug('repair: failed', err)
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToRepair),
})
}
},
async reinstallModpack() {
if (!modpack.value) return
debug(
'reinstallModpack: called, project:',
modpack.value.spec.project_id,
'version:',
modpack.value.spec.version_id,
)
debug('reinstallModpack: emitting reinstall before API call')
emit('reinstall')
try {
await client.archon.content_v1.installContent(serverId, worldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: modpack.value.spec.project_id,
version_id: modpack.value.spec.version_id,
},
soft_override: false,
})
debug('reinstallModpack: installContent succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('reinstallModpack: failed, emitting reinstall-failed', err)
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToReinstall),
})
}
},
async unlinkModpack() {
debug('unlinkModpack: called')
const previousData = addonsQuery.data.value
if (previousData) {
debug('unlinkModpack: optimistically removing modpack from cache')
queryClient.setQueryData(['content', 'list', 'v1', serverId], {
...previousData,
modpack: null,
})
}
try {
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
debug('unlinkModpack: API succeeded')
} catch (err) {
debug('unlinkModpack: failed, reverting cache', err)
if (previousData) {
queryClient.setQueryData(['content', 'list', 'v1', serverId], previousData)
}
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToUnlink),
})
} finally {
debug('unlinkModpack: invalidating queries')
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['servers', 'detail', serverId],
}),
queryClient.invalidateQueries({
queryKey: ['content', 'list', 'v1', serverId],
}),
])
debug('unlinkModpack: invalidation complete')
}
},
getCachedModpackVersions: () => modpackVersionsQuery.data.value ?? null,
async fetchModpackVersions() {
debug('fetchModpackVersions: called, project:', modpack.value?.spec.project_id)
try {
const versions = await client.labrinth.versions_v2.getProjectVersions(
modpack.value!.spec.project_id,
{
include_changelog: false,
},
)
debug('fetchModpackVersions: got', versions.length, 'versions')
return versions
} catch (err) {
debug('fetchModpackVersions: failed', err)
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
})
throw err
}
},
async getVersionChangelog(versionId) {
debug('getVersionChangelog: called, versionId:', versionId)
try {
return await client.labrinth.versions_v2.getVersion(versionId)
} catch {
debug('getVersionChangelog: failed for', versionId)
return null
}
},
async onModpackVersionConfirm(version) {
if (!modpack.value) return
debug('onModpackVersionConfirm: called, version:', version.id)
debug('onModpackVersionConfirm: emitting reinstall before API call')
emit('reinstall')
try {
await client.archon.content_v1.installContent(serverId, worldId.value!, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: modpack.value.spec.project_id,
version_id: version.id,
},
soft_override: true,
})
debug('onModpackVersionConfirm: installContent succeeded, invalidating')
invalidateServerState()
} catch (err) {
debug('onModpackVersionConfirm: failed, emitting reinstall-failed', err)
emit('reinstall-failed')
addNotification({
type: 'error',
text: err instanceof Error ? err.message : formatMessage(messages.failedToChangeVersion),
})
}
},
updaterModalProps: computed(() => ({
isApp: false,
currentVersionId: modpack.value?.spec.version_id ?? '',
projectIconUrl: modpack.value?.icon_url ?? undefined,
projectName:
modpack.value?.title ??
modpack.value?.spec.project_id ??
formatMessage(commonMessages.modpackLabel),
currentGameVersion: addonsQuery.data.value?.game_version ?? server.value?.mc_version ?? '',
currentLoader: addonsQuery.data.value?.modloader ?? server.value?.loader ?? '',
})),
isServer: true,
isApp: false,
lockPlatform: true,
hideLoaderVersion: true,
async previewSave(_platform, gameVersion, _loaderVersionId, signal) {
const result = await client.archon.content_v1.getUpdateGameVersionPreview(
serverId,
worldId.value!,
gameVersion,
signal,
)
if (result.addon_changes.length === 0 && !result.has_unknown_content) return null
return {
diffs: result.addon_changes.map((diff) => ({
type: diff.type,
projectName: diff.project?.title ?? undefined,
fileName: diff.file_name ?? undefined,
currentVersionName: diff.current_version?.version_number ?? undefined,
newVersionName: diff.new_version?.version_number ?? undefined,
})),
newGameVersion: result.new_game_version,
newLoaderVersion: result.new_loader_version,
hasUnknownContent: result.has_unknown_content,
}
},
})
watch(
() => server.value?.status,
(newStatus, oldStatus) => {
debug('status watcher:', oldStatus, '->', newStatus, {
'server.loader': server.value?.loader,
'server.mc_version': server.value?.mc_version,
'server.loader_version': server.value?.loader_version,
})
if (oldStatus === 'installing' && newStatus === 'available') {
debug('status installing->available, resetting editing refs')
editingPlatform.value = server.value?.loader?.toLowerCase() ?? 'vanilla'
editingGameVersion.value = server.value?.mc_version ?? ''
}
},
)
function onReinstall(event?: any) {
installationSettingsLayout.value?.cancelEditing()
emit('reinstall', event)
}
function onBrowseModpacks() {
debug('onBrowseModpacks: navigating to modpack discovery')
navigateTo({
path: '/discover/modpacks',
query: { sid: serverId, from: 'reset-server', wid: worldId.value },
})
}
</script>

View File

@@ -58,7 +58,7 @@
<div class="relative h-full w-full overflow-y-auto">
<div
v-if="server.moduleErrors.network"
v-if="allocationsError"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -72,10 +72,10 @@
<p class="text-lg text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.network.error)
allocationsError?.message ?? 'Unknown error'
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<ButtonStyled size="large" color="brand" @click="() => refetchAllocations()">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
@@ -249,7 +249,7 @@
</div>
<SaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
:server-id="serverId"
:is-updating="isUpdating"
:save="saveNetwork"
:reset="resetNetwork"
@@ -273,22 +273,24 @@ import {
ButtonStyled,
ConfirmModal,
CopyCode,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
StyledInput,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const { server, serverId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const isUpdating = ref(false)
const data = computed(() => props.server.general)
const data = server
const serverIP = ref(data?.value?.net?.ip ?? '')
const serverSubdomain = ref(data?.value?.net?.domain ?? '')
@@ -296,8 +298,15 @@ const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
const userDomain = ref('')
const exampleDomain = 'play.example.com'
const network = computed(() => props.server.network)
const allocations = computed(() => network.value?.allocations)
const {
data: allocationsData,
error: allocationsError,
refetch: refetchAllocations,
} = useQuery({
queryKey: ['servers', 'allocations', serverId] as const,
queryFn: () => client.archon.servers_v0.getAllocations(serverId),
})
const allocations = allocationsData
const newAllocationModal = ref<typeof NewModal>()
const editAllocationModal = ref<typeof NewModal>()
@@ -316,8 +325,8 @@ const addNewAllocation = async () => {
if (!newAllocationName.value) return
try {
await props.server.network?.reserveAllocation(newAllocationName.value)
await props.server.refresh(['network'])
await client.archon.servers_v0.reserveAllocation(serverId, newAllocationName.value)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
newAllocationModal.value?.hide()
newAllocationName.value = ''
@@ -360,8 +369,8 @@ const showConfirmDeleteModal = (port: number) => {
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return
await props.server.network?.deleteAllocation(allocationToDelete.value)
await props.server.refresh(['network'])
await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
@@ -376,8 +385,12 @@ const editAllocation = async () => {
if (!newAllocationName.value) return
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value)
await props.server.refresh(['network'])
await client.archon.servers_v0.updateAllocation(
serverId,
newAllocationPort.value,
newAllocationName.value,
)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
editAllocationModal.value?.hide()
newAllocationName.value = ''
@@ -397,7 +410,8 @@ const saveNetwork = async () => {
try {
isUpdating.value = true
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value)
const result = await client.archon.servers_v0.checkSubdomainAvailability(serverSubdomain.value)
const available = result.available
if (!available) {
addNotification({
type: 'error',
@@ -407,13 +421,18 @@ const saveNetwork = async () => {
return
}
if (serverSubdomain.value !== data?.value?.net?.domain) {
await props.server.network?.changeSubdomain(serverSubdomain.value)
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value)
await client.archon.servers_v0.updateAllocation(
serverId,
serverPrimaryPort.value,
newAllocationName.value,
)
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
title: 'Server settings updated',

View File

@@ -32,7 +32,7 @@
</div>
<SaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:server-id="serverId"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
@@ -45,16 +45,11 @@ import { injectNotificationManager, Toggle } from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const route = useNativeRoute()
const serverId = route.params.id as string
const props = defineProps<{
server: ModrinthServer
}>()
const preferences = {
ramAsNumber: {
displayName: 'RAM as bytes',

View File

@@ -1,9 +1,11 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<div v-if="propsData" class="flex h-full w-full flex-col justify-between gap-4 overflow-y-auto">
<Admonition
v-if="missingKnownProperties.length > 0"
type="warning"
body="Some expected properties are missing from your server.properties - this usually means the server hasn't completed its first startup yet."
/>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Server properties</h2>
@@ -22,7 +24,7 @@
</div>
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
<div class="w-full text-sm">
<label for="search-server-properties" class="sr-only">Search server properties</label>
<label for="search-server-properties" class="sr-only"> Search server properties </label>
<StyledInput
id="search-server-properties"
v-model="searchInput"
@@ -35,303 +37,258 @@
/>
</div>
<div
v-for="(property, index) in filteredProperties"
:key="index"
v-for="(_value, key) in filteredProperties"
:key="key"
class="flex flex-row flex-wrap items-center justify-between py-2"
>
<div class="flex items-center">
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
<EyeIcon v-tooltip="overrides[index].info" />
</span>
</div>
<span :id="`property-label-${key}`">{{ formatPropertyName(key) }}</span>
<div
v-if="overrides[index] && overrides[index].type === 'dropdown'"
v-if="getPropertyDef(key).type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<Combobox
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
:options="(overrides[index].options || []).map((v) => ({ value: v, label: v }))"
:aria-labelledby="`property-label-${index}`"
:display-value="String(liveProperties[index] ?? 'Select...')"
:id="`server-property-${key}`"
v-model="liveProperties[key]"
:name="formatPropertyName(key)"
:options="
(getPropertyDef(key) as DropdownPropertyDef).options.map((v) => ({
value: v,
label: formatPropertyName(v),
}))
"
:aria-labelledby="`property-label-${key}`"
:display-value="formatPropertyName(String(liveProperties[key] ?? 'Select...'))"
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<div v-else-if="getPropertyDef(key).type === 'toggle'" class="flex justify-end">
<Toggle
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:aria-labelledby="`property-label-${index}`"
:id="`server-property-${key}`"
:model-value="liveProperties[key] === 'true'"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = $event ? 'true' : 'false'"
/>
</div>
<div
v-else-if="typeof property === 'number' && index !== 'level-seed' && index !== 'seed'"
class="mt-2 w-full sm:w-[320px]"
>
<div v-else-if="getPropertyDef(key).type === 'number'" class="mt-2 w-full sm:w-[320px]">
<StyledInput
:id="`server-property-${index}`"
:model-value="liveProperties[index]"
:id="`server-property-${key}`"
:model-value="liveProperties[key]"
type="number"
wrapper-class="w-full"
:aria-labelledby="`property-label-${index}`"
@update:model-value="liveProperties[index] = $event"
/>
</div>
<div
v-else-if="index === 'level-seed' || index === 'seed'"
class="mt-2 w-full sm:w-[320px]"
>
<StyledInput
:id="`server-property-${index}`"
:model-value="liveProperties[index]"
wrapper-class="w-full"
:aria-labelledby="`property-label-${index}`"
@update:model-value="liveProperties[index] = $event"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<StyledInput
:id="`server-property-${index}`"
v-model="liveProperties[index]"
multiline
resize="vertical"
input-class="p-2"
:aria-labelledby="`property-label-${index}`"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = String($event)"
/>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<StyledInput
:id="`server-property-${index}`"
:model-value="liveProperties[index]"
:id="`server-property-${key}`"
v-model="liveProperties[key]"
wrapper-class="w-full"
:aria-labelledby="`property-label-${index}`"
@update:model-value="liveProperties[index] = $event"
:aria-labelledby="`property-label-${key}`"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card flex h-full w-full items-center justify-center">
<p class="text-contrast">
The server properties file has not been generated yet. Start up your server to generate it.
</p>
<div v-else class="flex h-full w-full items-center justify-center">
<SpinnerIcon class="animate-spin" />
</div>
<SaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
restart
:save="saveProperties"
:save="() => saveProperties()"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, SearchIcon } from '@modrinth/assets'
import type { Archon } from '@modrinth/api-client'
import { SearchIcon, SpinnerIcon } from '@modrinth/assets'
import {
Admonition,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
StyledInput,
Toggle,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import Fuse from 'fuse.js'
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const props = defineProps<{
server: ModrinthServer
}>()
const tags = useGeneratedState()
const isUpdating = ref(false)
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const searchInput = ref('')
const data = computed(() => props.server.general)
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
await modulesLoaded
try {
const blob = await client.kyros.files_v0.downloadFile('/server.properties')
const rawProps = await blob.text()
if (!rawProps) return null
type DropdownPropertyDef = { type: 'dropdown'; options: string[] }
type PropertyDef = { type: 'toggle' } | { type: 'number' } | { type: 'text' } | DropdownPropertyDef
const properties: Record<string, any> = {}
const lines = rawProps.split('\n')
const KNOWN_PROPERTIES: Record<string, PropertyDef> = {
allow_cheats: { type: 'toggle' },
allow_flight: { type: 'toggle' },
difficulty: { type: 'dropdown', options: ['peaceful', 'easy', 'normal', 'hard'] },
enforce_whitelist: { type: 'toggle' },
force_gamemode: { type: 'toggle' },
gamemode: { type: 'dropdown', options: ['survival', 'creative', 'adventure', 'spectator'] },
generate_structures: { type: 'toggle' },
generator_settings: { type: 'text' },
hardcore: { type: 'toggle' },
level_seed: { type: 'text' },
level_type: { type: 'text' },
max_players: { type: 'number' },
max_tick_time: { type: 'number' },
motd: { type: 'text' },
pause_when_empty_seconds: { type: 'number' },
player_idle_timeout: { type: 'number' },
require_resource_pack: { type: 'toggle' },
resource_pack: { type: 'text' },
resource_pack_id: { type: 'text' },
resource_pack_sha1: { type: 'text' },
simulation_distance: { type: 'number' },
spawn_protection: { type: 'number' },
sync_chunk_writes: { type: 'toggle' },
view_distance: { type: 'number' },
white_list: { type: 'toggle' },
}
for (const line of lines) {
if (line.startsWith('#') || !line.includes('=')) continue
const [key, ...valueParts] = line.split('=')
const rawValue = valueParts.join('=')
let value: string | boolean | number = rawValue
function getPropertyDef(key: string): PropertyDef {
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
}
if (rawValue.toLowerCase() === 'true' || rawValue.toLowerCase() === 'false') {
value = rawValue.toLowerCase() === 'true'
} else {
const intLike = /^[-+]?\d+$/.test(rawValue)
if (intLike) {
const n = Number(rawValue)
if (Number.isSafeInteger(n)) {
value = n
}
}
}
const queryKey = computed(() => ['servers', 'properties', 'v1', serverId, worldId.value])
properties[key.trim()] = value
}
return properties
} catch {
return null
}
const { data: propsData } = useQuery({
queryKey,
queryFn: () => client.archon.properties_v1.getProperties(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
const liveProperties = ref<Record<string, any>>({})
const originalProperties = ref<Record<string, any>>({})
function flattenProperties(data: Archon.Content.v1.PropertiesFields): Record<string, string> {
const result: Record<string, string> = {}
if (data.known) {
for (const [key, value] of Object.entries(data.known)) {
if (value != null) result[key] = value
}
}
if (data.custom) {
for (const [key, value] of Object.entries(data.custom)) {
if (value != null) result[key] = value
}
}
return result
}
const liveProperties = ref<Record<string, string>>({})
const originalProperties = ref<Record<string, string>>({})
function syncFormFromData() {
if (!propsData.value) return
const flat = flattenProperties(propsData.value)
liveProperties.value = { ...flat }
originalProperties.value = { ...flat }
}
watch(
propsData,
(newPropsData) => {
if (newPropsData) {
console.log(newPropsData)
liveProperties.value = JSON.parse(JSON.stringify(newPropsData))
originalProperties.value = JSON.parse(JSON.stringify(newPropsData))
(newData, oldData) => {
if (newData && !oldData) {
syncFormFromData()
}
},
{ immediate: true },
)
const hasUnsavedChanges = computed(() => {
return Object.keys(liveProperties.value).some(
(key) =>
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
)
watch(powerState, () => {
queryClient.invalidateQueries({ queryKey: queryKey.value })
})
const getDifficultyOptions = () => {
const pre113Versions = tags.value.gameVersions
.filter((v) => {
const versionNumbers = v.version.split('.').map(Number)
return versionNumbers[0] === 1 && versionNumbers[1] < 13
})
.map((v) => v.version)
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
return ['0', '1', '2', '3']
} else {
return ['peaceful', 'easy', 'normal', 'hard']
}
}
const missingKnownProperties = computed(() =>
Object.keys(KNOWN_PROPERTIES).filter((key) => !(key in liveProperties.value)),
)
const overrides: { [key: string]: { type: string; options?: string[]; info?: string } } = {
difficulty: {
type: 'dropdown',
options: getDifficultyOptions(),
},
gamemode: {
type: 'dropdown',
options: ['survival', 'creative', 'adventure', 'spectator'],
},
}
const hasUnsavedChanges = computed(() =>
Object.keys(liveProperties.value).some(
(key) => liveProperties.value[key] !== originalProperties.value[key],
),
)
const fuse = computed(() => {
if (!liveProperties.value) return null
function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
const known: Record<string, string> = {}
const custom: Record<string, string> = {}
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}))
return new Fuse(propertiesToFuse, {
keys: ['key', 'value'],
threshold: 0.2,
})
})
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) {
return liveProperties.value
}
const results = fuse.value?.search(searchInput.value) ?? []
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
})
const constructServerProperties = (): string => {
const properties = liveProperties.value
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`
for (const [key, value] of Object.entries(properties)) {
if (typeof value === 'object') {
fileContent += `${key}=${JSON.stringify(value)}\n`
} else if (typeof value === 'boolean') {
fileContent += `${key}=${value ? 'true' : 'false'}\n`
for (const key of Object.keys(liveProperties.value)) {
if (liveProperties.value[key] === originalProperties.value[key]) continue
if (key in KNOWN_PROPERTIES) {
known[key] = liveProperties.value[key]
} else {
fileContent += `${key}=${value}\n`
custom[key] = liveProperties.value[key]
}
}
return fileContent
const patch: Archon.Content.v1.PatchPropertiesFields = {}
if (Object.keys(known).length > 0) {
patch.known = known as Archon.Content.v1.KnownPropertiesFields
}
if (Object.keys(custom).length > 0) {
patch.custom = custom
}
return patch
}
const saveProperties = async () => {
try {
isUpdating.value = true
await client.kyros.files_v0.updateFile('/server.properties', constructServerProperties())
await new Promise((resolve) => setTimeout(resolve, 500))
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
await props.server.refresh()
const { mutate: saveProperties, isPending: isUpdating } = useMutation({
mutationFn: () =>
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
syncFormFromData()
addNotification({
type: 'success',
title: 'Server properties updated',
text: 'Your server properties were successfully changed.',
})
} catch (error) {
console.error('Error updating server properties:', error)
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: 'An error occurred while attempting to update your server properties.',
text: error instanceof Error ? error.message : 'An error occurred.',
})
} finally {
isUpdating.value = false
}
},
})
function resetProperties() {
syncFormFromData()
}
const resetProperties = async () => {
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value))
await new Promise((resolve) => setTimeout(resolve, 200))
}
const fuse = computed(() => {
const entries = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}))
return new Fuse(entries, { keys: ['key', 'value'], threshold: 0.2 })
})
const formatPropertyName = (propertyName: string): string => {
return propertyName
.split(/[-.]/)
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) return liveProperties.value
const results = fuse.value.search(searchInput.value)
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
})
function formatPropertyName(name: string): string {
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const isComplexProperty = (property: any): boolean => {
return (
typeof property === 'object' ||
(typeof property === 'string' &&
(property.includes(',') ||
property.includes('{') ||
property.includes('}') ||
property.includes('[') ||
property.includes(']') ||
property.length > 30))
)
}
</script>

View File

@@ -1,32 +1,6 @@
<template>
<div class="relative h-full w-full">
<div
v-if="server.moduleErrors.startup"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load startup settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.startup.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col gap-4">
<div class="flex h-full w-full flex-col gap-4">
<div
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
>
@@ -42,7 +16,7 @@
</label>
<ButtonStyled>
<button
:disabled="invocation === originalInvocation"
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
@@ -51,13 +25,22 @@
</button>
</ButtonStyled>
</div>
<StyledInput
id="startup-command-field"
v-model="invocation"
multiline
resize="vertical"
input-class="min-h-[270px] font-[family-name:var(--mono-font)]"
/>
<div class="relative">
<StyledInput
id="startup-command-field"
v-model="startupCommand"
multiline
resize="vertical"
input-class="min-h-[270px] font-[family-name:var(--mono-font)]"
:disabled="isStartupLoading"
/>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
</div>
</div>
</div>
<div class="card flex flex-col gap-8">
@@ -70,168 +53,203 @@
different Java version to work properly.
</span>
</div>
<div class="flex items-center gap-2">
<Toggle id="show-all-versions" v-model="showAllVersions" class="flex-none" />
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
<div class="relative max-w-xs">
<Combobox
:id="'java-version-field'"
v-model="javaVersion"
name="java-version"
:options="displayedJavaVersions"
:display-value="javaVersionLabel ?? 'Java Version'"
:disabled="isStartupLoading"
>
<template #dropdown-footer>
<button
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
@mousedown.prevent
@click="showAllVersions = !showAllVersions"
>
<EyeOffIcon v-if="showAllVersions" class="size-4" />
<EyeIcon v-else class="size-4" />
{{ showAllVersions ? 'Hide extra versions' : 'Show all versions' }}
</button>
</template>
</Combobox>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
</div>
</div>
<Combobox
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
:options="displayedJavaVersions.map((v) => ({ value: v, label: v }))"
:display-value="jdkVersion ?? 'Java Version'"
/>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Runtime</span>
<span> The Java runtime your server will use. </span>
</div>
<Combobox
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
:options="['Corretto', 'Temurin', 'GraalVM'].map((v) => ({ value: v, label: v }))"
:display-value="jdkBuild ?? 'Runtime'"
/>
<div class="relative max-w-xs">
<Combobox
:id="'runtime-field'"
v-model="jreVendor"
name="runtime"
:options="JRE_VENDORS"
:display-value="jreVendorLabel ?? 'Runtime'"
:disabled="isStartupLoading"
/>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
</div>
</div>
</div>
</div>
</div>
</div>
<SaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:save="saveStartup"
:server-id="serverId"
:is-updating="isPending"
:save="() => saveStartup()"
:reset="resetStartup"
/>
</div>
</template>
<script setup lang="ts">
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
import type { Archon } from '@modrinth/api-client'
import { EyeIcon, EyeOffIcon, SpinnerIcon, UpdatedIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
StyledInput,
Toggle,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const { server, serverId, worldId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
await props.server.startup.fetch()
const startupQueryKey = computed(() => ['servers', 'startup', 'v1', serverId, worldId.value])
const data = computed(() => props.server.general)
const showAllVersions = ref(false)
const { data: startupData, isLoading: isStartupLoading } = useQuery({
queryKey: startupQueryKey,
queryFn: () => client.archon.options_v1.getStartup(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
const jdkVersionMap = [
{ value: 'lts8', label: 'Java 8' },
{ value: 'lts11', label: 'Java 11' },
{ value: 'lts17', label: 'Java 17' },
{ value: 'lts21', label: 'Java 21' },
const JAVA_VERSIONS = [
{ value: 8, label: 'Java 8' },
{ value: 11, label: 'Java 11' },
{ value: 17, label: 'Java 17' },
{ value: 21, label: 'Java 21' },
]
const jdkBuildMap = [
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
{ value: 'corretto', label: 'Corretto' },
{ value: 'temurin', label: 'Temurin' },
{ value: 'graal', label: 'GraalVM' },
]
const invocation = ref(props.server.startup.invocation)
const jdkVersion = ref(
jdkVersionMap.find((v) => v.value === props.server.startup.jdk_version)?.label,
// Saved state derived directly from query
const savedStartupCommand = computed(() => startupData.value?.startup_command ?? '')
const savedJavaVersion = computed(() => startupData.value?.java_version ?? undefined)
const savedJreVendor = computed(() => startupData.value?.jre_vendor ?? undefined)
const defaultStartupCommand = computed(
() => startupData.value?.original_invocation ?? savedStartupCommand.value,
)
const jdkBuild = ref(jdkBuildMap.find((v) => v.value === props.server.startup.jdk_build)?.label)
const originalInvocation = ref(invocation.value)
const originalJdkVersion = ref(jdkVersion.value)
const originalJdkBuild = ref(jdkBuild.value)
// Local form state
const startupCommand = ref('')
const javaVersion = ref<number>()
const jreVendor = ref<Archon.Content.v1.JreVendor>()
// Display labels for comboboxes
const javaVersionLabel = computed(
() => JAVA_VERSIONS.find((v) => v.value === javaVersion.value)?.label,
)
const jreVendorLabel = computed(() => JRE_VENDORS.find((v) => v.value === jreVendor.value)?.label)
function syncFormFromData() {
startupCommand.value = savedStartupCommand.value
javaVersion.value = savedJavaVersion.value
jreVendor.value = savedJreVendor.value
}
watch(
startupData,
(newData, oldData) => {
if (newData && !oldData) {
syncFormFromData()
}
},
{ immediate: true },
)
const hasUnsavedChanges = computed(
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.value,
startupCommand.value !== savedStartupCommand.value ||
javaVersion.value !== savedJavaVersion.value ||
jreVendor.value !== savedJreVendor.value,
)
const isUpdating = ref(false)
const compatibleJavaVersions = computed(() => {
const mcVersion = data.value?.mc_version ?? ''
if (!mcVersion) return jdkVersionMap.map((v) => v.label)
const [major, minor] = mcVersion.split('.').map(Number)
if (major >= 1) {
if (minor >= 20) return ['Java 21']
if (minor >= 18) return ['Java 17', 'Java 21']
if (minor >= 17) return ['Java 16', 'Java 17', 'Java 21']
if (minor >= 12) return ['Java 8', 'Java 11', 'Java 17', 'Java 21']
if (minor >= 6) return ['Java 8', 'Java 11']
}
return ['Java 8']
})
// Java version filtering
const showAllVersions = ref(false)
const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value
if (showAllVersions.value) return JAVA_VERSIONS
const mcVersion = server.value?.mc_version ?? ''
if (!mcVersion) return JAVA_VERSIONS
const [, minor] = mcVersion.split('.').map(Number)
if (minor >= 20) return JAVA_VERSIONS.filter((v) => v.value === 21)
if (minor >= 17) return JAVA_VERSIONS.filter((v) => [17, 21].includes(v.value))
if (minor >= 12) return JAVA_VERSIONS
if (minor >= 6) return JAVA_VERSIONS.filter((v) => [8, 11].includes(v.value))
return JAVA_VERSIONS.filter((v) => v.value === 8)
})
async function saveStartup() {
try {
isUpdating.value = true
const invocationValue = invocation.value ?? ''
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any)
await new Promise((resolve) => setTimeout(resolve, 10))
await props.server.refresh(['startup'])
if (props.server.startup) {
invocation.value = props.server.startup.invocation
jdkVersion.value =
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || ''
jdkBuild.value =
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || ''
originalInvocation.value = invocation.value
originalJdkVersion.value = jdkVersion.value
originalJdkBuild.value = jdkBuild.value
}
// Save mutation
const { mutate: saveStartup, isPending } = useMutation({
mutationFn: () =>
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
startup_command: startupCommand.value || null,
java_version: javaVersion.value ?? null,
jre_vendor: jreVendor.value ?? null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: startupQueryKey.value })
syncFormFromData()
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
},
onError: (error) => {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server arguments',
text: 'Please try again later.',
})
} finally {
isUpdating.value = false
}
}
},
})
function resetStartup() {
invocation.value = originalInvocation.value
jdkVersion.value = originalJdkVersion.value
jdkBuild.value = originalJdkBuild.value
syncFormFromData()
}
function resetToDefault() {
invocation.value = originalInvocation.value ?? ''
startupCommand.value = defaultStartupCommand.value
}
</script>