Modrinth Hosting rebrand (#4846)

* Modrinth Hosting rebranding

* fix capitalization issue

* fix issues
This commit is contained in:
Prospector
2025-12-03 14:15:36 -08:00
committed by GitHub
parent 79c2633011
commit 16a6f7b352
61 changed files with 212 additions and 286 deletions

View File

@@ -0,0 +1,11 @@
<template>
<div class="flex h-full w-full flex-col">
<div
class="flex items-center justify-between gap-2 border-0 border-b border-solid border-bg-raised p-3"
>
<h2 class="m-0 text-2xl font-bold text-contrast">Admin</h2>
</div>
</div>
</template>
<script setup lang="ts"></script>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,334 @@
<template>
<div
v-if="server.moduleErrors.backups"
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 backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.backups.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<BackupCreateModal ref="createBackupModal" :server="server" />
<BackupRenameModal ref="renameBackupModal" :server="server" />
<BackupRestoreModal ref="restoreBackupModal" :server="server" />
<BackupDeleteModal ref="deleteBackupModal" :server="server" @delete="deleteBackup" />
<BackupSettingsModal ref="backupSettingsModal" :server="server" />
<div class="mb-6 flex flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<h1 class="m-0 text-2xl font-extrabold text-contrast">Backups</h1>
<TagItem
v-tooltip="`${data.backup_quota - data.used_backup_quota} backup slots remaining`"
class="cursor-help"
:style="{
'--_color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange)'
: undefined,
'--_bg-color':
data.backup_quota <= data.used_backup_quota
? 'var(--color-red-bg)'
: data.backup_quota - data.used_backup_quota <= 3
? 'var(--color-orange-bg)'
: undefined,
}"
>
{{ data.used_backup_quota }} / {{ data.backup_quota }}
</TagItem>
</div>
<p class="m-0">
You can have up to {{ data.backup_quota }} backups at once, stored securely off-site.
</p>
</div>
<div
class="grid w-full grid-cols-[repeat(auto-fit,_minmax(180px,1fr))] gap-2 sm:flex sm:w-fit sm:flex-row"
>
<ButtonStyled type="standard">
<button
v-tooltip="
'Auto backups are currently unavailable; we apologize for the inconvenience.'
"
:disabled="true || server.general?.status === 'installing'"
@click="showbackupSettingsModal"
>
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="backupCreationDisabled"
class="w-full sm:w-fit"
:disabled="!!backupCreationDisabled"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
<div class="flex w-full flex-col gap-2">
<div
v-if="backups.length === 0"
class="mt-6 flex items-center justify-center gap-2 text-center text-secondary"
>
<template v-if="data.used_backup_quota">
<SpinnerIcon class="animate-spin" />
Loading backups...
</template>
<template v-else> You don't have any backups yet. </template>
</div>
<BackupItem
v-for="backup in backups"
:key="`backup-${backup.id}`"
:backup="backup"
:kyros-url="props.server.general?.node.instance"
:jwt="props.server.general?.node.token"
@download="() => triggerDownloadAnimation()"
@rename="() => renameBackupModal?.show(backup)"
@restore="() => restoreBackupModal?.show(backup)"
@lock="
() => {
if (backup.locked) {
unlockBackup(backup.id)
} else {
lockBackup(backup.id)
}
}
"
@delete="
(skipConfirmation?: boolean) =>
!skipConfirmation ? deleteBackup(backup) : deleteBackupModal?.show(backup)
"
@retry="() => retryBackup(backup.id)"
/>
</div>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { DownloadIcon, IssuesIcon, PlusIcon, SettingsIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, TagItem } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import BackupCreateModal from '~/components/ui/servers/BackupCreateModal.vue'
import BackupDeleteModal from '~/components/ui/servers/BackupDeleteModal.vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
import BackupRenameModal from '~/components/ui/servers/BackupRenameModal.vue'
import BackupRestoreModal from '~/components/ui/servers/BackupRestoreModal.vue'
import BackupSettingsModal from '~/components/ui/servers/BackupSettingsModal.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
isServerRunning: boolean
}>()
const route = useNativeRoute()
const serverId = route.params.id
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
backupWhileRunning: false,
})
defineEmits(['onDownload'])
const data = computed(() => props.server.general)
const backups = computed(() => {
if (!props.server.backups?.data) return []
return [...props.server.backups.data].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
})
})
useHead({
title: `Backups - ${data.value?.name ?? 'Server'} - Modrinth`,
})
const overTheTopDownloadAnimation = ref()
const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>()
const backupCreationDisabled = computed(() => {
if (props.isServerRunning && !userPreferences.value.backupWhileRunning) {
return 'Cannot create backup while server is running'
}
if (
data.value?.used_backup_quota !== undefined &&
data.value?.backup_quota !== undefined &&
data.value?.used_backup_quota >= data.value?.backup_quota
) {
return `All ${data.value.backup_quota} of your backup slots are in use`
}
if (backups.value.some((backup) => backup.task?.create?.state === 'ongoing')) {
return 'A backup is already in progress'
}
if (props.server.general?.status === 'installing') {
return 'Cannot create backup while server is installing'
}
return undefined
})
const showCreateModel = () => {
createBackupModal.value?.show()
}
const showbackupSettingsModal = () => {
backupSettingsModal.value?.show()
}
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
}
const lockBackup = async (backupId: string) => {
try {
await props.server.backups?.lock(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to toggle lock:', error)
}
}
const unlockBackup = async (backupId: string) => {
try {
await props.server.backups?.unlock(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to toggle lock:', error)
}
}
const retryBackup = async (backupId: string) => {
try {
await props.server.backups?.retry(backupId)
await props.server.refresh(['backups'])
} catch (error) {
console.error('Failed to retry backup:', error)
}
}
async function deleteBackup(backup?: Backup) {
if (!backup) {
addNotification({
type: 'error',
title: 'Error deleting backup',
text: 'Backup is null',
})
return
}
try {
await props.server.backups?.delete(backup.id)
await props.server.refresh()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({
type: 'error',
title: 'Error deleting backup',
text: message,
})
}
}
</script>
<style scoped>
.over-the-top-download-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
&.animation-hidden {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
<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'
const route = useNativeRoute()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
useHead({
title: `Content - ${data.value?.name ?? 'Server'} - Modrinth`,
})
</script>

View File

@@ -0,0 +1,696 @@
<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="relative flex-1 text-sm">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="!h-9 !min-h-0 w-full border-[1px] border-solid border-button-border pl-9"
type="search"
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="`/${type.toLocaleLowerCase()}s?sid=${props.server.serverId}`"
>
<PlusIcon />
Add {{ type.toLocaleLowerCase() }}
</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
<FilesUploadDropdown
v-if="props.server.fs"
ref="uploadDropdownRef"
class="rounded-xl bg-bg-raised"
:margin-bottom="16"
:file-type="type"
:current-path="`/${type.toLocaleLowerCase()}s`"
:fs="props.server.fs"
: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="mod.filename">
<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>
<input
:id="`toggle-${mod.filename}`"
:checked="!mod.disabled"
:disabled="mod.changing"
class="switch stylized-toggle"
type="checkbox"
@change="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="`/${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="`/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, injectNotificationManager } 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 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
}
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 props.server.fs?.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;
}
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,739 @@
<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<div
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"
>
<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>
</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>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<ServerStats
:data="isConnected && !isWsAuthIncorrect ? stats : undefined"
:loading="!isConnected || isWsAuthIncorrect"
/>
<div
class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
:class="{ 'border-0': !isConnected || isWsAuthIncorrect }"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
<PanelServerStatus v-if="isConnected && !isWsAuthIncorrect" :state="serverPowerState" />
</div>
</div>
<PanelTerminal :full-screen="fullScreen" :loading="!isConnected || isWsAuthIncorrect">
<div class="relative w-full px-4 pt-4">
<ul
v-if="suggestions.length && isConnected && !isWsAuthIncorrect"
id="command-suggestions"
ref="suggestionsList"
class="mt-1 max-h-60 w-full list-none overflow-auto rounded-md border border-divider bg-bg-raised p-0 shadow-lg"
role="listbox"
>
<li
v-for="(suggestion, index) in suggestions"
:id="'suggestion-' + index"
:key="index"
role="option"
:aria-selected="index === selectedSuggestionIndex"
:class="[
'cursor-pointer px-4 py-2',
index === selectedSuggestionIndex ? 'bg-bg-raised' : 'bg-bg',
]"
@click="selectSuggestion(index)"
@mousemove="() => (selectedSuggestionIndex = index)"
>
{{ suggestion }}
</li>
</ul>
<div class="relative flex items-center">
<span
v-if="bestSuggestion && isConnected && !isWsAuthIncorrect"
class="pointer-events-none absolute left-[26px] transform select-none text-gray-400"
>
<span class="ml-[23.5px] whitespace-pre">{{
' '.repeat(commandInput.length - 1)
}}</span>
<span> {{ bestSuggestion }} </span>
<button
class="text pointer-events-auto ml-2 cursor-pointer rounded-md border-none bg-white text-sm focus:outline-none dark:bg-highlight"
aria-label="Accept suggestion"
style="transform: translateY(-1px)"
@click="acceptSuggestion"
>
TAB
</button>
</span>
<div
class="pointer-events-none absolute left-0 top-0 flex h-full w-full items-center"
>
<TerminalSquareIcon class="ml-3 h-5 w-5" />
</div>
<input
v-if="isServerRunning && isConnected && !isWsAuthIncorrect"
v-model="commandInput"
type="text"
placeholder="Send a command"
class="w-full rounded-md !pl-10 pt-4 focus:border-none [&&]:border-[1px] [&&]:border-solid [&&]:border-bg-raised [&&]:bg-bg"
aria-autocomplete="list"
aria-controls="command-suggestions"
spellcheck="false"
:aria-activedescendant="'suggestion-' + selectedSuggestionIndex"
@keydown.tab.prevent="acceptSuggestion"
@keydown.down.prevent="selectNextSuggestion"
@keydown.up.prevent="selectPrevSuggestion"
@keydown.enter.prevent="sendCommand"
/>
<input
v-else
disabled
type="text"
placeholder="Send a command"
class="w-full rounded-md !pl-10 focus:border-none [&&]:border-[1px] [&&]:border-solid [&&]:border-bg-raised [&&]:bg-bg"
/>
</div>
</div>
</PanelTerminal>
</div>
</div>
<div
v-if="isWsAuthIncorrect"
class="absolute inset-0 flex flex-col items-center justify-center bg-bg"
>
<h2>Could not connect to the server.</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the
page. (WebSocket Authentication Failed)
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { IssuesIcon, TerminalSquareIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled } 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 = {
socket: WebSocket | null
isConnected: boolean
isWsAuthIncorrect: boolean
stats: Stats
serverPowerState: ServerState
powerStateDetails?: {
oom_killed?: boolean
exit_code?: number
}
isServerRunning: boolean
server: ModrinthServer
}
const props = defineProps<ServerProps>()
interface ErrorData {
id: string
name: string
type: string
version: string
title: string
analysis: {
problems: Array<{
message: string
counter: number
entry: {
level: number
time: string | null
prefix: string
lines: Array<{ number: number; content: string }>
}
solutions: Array<{ message: string }>
}>
information: Array<{
message: string
counter: number
label: string
value: string
entry: {
level: number
time: string | null
prefix: string
lines: Array<{ number: number; content: string }>
}
}>
}
}
const inspectingError = ref<ErrorData | null>(null)
const inspectError = async () => {
try {
const log = await props.server.fs?.downloadFile('logs/latest.log')
if (!log) return
// @ts-ignore
const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
content: log,
}),
})
// @ts-ignore
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
inspectingError.value = response as ErrorData
} else {
inspectingError.value = null
}
} catch (error) {
console.error('Failed to analyze logs:', error)
inspectingError.value = null
}
}
const clearError = () => {
inspectingError.value = null
}
watch(
() => props.serverPowerState,
(newVal) => {
if (newVal === 'crashed' && !props.powerStateDetails?.oom_killed) {
inspectError()
} else {
clearError()
}
},
)
if (props.serverPowerState === 'crashed' && !props.powerStateDetails?.oom_killed) {
inspectError()
}
const socket = ref(props.socket)
watch(props, (newAttrs) => {
socket.value = newAttrs.socket
})
const DYNAMIC_ARG = Symbol('DYNAMIC_ARG')
const commandTree: any = {
advancement: {
grant: {
[DYNAMIC_ARG]: {
everything: null,
only: {
[DYNAMIC_ARG]: null,
},
from: {
[DYNAMIC_ARG]: null,
},
through: {
[DYNAMIC_ARG]: null,
},
until: {
[DYNAMIC_ARG]: null,
},
},
},
revoke: {
[DYNAMIC_ARG]: {
everything: null,
only: {
[DYNAMIC_ARG]: null,
},
from: {
[DYNAMIC_ARG]: null,
},
through: {
[DYNAMIC_ARG]: null,
},
until: {
[DYNAMIC_ARG]: null,
},
},
},
},
ban: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
duration: {
[DYNAMIC_ARG]: null,
},
},
},
'ban-ip': null,
banlist: {
ips: null,
players: null,
all: null,
},
bossbar: {
add: null,
get: null,
list: null,
remove: null,
set: null,
},
clear: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
reason: null,
},
},
},
clone: null,
data: {
get: null,
merge: null,
modify: null,
remove: null,
},
datapack: {
disable: null,
enable: null,
list: null,
reload: null,
},
debug: {
start: null,
stop: null,
function: null,
memory: null,
},
defaultgamemode: {
survival: null,
creative: null,
adventure: null,
spectator: null,
},
deop: null,
difficulty: {
peaceful: null,
easy: null,
normal: null,
hard: null,
},
effect: {
give: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
true: null,
false: null,
},
},
},
},
},
clear: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
enchant: null,
execute: null,
experience: {
add: null,
set: null,
query: null,
},
fill: null,
forceload: {
add: null,
remove: null,
query: null,
},
function: null,
gamemode: {
survival: {
[DYNAMIC_ARG]: null,
},
creative: {
[DYNAMIC_ARG]: null,
},
adventure: {
[DYNAMIC_ARG]: null,
},
spectator: {
[DYNAMIC_ARG]: null,
},
},
gamerule: null,
give: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
help: null,
kick: null,
kill: {
[DYNAMIC_ARG]: null,
},
list: null,
locate: {
biome: null,
poi: null,
structure: null,
},
loot: {
give: null,
insert: null,
replace: null,
spawn: null,
},
me: null,
msg: null,
op: null,
pardon: null,
'pardon-ip': null,
particle: null,
playsound: null,
recipe: {
give: null,
take: null,
},
reload: null,
say: null,
schedule: {
function: null,
clear: null,
},
scoreboard: {
objectives: {
add: null,
remove: null,
setdisplay: null,
list: null,
modify: null,
},
players: {
add: null,
remove: null,
set: null,
get: null,
list: null,
enable: null,
operation: null,
reset: null,
},
},
seed: null,
setblock: null,
setidletimeout: null,
setworldspawn: null,
spawnpoint: null,
spectate: null,
spreadplayers: null,
stop: null,
stopsound: null,
summon: null,
tag: {
add: null,
list: null,
remove: null,
},
team: {
add: null,
empty: null,
join: null,
leave: null,
list: null,
modify: null,
remove: null,
},
teleport: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
},
tp: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
trigger: null,
weather: {
clear: {
[DYNAMIC_ARG]: null,
},
rain: {
[DYNAMIC_ARG]: null,
},
thunder: {
[DYNAMIC_ARG]: null,
},
},
whitelist: {
add: null,
list: null,
off: null,
on: null,
reload: null,
remove: null,
},
worldborder: {
add: null,
center: null,
damage: {
amount: null,
buffer: null,
},
get: null,
set: null,
warning: {
distance: null,
time: null,
},
},
xp: null,
}
const fullScreen = ref(false)
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 ?? "");
const suggestionsList = ref<HTMLUListElement | null>(null)
useHead({
title: `Overview - ${serverData.value?.name ?? 'Server'} - Modrinth`,
})
const bestSuggestion = computed(() => {
if (!suggestions.value.length) return ''
const inputTokens = commandInput.value.trim().split(/\s+/)
let lastInputToken = inputTokens[inputTokens.length - 1] || ''
if (inputTokens.length - 1 === 0 && lastInputToken.startsWith('/')) {
lastInputToken = lastInputToken.slice(1)
}
const selectedSuggestion = suggestions.value[selectedSuggestionIndex.value]
const suggestionTokens = selectedSuggestion.split(/\s+/)
const lastSuggestionToken = suggestionTokens[suggestionTokens.length - 1] || ''
if (lastSuggestionToken.toLowerCase().startsWith(lastInputToken.toLowerCase())) {
return lastSuggestionToken.slice(lastInputToken.length)
}
return ''
})
const getSuggestions = (input: string): string[] => {
const trimmedInput = input.trim()
const inputWithoutSlash = trimmedInput.startsWith('/') ? trimmedInput.slice(1) : trimmedInput
const tokens = inputWithoutSlash.split(/\s+/)
let currentLevel: any = commandTree
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i].toLowerCase()
if (currentLevel?.[token]) {
currentLevel = currentLevel[token] as any
} else if (currentLevel?.[DYNAMIC_ARG]) {
currentLevel = currentLevel[DYNAMIC_ARG] as any
} else {
if (i === tokens.length - 1) {
break
}
currentLevel = null
break
}
}
if (currentLevel) {
const lastToken = tokens[tokens.length - 1]?.toLowerCase() || ''
const possibleKeys = Object.keys(currentLevel)
if (currentLevel[DYNAMIC_ARG]) {
possibleKeys.push('<arg>')
}
return possibleKeys
.filter((key) => key === '<arg>' || key.toLowerCase().startsWith(lastToken))
.filter((k) => k !== lastToken.trim())
.map((key) => {
if (key === '<arg>') {
return [...tokens.slice(0, -1), '<arg>'].join(' ')
}
const newTokens = [...tokens.slice(0, -1), key]
return newTokens.join(' ')
})
}
return []
}
const sendCommand = () => {
const cmd = commandInput.value.trim()
if (!socket.value || !cmd) return
try {
sendConsoleCommand(cmd)
commandInput.value = ''
suggestions.value = []
selectedSuggestionIndex.value = 0
} catch (error) {
console.error('Error sending command:', error)
}
}
const sendConsoleCommand = (cmd: string) => {
try {
socket.value?.send(JSON.stringify({ event: 'command', cmd }))
} catch (error) {
console.error('Error sending command:', error)
}
}
watch(
() => selectedSuggestionIndex.value,
(newVal) => {
if (suggestionsList.value) {
const selectedSuggestion = suggestionsList.value.querySelector(`#suggestion-${newVal}`)
if (selectedSuggestion) {
selectedSuggestion.scrollIntoView({ block: 'nearest' })
}
}
},
)
watch(
() => commandInput.value,
(newVal) => {
const trimmed = newVal.trim()
if (!trimmed) {
suggestions.value = []
return
}
suggestions.value = getSuggestions(newVal)
selectedSuggestionIndex.value = 0
},
)
const selectNextSuggestion = () => {
if (suggestions.value.length === 0) return
selectedSuggestionIndex.value = (selectedSuggestionIndex.value + 1) % suggestions.value.length
}
const selectPrevSuggestion = () => {
if (suggestions.value.length === 0) return
selectedSuggestionIndex.value =
(selectedSuggestionIndex.value - 1 + suggestions.value.length) % suggestions.value.length
}
const acceptSuggestion = () => {
if (suggestions.value.filter((s) => s !== '<arg>').length === 0) return
const selected = suggestions.value[selectedSuggestionIndex.value]
const currentTokens = commandInput.value.trim().split(' ')
const suggestionTokens = selected.split(/\s+/).filter(Boolean)
// check if last current token is in command tree if so just add to the end
if (currentTokens[currentTokens.length - 1].toLowerCase() === suggestionTokens[0].toLowerCase()) {
/* empty */
} else {
const offset = currentTokens.length - 1 === 0 && currentTokens[0].trim().startsWith('/') ? 1 : 0
commandInput.value =
commandInput.value +
suggestionTokens[suggestionTokens.length - 1].substring(
currentTokens[currentTokens.length - 1].length - offset,
) +
' '
suggestions.value = getSuggestions(commandInput.value)
selectedSuggestionIndex.value = 0
}
}
const selectSuggestion = (index: number) => {
selectedSuggestionIndex.value = index
acceptSuggestion()
}
</script>

