You've already forked AstralRinth
forked from didirus/AstralRinth
Modrinth Hosting rebrand (#4846)
* Modrinth Hosting rebranding * fix capitalization issue * fix issues
This commit is contained in:
11
apps/frontend/src/pages/hosting/admin/index.vue
Normal file
11
apps/frontend/src/pages/hosting/admin/index.vue
Normal 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>
|
||||
1095
apps/frontend/src/pages/hosting/index.vue
Normal file
1095
apps/frontend/src/pages/hosting/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
1326
apps/frontend/src/pages/hosting/manage/[id].vue
Normal file
1326
apps/frontend/src/pages/hosting/manage/[id].vue
Normal file
File diff suppressed because it is too large
Load Diff
334
apps/frontend/src/pages/hosting/manage/[id]/backups.vue
Normal file
334
apps/frontend/src/pages/hosting/manage/[id]/backups.vue
Normal 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>
|
||||
21
apps/frontend/src/pages/hosting/manage/[id]/content.vue
Normal file
21
apps/frontend/src/pages/hosting/manage/[id]/content.vue
Normal 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>
|
||||
696
apps/frontend/src/pages/hosting/manage/[id]/content/index.vue
Normal file
696
apps/frontend/src/pages/hosting/manage/[id]/content/index.vue
Normal 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>
|
||||
1182
apps/frontend/src/pages/hosting/manage/[id]/files.vue
Normal file
1182
apps/frontend/src/pages/hosting/manage/[id]/files.vue
Normal file
File diff suppressed because it is too large
Load Diff
739
apps/frontend/src/pages/hosting/manage/[id]/index.vue
Normal file
739
apps/frontend/src/pages/hosting/manage/[id]/index.vue
Normal 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>
|
||||
71
apps/frontend/src/pages/hosting/manage/[id]/options.vue
Normal file
71
apps/frontend/src/pages/hosting/manage/[id]/options.vue
Normal 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>
|
||||
@@ -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>
|
||||
332
apps/frontend/src/pages/hosting/manage/[id]/options/index.vue
Normal file
332
apps/frontend/src/pages/hosting/manage/[id]/options/index.vue
Normal 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>
|
||||
157
apps/frontend/src/pages/hosting/manage/[id]/options/info.vue
Normal file
157
apps/frontend/src/pages/hosting/manage/[id]/options/info.vue
Normal 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>
|
||||
@@ -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>
|
||||
491
apps/frontend/src/pages/hosting/manage/[id]/options/network.vue
Normal file
491
apps/frontend/src/pages/hosting/manage/[id]/options/network.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
240
apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue
Normal file
240
apps/frontend/src/pages/hosting/manage/[id]/options/startup.vue
Normal 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>
|
||||
24
apps/frontend/src/pages/hosting/manage/index.vue
Normal file
24
apps/frontend/src/pages/hosting/manage/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user