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:
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 ?? "");
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user