View File

@@ -0,0 +1,71 @@
<template>
<ServerSidebar
:route="route"
:nav-links="navLinks"
:server="server"
:backup-in-progress="backupInProgress"
/>
</template>
<script setup lang="ts">
import {
CardIcon,
InfoIcon,
ListIcon,
ModrinthIcon,
SettingsIcon,
TextQuoteIcon,
UserIcon,
VersionIcon,
WrenchIcon,
} from '@modrinth/assets'
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
}>()
useHead({
title: `Options - ${props.server.general?.name ?? 'Server'} - Modrinth`,
})
const ownerId = computed(() => props.server.general?.owner_id ?? 'Ghost')
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value)
const isAdmin = computed(() => isUserAdmin(auth.value?.user))
const navLinks = computed(() => [
{ icon: SettingsIcon, label: 'General', href: `/hosting/manage/${serverId}/options` },
{ 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: UserIcon,
label: 'Preferences',
href: `/hosting/manage/${serverId}/options/preferences`,
},
{
icon: CardIcon,
label: 'Billing',
href: `/settings/billing#server-${serverId}`,
external: true,
shown: isOwner.value,
},
{
icon: ModrinthIcon,
label: 'Admin Billing',
href: `/admin/billing/${ownerId.value}`,
external: true,
shown: isAdmin.value,
},
{ icon: InfoIcon, label: 'Info', href: `/hosting/manage/${serverId}/options/info` },
])
</script>

