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:
@@ -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