View File

@@ -0,0 +1,12 @@
<template>
<div class="universal-card">
<p>You can manage your server's billing from Settings > Billing and subscriptions.</p>
<ButtonStyled>
<NuxtLink to="/settings/billing">Go to Billing</NuxtLink>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from '@modrinth/ui'
</script>

View File

@@ -0,0 +1,332 @@
<template>
<div class="relative h-full w-full overflow-y-auto">
<div v-if="data" class="flex h-full w-full flex-col">
<div class="gap-2">
<div class="card flex flex-col gap-4">
<label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server name</span>
<span> This name is only visible on Modrinth.</span>
</label>
<div class="flex flex-col gap-2">
<input
id="server-name-field"
v-model="serverName"
class="w-full md:w-[50%]"
maxlength="48"
minlength="1"
@keyup.enter="!serverName && saveGeneral"
/>
<span v-if="!serverName" class="text-sm text-rose-400">
Server name must be at least 1 character long.
</span>
<span v-if="!isValidServerName" class="text-sm text-rose-400">
Server name can contain any character.
</span>
</div>
</div>
<!-- WIP - disable for now
<div class="card flex flex-col gap-4">
<label for="server-motd-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server MOTD</span>
<span>
The message of the day is the message that players see when they log in to the server.
</span>
</label>
<UiServersMOTDEditor :server="props.server" />
</div>
-->
<div class="card flex flex-col gap-4">
<label for="server-subdomain" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Custom URL</span>
<span> Your friends can connect to your server using this URL. </span>
</label>
<div class="flex w-full items-center gap-2 md:w-[60%]">
<input
id="server-subdomain"
v-model="serverSubdomain"
class="h-[50%] w-[63%]"
maxlength="32"
@keyup.enter="saveGeneral"
/>
.modrinth.gg
</div>
<div v-if="!isValidSubdomain" class="flex flex-col text-sm text-rose-400">
<span v-if="!isValidLengthSubdomain">
Subdomain must be at least 5 characters long.
</span>
<span v-if="!isValidCharsSubdomain">
Subdomain can only contain alphanumeric characters and dashes.
</span>
</div>
</div>
<div v-if="!data.is_medal" class="card flex flex-col gap-4">
<label for="server-icon-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server icon</span>
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
</label>
<div class="flex gap-4">
<div
v-tooltip="'Upload a custom Icon'"
class="group relative flex w-fit cursor-pointer items-center gap-2 rounded-xl bg-table-alternateRow"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
@click="triggerFileInput"
>
<input
v-if="icon"
id="server-icon-field"
type="file"
accept="image/png,image/jpeg,image/gif,image/webp"
hidden
@change="uploadFile"
/>
<div
class="absolute top-0 hidden size-24 flex-col items-center justify-center rounded-xl bg-button-bg p-2 opacity-80 group-hover:flex"
>
<EditIcon class="h-8 w-8 text-contrast" />
</div>
<ServerIcon class="size-24" :image="icon" />
</div>
<ButtonStyled>
<button
v-tooltip="'Synchronize icon with installed modpack'"
class="my-auto"
@click="resetIcon"
>
<TransferIcon /> Sync icon
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else />
<SaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
:save="saveGeneral"
:reset="resetGeneral"
/>
</div>
</template>
<script setup lang="ts">
import { EditIcon, TransferIcon } from '@modrinth/assets'
import { injectNotificationManager, ServerIcon } from '@modrinth/ui'
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.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 data = computed(() => props.server.general)
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 isUpdating = ref(false)
const hasUnsavedChanges = computed(
() =>
(serverName.value && serverName.value !== data.value?.name) ||
serverSubdomain.value !== data.value?.net?.domain,
)
const isValidServerName = computed(() => (serverName.value?.length ?? 0) > 0)
watch(serverName, (oldValue) => {
if (!isValidServerName.value) {
serverName.value = oldValue
}
})
const saveGeneral = async () => {
if (!isValidServerName.value || !isValidSubdomain.value) return
try {
isUpdating.value = true
if (serverName.value !== data.value?.name) {
await data.value?.updateName(serverName.value ?? '')
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const available = await props.server.network?.checkSubdomainAvailability(
serverSubdomain.value,
)
if (!available) {
addNotification({
type: 'error',
title: 'Subdomain not available',
text: 'The subdomain you entered is already in use.',
})
return
}
await props.server.network?.changeSubdomain(serverSubdomain.value)
} catch (error) {
console.error('Error checking subdomain availability:', error)
addNotification({
type: 'error',
title: 'Error checking availability',
text: 'Failed to verify if the subdomain is available.',
})
return
}
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server settings',
text: 'An error occurred while attempting to update your server settings.',
})
} finally {
isUpdating.value = false
}
}
const resetGeneral = () => {
serverName.value = data.value?.name || ''
serverSubdomain.value = data.value?.net?.domain ?? ''
}
const uploadFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) {
addNotification({
type: 'error',
title: 'No file selected',
text: 'Please select a file to upload.',
})
return
}
const scaledFile = await new Promise<File>((resolve, reject) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = 64
canvas.height = 64
ctx?.drawImage(img, 0, 0, 64, 64)
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], 'server-icon.png', { type: 'image/png' }))
} else {
reject(new Error('Canvas toBlob failed'))
}
}, 'image/png')
URL.revokeObjectURL(img.src)
}
img.onerror = reject
img.src = URL.createObjectURL(file)
})
try {
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
}
await props.server.fs?.uploadFile('/server-icon.png', scaledFile)
await props.server.fs?.uploadFile('/server-icon-original.png', file)
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512
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
resolve()
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(file)
})
addNotification({
type: 'success',
title: 'Server icon updated',
text: 'Your server icon was successfully changed.',
})
} catch (error) {
console.error('Error uploading icon:', error)
addNotification({
type: 'error',
title: 'Upload failed',
text: 'Failed to upload server icon.',
})
}
}
const resetIcon = async () => {
if (data.value?.image) {
try {
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
useState(`server-icon-${props.server.serverId}`).value = undefined
if (data.value) data.value.image = undefined
await props.server.refresh(['general'])
addNotification({
type: 'success',
title: 'Server icon reset',
text: 'Your server icon was successfully reset.',
})
} catch (error) {
console.error('Error resetting icon:', error)
addNotification({
type: 'error',
title: 'Reset failed',
text: 'Failed to reset server icon.',
})
}
}
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
uploadFile(e)
}
const triggerFileInput = () => {
const input = document.createElement('input')
input.type = 'file'
input.id = 'server-icon-field'
input.accept = 'image/png,image/jpeg,image/gif,image/webp'
input.onchange = uploadFile
input.click()
}
</script>

View File

@@ -0,0 +1,157 @@
<template>
<div class="h-full w-full gap-2 overflow-y-auto">
<div class="card">
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-4 sm:flex-row">
<label class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">SFTP</span>
<span> SFTP allows you to access your server's files from outside of Modrinth. </span>
</label>
<ButtonStyled>
<button
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
class="!w-full sm:!w-auto"
@click="openSftp"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</button>
</ButtonStyled>
</div>
<div
class="flex w-full flex-row justify-between gap-2 rounded-xl bg-table-alternateRow p-4"
>
<div class="flex flex-col gap-2">
<span class="cursor-pointer font-bold text-contrast">
{{ data?.sftp_host }}
</span>
<span class="text-xs text-secondary">Server Address</span>
</div>
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP server address'"
@click="copyToClipboard('Server address', data?.sftp_host)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
<div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
>
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{ data?.sftp_username }}
</span>
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP username'"
@click="copyToClipboard('Username', data?.sftp_username)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
<span class="text-xs text-secondary">Username</span>
</div>
<div
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
>
<div class="flex h-8 items-center justify-between">
<span class="font-bold text-contrast">
{{
showPassword ? data?.sftp_password : '*'.repeat(data?.sftp_password?.length ?? 0)
}}
</span>
<div class="flex flex-row items-center gap-1">
<ButtonStyled type="transparent">
<button
v-tooltip="'Copy SFTP password'"
@click="copyToClipboard('Password', data?.sftp_password)"
>
<CopyIcon class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
@click="togglePassword"
>
<EyeIcon v-if="showPassword" class="h-5 w-5 hover:cursor-pointer" />
<EyeOffIcon v-else class="h-5 w-5 hover:cursor-pointer" />
</button>
</ButtonStyled>
</div>
</div>
<span class="text-xs text-secondary">Password</span>
</div>
</div>
</div>
</div>
<div class="card">
<h2 class="text-xl font-bold">Info</h2>
<div class="rounded-xl bg-table-alternateRow p-4">
<table
class="min-w-full border-collapse overflow-hidden rounded-lg border-2 border-gray-300"
>
<tbody>
<tr v-for="property in properties" :key="property.name">
<td v-if="property.value !== 'Unknown'" class="py-3">
{{ property.name }}
</td>
<td v-if="property.value !== 'Unknown'" class="px-4">
<CopyCode :text="property.value" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<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'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const data = computed(() => props.server.general)
const showPassword = ref(false)
const openSftp = () => {
const sftpUrl = `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`
window.open(sftpUrl, '_blank')
}
const togglePassword = () => {
showPassword.value = !showPassword.value
}
const copyToClipboard = (name: string, textToCopy?: string) => {
navigator.clipboard.writeText(textToCopy || '')
addNotification({
type: 'success',
title: `${name} copied to clipboard!`,
})
}
const properties = [
{ name: 'Server ID', value: props.server.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' },
{ name: 'Version ID', value: data.value?.upstream?.version_id ?? 'Unknown' },
]
</script>

View File

@@ -0,0 +1,22 @@
<template>
<ServerInstallation
:server="props.server"
:backup-in-progress="props.backupInProgress"
@reinstall="emit('reinstall')"
/>
</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'
const props = defineProps<{
server: ModrinthServer
backupInProgress?: BackupInProgressReason
}>()
const emit = defineEmits<{
reinstall: [any?]
}>()
</script>

View File

@@ -0,0 +1,491 @@
<template>
<div class="contents">
<NewModal ref="newAllocationModal" header="New allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="addNewAllocation">
<label for="new-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
id="new-allocation-name"
ref="newAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<PlusIcon /> Create allocation
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="newAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal ref="editAllocationModal" header="Edit allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
id="edit-allocation-name"
ref="editAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<SaveIcon /> Update allocation
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="editAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<ConfirmModal
ref="confirmDeleteModal"
title="Deleting allocation"
:description="`You are deleting the allocation ${allocationToDelete}. This cannot be reserved again. Are you sure you want to proceed?`"
proceed-label="Delete"
@proceed="confirmDeleteAllocation"
/>
<div class="relative h-full w-full overflow-y-auto">
<div
v-if="server.moduleErrors.network"
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 network settings</h1>
</div>
<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)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<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 justify-between gap-4">
<div class="flex h-full flex-col">
<!-- Subdomain section -->
<div class="card flex flex-col gap-4">
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
<label for="user-domain" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Generated DNS records</span>
<span>
Set up your personal domain to connect to your server via custom DNS records.
</span>
</label>
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="userDomain == ''"
@click="exportDnsRecords"
>
<UploadIcon />
<span>Export DNS records</span>
</button>
</ButtonStyled>
</div>
<input
id="user-domain"
v-model="userDomain"
class="w-full md:w-[50%]"
maxlength="64"
minlength="1"
type="text"
:placeholder="exampleDomain"
/>
<div
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow px-4 py-2"
>
<table
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
>
<tbody class="w-full">
<tr v-for="record in dnsRecords" :key="record.content" class="w-full">
<td class="w-1/6 py-3 pr-4 md:w-1/5 md:pr-8 lg:w-1/4 lg:pr-12">
<div class="flex flex-col gap-1" @click="copyText(record.type)">
<span
class="text-md font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.type }}
</span>
<span class="text-xs text-secondary">Type</span>
</div>
</td>
<td class="w-2/6 py-3 md:w-1/3">
<div class="flex flex-col gap-1" @click="copyText(record.name)">
<span
class="text-md truncate font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.name }}
</span>
<span class="text-xs text-secondary">Name</span>
</div>
</td>
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
<div class="flex flex-col gap-1" @click="copyText(record.content)">
<span
class="text-md w-fit truncate font-bold tracking-wide text-contrast hover:cursor-pointer"
>
{{ record.content }}
</span>
<span class="text-xs text-secondary">Content</span>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
You must own your own domain to use this feature.
</span>
</div>
</div>
<!-- Allocations section -->
<div class="card flex flex-col gap-4">
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Allocations</span>
<span>
Configure additional ports for internet-facing features like map viewers or voice
chat mods.
</span>
</div>
<ButtonStyled type="standard" @click="showNewAllocationModal">
<button class="!w-full sm:!w-auto">
<PlusIcon />
<span>New allocation</span>
</button>
</ButtonStyled>
</div>
<div class="flex w-full flex-col overflow-hidden rounded-xl bg-table-alternateRow p-4">
<!-- Primary allocation -->
<div class="flex flex-col justify-between gap-2 sm:flex-row sm:items-center">
<span class="text-md font-bold tracking-wide text-contrast">
Primary allocation
</span>
<CopyCode :text="`${serverIP}:${serverPrimaryPort}`" />
</div>
</div>
<div
v-if="allocations?.[0]"
class="flex w-full flex-col gap-4 overflow-hidden rounded-xl bg-table-alternateRow p-4"
>
<div
v-for="allocation in allocations"
:key="allocation.port"
class="border-border flex flex-col justify-between gap-4 sm:flex-row sm:items-center"
>
<div class="flex flex-row items-center gap-4">
<VersionIcon class="h-7 w-7 flex-none rotate-90" />
<div class="flex w-[20rem] flex-col justify-between sm:flex-row sm:items-center">
<div class="flex flex-col gap-1">
<span class="text-md font-bold tracking-wide text-contrast">
{{ allocation.name }}
</span>
<span class="hidden text-xs text-secondary sm:block">Name</span>
</div>
<div class="flex flex-col gap-1">
<span
class="text-md w-10 tracking-wide text-secondary sm:font-bold sm:text-contrast"
>
{{ allocation.port }}
</span>
<span class="hidden text-xs text-secondary sm:block">Port</span>
</div>
</div>
</div>
<div class="flex w-full flex-row items-center gap-2 sm:w-auto">
<CopyCode :text="`${serverIP}:${allocation.port}`" />
<ButtonStyled icon-only>
<button
class="!w-full sm:!w-auto"
@click="showEditAllocationModal(allocation.port)"
>
<EditIcon />
</button>
</ButtonStyled>
<ButtonStyled icon-only color="red">
<button
class="!w-full sm:!w-auto"
@click="showConfirmDeleteModal(allocation.port)"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</div>
<SaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
:is-updating="isUpdating"
:save="saveNetwork"
:reset="resetNetwork"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
EditIcon,
InfoIcon,
IssuesIcon,
PlusIcon,
SaveIcon,
TrashIcon,
UploadIcon,
VersionIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
CopyCode,
injectNotificationManager,
NewModal,
} from '@modrinth/ui'
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 isUpdating = ref(false)
const data = computed(() => props.server.general)
const serverIP = ref(data?.value?.net?.ip ?? '')
const serverSubdomain = ref(data?.value?.net?.domain ?? '')
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 newAllocationModal = ref<typeof NewModal>()
const editAllocationModal = ref<typeof NewModal>()
const confirmDeleteModal = ref<typeof ConfirmModal>()
const newAllocationInput = ref<HTMLInputElement | null>(null)
const editAllocationInput = ref<HTMLInputElement | null>(null)
const newAllocationName = ref('')
const newAllocationPort = ref(0)
const allocationToDelete = ref<number | null>(null)
const hasUnsavedChanges = computed(() => serverSubdomain.value !== data?.value?.net?.domain)
const isValidSubdomain = computed(() => /^[a-zA-Z0-9-]{5,}$/.test(serverSubdomain.value))
const addNewAllocation = async () => {
if (!newAllocationName.value) return
try {
await props.server.network?.reserveAllocation(newAllocationName.value)
await props.server.refresh(['network'])
newAllocationModal.value?.hide()
newAllocationName.value = ''
addNotification({
type: 'success',
title: 'Allocation reserved',
text: 'Your allocation has been reserved.',
})
} catch (error) {
console.error('Failed to reserve new allocation:', error)
}
}
const showNewAllocationModal = () => {
newAllocationName.value = ''
newAllocationModal.value?.show()
nextTick(() => {
setTimeout(() => {
newAllocationInput.value?.focus()
}, 100)
})
}
const showEditAllocationModal = (port: number) => {
newAllocationPort.value = port
editAllocationModal.value?.show()
nextTick(() => {
setTimeout(() => {
editAllocationInput.value?.focus()
}, 100)
})
}
const showConfirmDeleteModal = (port: number) => {
allocationToDelete.value = port
confirmDeleteModal.value?.show()
}
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return
await props.server.network?.deleteAllocation(allocationToDelete.value)
await props.server.refresh(['network'])
addNotification({
type: 'success',
title: 'Allocation removed',
text: 'Your allocation has been removed.',
})
allocationToDelete.value = null
}
const editAllocation = async () => {
if (!newAllocationName.value) return
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value)
await props.server.refresh(['network'])
editAllocationModal.value?.hide()
newAllocationName.value = ''
addNotification({
type: 'success',
title: 'Allocation updated',
text: 'Your allocation has been updated.',
})
} catch (error) {
console.error('Failed to reserve new allocation:', error)
}
}
const saveNetwork = async () => {
if (!isValidSubdomain.value) return
try {
isUpdating.value = true
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value)
if (!available) {
addNotification({
type: 'error',
title: 'Subdomain not available',
text: 'The subdomain you entered is already in use.',
})
return
}
if (serverSubdomain.value !== data?.value?.net?.domain) {
await props.server.network?.changeSubdomain(serverSubdomain.value)
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await props.server.network?.updateAllocation(serverPrimaryPort.value, newAllocationName.value)
}
await new Promise((resolve) => setTimeout(resolve, 500))
await props.server.refresh()
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server settings',
text: 'An error occurred while attempting to update your server settings.',
})
} finally {
isUpdating.value = false
}
}
const resetNetwork = () => {
serverSubdomain.value = data?.value?.net?.domain ?? ''
}
const dnsRecords = computed(() => {
const domain = userDomain.value === '' ? exampleDomain : userDomain.value
return [
{
type: 'A',
name: `${domain}`,
content: data.value?.net?.ip ?? '',
},
{
type: 'SRV',
name: `_minecraft._tcp.${domain}`,
content: `0 10 ${data.value?.net?.port} ${domain}`,
},
]
})
const exportDnsRecords = () => {
const records = dnsRecords.value.reduce(
(acc, record) => {
const type = record.type
if (!acc[type]) {
acc[type] = []
}
acc[type].push(record)
return acc
},
{} as Record<string, any[]>,
)
const text = Object.entries(records)
.map(([type, records]) => {
return `; ${type} Records\n${records.map((record) => `${record.name}. 1 IN ${record.type} ${record.content}${record.type === 'SRV' ? '.' : ''}`).join('\n')}\n`
})
.join('\n')
const blob = new Blob([text], { type: 'text/plain' })
const a = document.createElement('a')
a.href = window.URL.createObjectURL(blob)
a.download = `${userDomain.value}.txt`
a.click()
a.remove()
}
const copyText = (text: string) => {
navigator.clipboard.writeText(text)
addNotification({
type: 'success',
title: 'Text copied',
text: `${text} has been copied to your clipboard`,
})
}
</script>

View File

@@ -0,0 +1,131 @@
<template>
<div class="h-full w-full">
<div class="h-full w-full gap-2 overflow-y-auto">
<div class="card flex flex-col gap-4">
<h1 class="m-0 text-lg font-bold text-contrast">Server preferences</h1>
<p class="m-0">Preferences apply per server and changes are only saved in your browser.</p>
<div
v-for="(prefConfig, key) in preferences"
:key="key"
class="flex items-center justify-between gap-2"
>
<label :for="`pref-${key}`" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<span class="text-lg font-bold text-contrast">{{ prefConfig.displayName }}</span>
<div
v-if="prefConfig.implemented === false"
class="hidden items-center gap-1 rounded-full bg-table-alternateRow p-1 px-1.5 text-xs font-semibold sm:flex"
>
Coming Soon
</div>
</div>
<span>{{ prefConfig.description }}</span>
</label>
<input
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="switch stylized-toggle flex-none"
type="checkbox"
:disabled="prefConfig.implemented === false"
/>
</div>
</div>
</div>
<SaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
/>
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager } 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',
description:
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
implemented: true,
},
hideSubdomainLabel: {
displayName: 'Hide subdomain label',
description: 'When enabled, the subdomain label will be hidden from the server header.',
implemented: true,
},
autoRestart: {
displayName: 'Auto restart',
description: 'When enabled, your server will automatically restart if it crashes.',
implemented: false,
},
powerDontAskAgain: {
displayName: 'Power actions confirmation',
description: 'When enabled, you will be prompted before stopping and restarting your server.',
implemented: true,
},
backupWhileRunning: {
displayName: 'Create backups while running',
description: 'When enabled, backups will be created even if the server is running.',
implemented: true,
},
} as const
type PreferenceKeys = keyof typeof preferences
type UserPreferences = {
[K in PreferenceKeys]: boolean
}
const defaultPreferences: UserPreferences = {
ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,
}
const userPreferences = useStorage<UserPreferences>(
`pyro-server-${serverId}-preferences`,
defaultPreferences,
)
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)))
const hasUnsavedChanges = computed(() => {
return JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value)
})
const savePreferences = () => {
userPreferences.value = { ...newUserPreferences.value }
addNotification({
type: 'success',
title: 'Preferences saved',
text: 'Your preferences have been saved.',
})
}
const resetPreferences = () => {
newUserPreferences.value = { ...userPreferences.value }
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -0,0 +1,356 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="server.moduleErrors.fs"
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 properties</h1>
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.fs.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-else-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<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>
<div class="m-0">
Edit the Minecraft server properties file. If you're unsure about a specific property,
the
<NuxtLink
class="goto-link !inline-block"
to="https://minecraft.wiki/w/Server.properties"
external
>
Minecraft Wiki
</NuxtLink>
has more detailed information.
</div>
</div>
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
<div class="relative w-full text-sm">
<label for="search-server-properties" class="sr-only">Search server properties</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-server-properties"
v-model="searchInput"
class="w-full pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div
v-for="(property, index) in filteredProperties"
:key="index"
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>
<div
v-if="overrides[index] && overrides[index].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...')"
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="switch stylized-toggle"
type="checkbox"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div
v-else-if="typeof property === 'number' && index !== 'level-seed' && index !== 'seed'"
class="mt-2 w-full sm:w-[320px]"
>
<input
:id="`server-property-${index}`"
v-model.number="liveProperties[index]"
type="number"
class="w-full border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div
v-else-if="index === 'level-seed' || index === 'seed'"
class="mt-2 w-full sm:w-[320px]"
>
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
type="text"
class="w-full rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<textarea
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="w-full resize-y rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
></textarea>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
type="text"
class="w-full rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</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>
<SaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
restart
:save="saveProperties"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import { EyeIcon, IssuesIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectNotificationManager } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, inject, 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 props = defineProps<{
server: ModrinthServer
}>()
const tags = useGeneratedState()
const isUpdating = ref(false)
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
const rawProps = await props.server.fs?.downloadFile('server.properties')
if (!rawProps) return null
const properties: Record<string, any> = {}
const lines = rawProps.split('\n')
for (const line of lines) {
if (line.startsWith('#') || !line.includes('=')) continue
const [key, ...valueParts] = line.split('=')
let value = valueParts.join('=')
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
value = value.toLowerCase() === 'true'
} else {
const intLike = /^[-+]?\d+$/.test(value)
if (intLike) {
const n = Number(value)
if (Number.isSafeInteger(n)) {
value = n
}
}
}
properties[key.trim()] = value
}
return properties
})
const liveProperties = ref<Record<string, any>>({})
const originalProperties = ref<Record<string, any>>({})
watch(
propsData,
(newPropsData) => {
if (newPropsData) {
console.log(newPropsData)
liveProperties.value = JSON.parse(JSON.stringify(newPropsData))
originalProperties.value = JSON.parse(JSON.stringify(newPropsData))
}
},
{ immediate: true },
)
const hasUnsavedChanges = computed(() => {
return Object.keys(liveProperties.value).some(
(key) =>
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
)
})
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 overrides: { [key: string]: { type: string; options?: string[]; info?: string } } = {
difficulty: {
type: 'dropdown',
options: getDifficultyOptions(),
},
gamemode: {
type: 'dropdown',
options: ['survival', 'creative', 'adventure', 'spectator'],
},
}
const fuse = computed(() => {
if (!liveProperties.value) return null
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`
} else {
fileContent += `${key}=${value}\n`
}
}
return fileContent
}
const saveProperties = async () => {
try {
isUpdating.value = true
await props.server.fs?.updateFile('server.properties', constructServerProperties())
await new Promise((resolve) => setTimeout(resolve, 500))
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
await props.server.refresh()
addNotification({
type: 'success',
title: 'Server properties updated',
text: 'Your server properties were successfully changed.',
})
} catch (error) {
console.error('Error updating server properties:', error)
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: 'An error occurred while attempting to update your server properties.',
})
} finally {
isUpdating.value = false
}
}
const resetProperties = async () => {
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value))
await new Promise((resolve) => setTimeout(resolve, 200))
}
const formatPropertyName = (propertyName: string): string => {
return propertyName
.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>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -0,0 +1,240 @@
<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="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
>
These settings are for advanced users. Changing them can break your server.
</div>
<div class="gap-2">
<div class="card flex flex-col gap-4">
<div class="flex flex-col justify-between gap-4 sm:flex-row">
<label for="startup-command-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Startup command</span>
<span> The command that runs when your server is started. </span>
</label>
<ButtonStyled>
<button
:disabled="invocation === originalInvocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Restore default command
</button>
</ButtonStyled>
</div>
<textarea
id="startup-command-field"
v-model="invocation"
class="min-h-[270px] w-full resize-y font-[family-name:var(--mono-font)]"
/>
</div>
<div class="card flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Java version</span>
<span>
The version of Java that your server will run on. By default, only the Java versions
compatible with this version of Minecraft are shown. Some mods may require a
different Java version to work properly.
</span>
</div>
<div class="flex items-center gap-2">
<input
id="show-all-versions"
v-model="showAllVersions"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
</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>
</div>
</div>
</div>
<SaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:save="saveStartup"
:reset="resetStartup"
/>
</div>
</template>
<script setup lang="ts">
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectNotificationManager } from '@modrinth/ui'
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
}>()
await props.server.startup.fetch()
const data = computed(() => props.server.general)
const showAllVersions = ref(false)
const jdkVersionMap = [
{ value: 'lts8', label: 'Java 8' },
{ value: 'lts11', label: 'Java 11' },
{ value: 'lts17', label: 'Java 17' },
{ value: 'lts21', label: 'Java 21' },
]
const jdkBuildMap = [
{ 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,
)
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)
const hasUnsavedChanges = computed(
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.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']
})
const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value
})
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
}
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (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
}
function resetToDefault() {
invocation.value = originalInvocation.value ?? ''
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
import { ServersManagePageIndex } from '@modrinth/ui'
import { useGeneratedState } from '~/composables/generated'
definePageMeta({
middleware: 'auth',
})
useHead({
title: 'Servers - Modrinth',
})
const config = useRuntimeConfig()
const generatedState = useGeneratedState()
</script>
<template>
<ServersManagePageIndex
:stripe-publishable-key="config.public.stripePublishableKey"
:site-url="config.public.siteUrl"
:products="generatedState.products || []"
/>
</template>