feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 15:38:08 -06:00
committed by GitHub
parent a2a97d1313
commit 693a371d61
278 changed files with 15974 additions and 12608 deletions

View File

@@ -841,6 +841,23 @@ button {
opacity: 0.5;
box-shadow: none;
flex-shrink: 0;
user-select: none;
}
.text-input-wrapper__after {
display: flex;
color: var(--color-text);
padding: 0.5rem 1rem 0.5rem 0;
font-weight: var(--font-weight-medium);
min-height: 36px;
box-sizing: border-box;
width: fit-content;
align-items: center;
filter: grayscale(50%);
opacity: 0.5;
box-shadow: none;
flex-shrink: 0;
user-select: none;
}
input,

View File

@@ -456,9 +456,9 @@ kbd {
font-size: 0.85em !important;
}
@import '~/assets/styles/layout.scss';
@import '~/assets/styles/utils.scss';
@import '~/assets/styles/components.scss';
@import './layout.scss';
@import './utils.scss';
@import './components.scss';
// OMORPHIA FIXES
.card {

View File

@@ -1,96 +0,0 @@
<template>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in vanillaLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<LoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
<div class="mt-4">
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Mod loaders</h2>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in modLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<LoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
</div>
<div class="mt-4">
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Plugin loaders</h2>
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
<div
v-for="loader in pluginLoaders"
:key="loader.name"
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
>
<LoaderSelectorCard
:loader="loader"
:is-current="isCurrentLoader(loader.name)"
:loader-version="data.loader_version"
:current-loader="data.loader"
:is-installing="isInstalling"
@select="selectLoader"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import LoaderSelectorCard from './LoaderSelectorCard.vue'
const props = defineProps<{
data: {
loader: string | null
loader_version: string | null
}
ignoreCurrentInstallation?: boolean
isInstalling?: boolean
}>()
const emit = defineEmits<{
(e: 'selectLoader', loader: string): void
}>()
const vanillaLoaders = [{ name: 'Vanilla' as const, displayName: 'Vanilla' }]
const modLoaders = [
{ name: 'Fabric' as const, displayName: 'Fabric' },
{ name: 'Quilt' as const, displayName: 'Quilt' },
{ name: 'Forge' as const, displayName: 'Forge' },
{ name: 'NeoForge' as const, displayName: 'NeoForge' },
]
const pluginLoaders = [
{ name: 'Paper' as const, displayName: 'Paper' },
{ name: 'Purpur' as const, displayName: 'Purpur' },
]
const isCurrentLoader = (loaderName: string) => {
return props.data.loader?.toLowerCase() === loaderName.toLowerCase()
}
const selectLoader = (loader: string) => {
emit('selectLoader', loader)
}
</script>

View File

@@ -1,68 +0,0 @@
<template>
<div class="flex w-full items-center justify-between">
<div class="flex items-center gap-4">
<div
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
:class="isCurrentLoader ? '[&&]:bg-bg-green' : ''"
>
<LoaderIcon
:loader="loader.name"
class="size-6"
:class="isCurrentLoader ? 'text-brand' : ''"
/>
</div>
<div class="flex flex-col gap-0.5">
<div class="flex flex-row items-center gap-2">
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
{{ loader.displayName }}
</h1>
<span
v-if="isCurrentLoader"
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
>
<CheckIcon class="h-4 w-4" /> Current
</span>
</div>
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">{{ loaderVersion }}</p>
</div>
</div>
<ButtonStyled>
<button :disabled="isInstalling" @click="onSelect">
<DownloadIcon class="h-5 w-5" /> {{ isCurrentLoader ? 'Reinstall' : 'Install' }}
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { CheckIcon, DownloadIcon } from '@modrinth/assets'
import { ButtonStyled, LoaderIcon } from '@modrinth/ui'
interface LoaderInfo {
name: 'Vanilla' | 'Fabric' | 'Forge' | 'Quilt' | 'Paper' | 'NeoForge' | 'Purpur'
displayName: string
}
interface Props {
loader: LoaderInfo
currentLoader: string | null
loaderVersion: string | null
isInstalling?: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'select', loader: string): void
}>()
const isCurrentLoader = computed(() => {
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase()
})
const onSelect = () => {
emit('select', props.loader.name)
}
</script>

View File

@@ -1,91 +0,0 @@
<template>
<div
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
@mouseenter="checkOverflow"
@touchstart="checkOverflow"
>
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
<span v-html="sanitizedLog"></span>
</div>
<button
v-if="isOverflowing"
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
type="button"
@click.stop="$emit('show-full-log', props.log)"
>
...
</button>
</div>
</template>
<script setup lang="ts">
import Convert from 'ansi-to-html'
import DOMPurify from 'dompurify'
import { computed, onMounted, onUnmounted, ref } from 'vue'
const props = defineProps<{
log: string
}>()
defineEmits<{
'show-full-log': [log: string]
}>()
const logContent = ref<HTMLElement | null>(null)
const isOverflowing = ref(false)
const checkOverflow = () => {
if (logContent.value && !isOverflowing.value) {
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth
}
}
const convert = new Convert({
fg: '#FFF',
bg: '#000',
newline: false,
escapeXML: true,
stream: false,
})
const sanitizedLog = computed(() =>
DOMPurify.sanitize(convert.toHtml(props.log), {
ALLOWED_TAGS: ['span'],
ALLOWED_ATTR: ['style'],
USE_PROFILES: { html: true },
}),
)
const preventSelection = (e: MouseEvent) => {
e.preventDefault()
}
onMounted(() => {
logContent.value?.addEventListener('mousedown', preventSelection)
})
onUnmounted(() => {
logContent.value?.removeEventListener('mousedown', preventSelection)
})
</script>
<style scoped>
.parsed-log {
background: transparent;
transition: background-color 0.1s;
}
.parsed-log:hover {
background: rgba(128, 128, 128, 0.25);
transition: 0s;
}
.log-content > span {
user-select: none;
white-space: pre;
}
.log-content {
white-space: pre;
}
</style>

View File

@@ -1,276 +0,0 @@
<template>
<div class="contents">
<NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
<div class="flex flex-col gap-4 md:w-[400px]">
<p class="m-0">
Are you sure you want to
<span class="lowercase">{{ pendingAction }}</span> the server?
</p>
<Checkbox
v-model="dontAskAgain"
label="Don't ask me again"
class="text-sm"
:disabled="!pendingAction"
/>
<div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button>
<CheckIcon class="h-5 w-5" />
{{ pendingAction }} server
</button>
</ButtonStyled>
<ButtonStyled @click="resetPowerAction">
<button>
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal
ref="detailsModal"
:header="`All of ${server.name || 'Server'} info`"
@close="detailsModal?.hide()"
>
<ServerInfoLabels
:server-data="server"
:show-game-label="true"
:show-loader-label="true"
:uptime-seconds="uptimeSeconds"
:column="true"
class="mb-6 flex flex-col gap-2"
/>
<div v-if="flags.advancedDebugInfo" class="markdown-body">
<pre>{{ server }}</pre>
</div>
<ButtonStyled type="standard" color="brand" @click="detailsModal?.hide()">
<button class="w-full">Close</button>
</ButtonStyled>
</NewModal>
<div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
<button disabled class="flex-shrink-0">
<PanelSpinner class="size-5" /> Installing...
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent" size="large">
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ isStopping ? 'Stopping...' : 'Stop' }}</span>
</div>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitioning" class="grid place-content-center">
<LoadingIcon />
</div>
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
<ButtonStyled circular type="transparent" size="large">
<TeleportOverflowMenu :options="[...menuOptions]">
<MoreVerticalIcon aria-hidden="true" />
<template #kill>
<SlashIcon class="h-5 w-5" />
<span>Kill server</span>
</template>
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
</template>
<template #details>
<InfoIcon class="h-5 w-5" />
<span>Details</span>
</template>
<template #copy-id>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
<span>Copy ID</span>
</template>
</TeleportOverflowMenu>
</ButtonStyled>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {
CheckIcon,
ClipboardCopyIcon,
InfoIcon,
MoreVerticalIcon,
PlayIcon,
ServerIcon,
SlashIcon,
StopCircleIcon,
UpdatedIcon,
XIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
ServerInfoLabels,
useVIntl,
} from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import LoadingIcon from './icons/LoadingIcon.vue'
import PanelSpinner from './PanelSpinner.vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
const props = defineProps<{
disabled?: boolean
uptimeSeconds: number
}>()
const { formatMessage } = useVIntl()
const flags = useFeatureFlags()
const router = useRouter()
const client = injectModrinthClient()
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null)
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null)
const pendingAction = ref<PowerAction | null>(null)
const dontAskAgain = ref(false)
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false,
})
const isInstalling = computed(() => server.value.status === 'installing')
const isRunning = computed(() => powerState.value === 'running')
const isStopping = computed(() => powerState.value === 'stopping')
const isTransitioning = computed(
() => powerState.value === 'starting' || powerState.value === 'stopping',
)
const showStopButton = computed(() => isRunning.value || isStopping.value)
const busyTooltip = computed(() =>
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
)
const canTakeAction = computed(
() => !isTransitioning.value && !props.disabled && busyReasons.value.length === 0,
)
const primaryActionText = computed(() => {
switch (powerState.value) {
case 'starting':
return 'Starting...'
case 'stopping':
return 'Stopping...'
case 'running':
return 'Restart'
default:
return 'Start'
}
})
const menuOptions = computed(() => [
...(isInstalling.value
? []
: [
{
id: 'kill',
label: 'Kill server',
icon: SlashIcon,
action: () => initiateAction('Kill'),
},
]),
{
id: 'allServers',
label: 'All servers',
icon: ServerIcon,
action: () => router.push('/hosting/manage'),
},
{
id: 'details',
label: 'Details',
icon: InfoIcon,
action: () => detailsModal.value?.show(),
},
{
id: 'copy-id',
label: 'Copy ID',
icon: ClipboardCopyIcon,
action: () => copyId(),
shown: flags.value.developerMode,
},
])
async function copyId() {
await navigator.clipboard.writeText(serverId)
}
async function sendPowerAction(action: PowerAction) {
try {
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error performing ${action} on server:`, error)
addNotification({
type: 'error',
title: `Failed to ${action.toLowerCase()} server`,
text: 'An error occurred while performing this action.',
})
}
}
function initiateAction(action: PowerAction) {
if (!canTakeAction.value) return
if (action === 'Start') {
sendPowerAction(action)
return
}
pendingAction.value = action
if (userPreferences.value.powerDontAskAgain) {
executePowerAction()
} else {
confirmActionModal.value?.show()
}
}
function handlePrimaryAction() {
initiateAction(isRunning.value ? 'Restart' : 'Start')
}
function executePowerAction() {
if (!pendingAction.value) return
sendPowerAction(pendingAction.value)
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true
}
resetPowerAction()
}
function resetPowerAction() {
confirmActionModal.value?.hide()
pendingAction.value = null
dontAskAgain.value = false
}
</script>

View File

@@ -1,75 +0,0 @@
<template>
<div
:aria-label="`Server is ${getStatusText(state)}`"
class="relative inline-flex select-none items-center"
@mouseenter="isExpanded = true"
@mouseleave="isExpanded = false"
>
<div
:class="[
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
getStatusClass(state).main,
]"
>
<div
:class="[
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
getStatusClass(state).bg,
]"
></div>
</div>
<div
:class="[
'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
getStatusClass(state).bg,
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
]"
>
<div class="h-3 w-3 rounded-full"></div>
<span
:class="[
'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
]"
>
{{ getStatusText(state) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { ServerState } from '@modrinth/utils'
import { ref } from 'vue'
const STATUS_CLASSES = {
running: { main: 'bg-brand', bg: 'bg-bg-green' },
stopped: { main: '', bg: '' },
crashed: { main: 'bg-brand-red', bg: 'bg-bg-red' },
unknown: { main: '', bg: '' },
} as const
const STATUS_TEXTS: Partial<Record<ServerState, string>> = {
running: 'Running',
stopped: '',
crashed: 'Crashed',
unknown: 'Unknown',
} as const
defineProps<{
state: ServerState
}>()
const isExpanded = ref(false)
function getStatusClass(state: ServerState) {
if (state in STATUS_CLASSES) {
return STATUS_CLASSES[state as keyof typeof STATUS_CLASSES]
}
return STATUS_CLASSES.unknown
}
function getStatusText(state: ServerState) {
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown
}
</script>

View File

@@ -1,22 +0,0 @@
<template>
<svg
class="h-5 w-5 animate-spin"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</template>

File diff suppressed because it is too large Load Diff

View File

@@ -1,163 +0,0 @@
<template>
<NewModal
ref="modal"
:header="'Changing ' + props.project?.title + ' version'"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<div class="flex flex-col gap-2">
<p class="m-0">
Select the version of {{ props.project?.title || 'the modpack' }} you want to install on
your server.
</p>
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
Currently installed: {{ props.currentVersion.version_number }}
</p>
</div>
<div class="flex w-full flex-col gap-4">
<Combobox
v-if="props.versions?.length"
v-model="selectedVersion"
:options="versionOptions.map((v) => ({ value: v, label: v }))"
:display-value="selectedVersion || 'Select version...'"
placeholder="Select version..."
name="version"
class="w-full max-w-full"
/>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
Erase all data
</label>
<Toggle id="modpack-hard-reset" v-model="hardReset" class="shrink-0" />
</div>
<div>
If enabled, existing mods, worlds, and configurations, will be deleted before installing
the new modpack version.
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
<button
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
@click="handleReinstall"
>
<DownloadIcon class="size-4" />
{{ isLoading ? 'Installing...' : hardReset ? 'Erase and install' : 'Install' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isLoading" @click="hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
Toggle,
} from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
const { serverId } = injectModrinthServerContext()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const props = defineProps<{
project: any
versions: any[]
currentVersion?: any
currentVersionId?: string
serverStatus?: string
}>()
const emit = defineEmits<{
reinstall: [any?]
}>()
const modal = ref()
const hardReset = ref(false)
const isLoading = ref(false)
const selectedVersion = ref(props.currentVersion?.version_number || '')
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || [])
const handleReinstall = async () => {
if (!selectedVersion.value || !props.project?.id) return
isLoading.value = true
try {
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id
await client.archon.servers_v0.reinstall(
serverId,
{
project_id: props.project.id,
version_id: versionId,
},
hardReset.value,
)
emit('reinstall')
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
addNotification({
title: 'Cannot reinstall server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
title: 'Reinstall Failed',
text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false
}
}
watch(
() => props.serverStatus,
(newStatus) => {
if (newStatus === 'installing') {
hide()
}
},
)
const onShow = () => {
hardReset.value = false
selectedVersion.value =
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? ''
}
const onHide = () => {
hardReset.value = false
selectedVersion.value = ''
isLoading.value = false
}
const show = () => modal.value?.show()
const hide = () => modal.value?.hide()
defineExpose({ show, hide })
</script>

View File

@@ -1,538 +0,0 @@
<template>
<NewModal
ref="versionSelectModal"
:header="
isSecondPhase
? 'Confirming reinstallation'
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<p
v-if="isSecondPhase"
:style="{
lineHeight: isSecondPhase ? '1.5' : undefined,
marginBottom: isSecondPhase ? '-12px' : '0',
marginTop: isSecondPhase ? '-4px' : '-2px',
}"
>
{{
'This will reinstall your server and erase all data. Are you sure you want to continue?'
}}
</p>
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
<div class="mx-auto flex flex-row items-center gap-4">
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LoaderIcon class="size-10" :loader="selectedLoader" />
</div>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-10"
>
<path d="M5 9v6" />
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
</svg>
<div
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
>
<ServerIcon class="size-10" />
</div>
</div>
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
<div class="text-lg font-bold text-contrast">Minecraft version</div>
<Combobox
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions.map((v) => ({ value: v, label: v }))"
:display-value="selectedMCVersion || 'Select Minecraft version...'"
class="!w-full"
placeholder="Select Minecraft version..."
/>
<div class="mt-2 flex items-center justify-between gap-2">
<label for="toggle-snapshots" class="font-semibold"> Show snapshot versions </label>
<div
v-tooltip="
isSnapshotSelected ? 'A snapshot version is currently selected.' : undefined
"
>
<Toggle
id="toggle-snapshots"
v-model="showSnapshots"
:disabled="isSnapshotSelected"
/>
</div>
</div>
</div>
<div
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
class="flex w-full flex-col gap-2 rounded-2xl p-4"
:class="{
'bg-table-alternateRow':
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
'bg-highlight-red':
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
}"
>
<div class="flex flex-col gap-2">
<div class="text-lg font-bold text-contrast">{{ selectedLoader }} version</div>
<template v-if="!selectedMCVersion">
<div
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
>
Select a Minecraft version to see available versions
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="isLoading">
<div
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
>
<LoadingIcon class="mr-2 animate-spin" />
Loading versions...
<DropdownIcon class="absolute right-4" />
</div>
</template>
<template v-else-if="selectedLoaderVersions.length > 0">
<Combobox
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions.map((v) => ({ value: v, label: v }))"
:display-value="
selectedLoaderVersion ||
(selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? 'Select build number...'
: 'Select loader version...')
"
class="w-full max-w-[100%]"
:placeholder="
selectedLoader.toLowerCase() === 'paper' ||
selectedLoader.toLowerCase() === 'purpur'
? `Select build number...`
: `Select loader version...`
"
/>
</template>
<template v-else>
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
</template>
</div>
</div>
<div
v-if="!initialSetup"
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
>
<div class="flex w-full flex-row items-center justify-between">
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<Toggle id="hard-reset" v-model="hardReset" class="shrink-0" />
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
then reinstalls it with the selected version.
</div>
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
</div>
<BackupWarning v-if="!initialSetup" :backup-link="`/hosting/manage/${serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button
v-tooltip="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
:disabled="canInstall || busyReasons.length > 0"
@click="handleReinstall"
>
<RightArrowIcon />
{{
isLoading
? 'Installing...'
: isSecondPhase
? 'Erase and install'
: hardReset
? 'Continue'
: 'Install'
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
() => {
if (isSecondPhase) {
isSecondPhase = false
} else {
hide()
}
}
"
>
<XIcon />
{{ isSecondPhase ? 'Go back' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/assets'
import {
BackupWarning,
ButtonStyled,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from 'ofetch'
import LoaderIcon from './icons/LoaderIcon.vue'
import LoadingIcon from './icons/LoadingIcon.vue'
const { server, serverId, busyReasons } = injectModrinthServerContext()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
interface LoaderVersion {
id: string
stable: boolean
loaders: {
id: string
url: string
stable: boolean
}[]
}
type VersionMap = Record<string, LoaderVersion[]>
type VersionCache = Record<string, any>
const props = defineProps<{
currentLoader: Loaders | undefined
initialSetup?: boolean
}>()
const emit = defineEmits<{
reinstall: [any?]
}>()
const versionSelectModal = ref()
const isSecondPhase = ref(false)
const hardReset = ref(false)
const isLoading = ref(false)
const loadingServerCheck = ref(false)
const serverCheckError = ref('')
const showSnapshots = ref(false)
const selectedLoader = ref<Loaders>('Vanilla')
const selectedMCVersion = ref('')
const selectedLoaderVersion = ref('')
const paperVersions = ref<Record<string, number[]>>({})
const purpurVersions = ref<Record<string, string[]>>({})
const loaderVersions = ref<VersionMap>({})
const cachedVersions = ref<VersionCache>({})
const versionStrings = ['forge', 'fabric', 'quilt', 'neo'] as const
const isSnapshotSelected = computed(() => {
if (selectedMCVersion.value) {
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value)
if (selected?.version_type !== 'release') {
return true
}
}
return false
})
const getLoaderVersions = async (loader: string) => {
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
)
}
const fetchLoaderVersions = async () => {
const versions = await Promise.all(
versionStrings.map(async (loader) => {
const runFetch = async (iterations: number) => {
if (iterations > 5) {
throw new Error('Failed to fetch loader versions')
}
try {
const res = await getLoaderVersions(loader)
return { [loader]: (res as any).gameVersions }
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (_) {
return await runFetch(iterations + 1)
}
}
try {
return await runFetch(0)
} catch (e) {
console.error(e)
return { [loader]: [] }
}
}),
)
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {})
}
const fetchPaperVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://fill.papermc.io/v3/projects/paper/versions/${mcVersion}`)
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a)
return res
} catch (e) {
console.error(e)
return null
}
}
const fetchPurpurVersions = async (mcVersion: string) => {
try {
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`)
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
(a: string, b: string) => parseInt(b) - parseInt(a),
)
return res
} catch (e) {
console.error(e)
return null
}
}
const selectedLoaderVersions = computed<string[]>(() => {
const loader = selectedLoader.value.toLowerCase()
if (loader === 'paper') {
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || []
}
if (loader === 'purpur') {
return purpurVersions.value[selectedMCVersion.value] || []
}
if (loader === 'vanilla') {
return []
}
let apiLoader = loader
if (loader === 'neoforge') {
apiLoader = 'neo'
}
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
(x) => x.id === '${modrinth.gameVersion}',
)
if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id)
}
return (
loaderVersions.value[apiLoader]
?.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || []
)
})
watch(selectedLoader, async () => {
if (selectedMCVersion.value) {
selectedLoaderVersion.value = ''
serverCheckError.value = ''
await checkVersionAvailability(selectedMCVersion.value)
}
})
watch(
selectedLoaderVersions,
(newVersions) => {
if (
newVersions.length > 0 &&
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
) {
selectedLoaderVersion.value = String(newVersions[0])
}
},
{ immediate: true },
)
const getLoaderVersion = async (loader: string, version: string) => {
return await $fetch(
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
)
}
const checkVersionAvailability = async (version: string) => {
if (!version || version.trim().length < 3) return
isLoading.value = true
loadingServerCheck.value = true
try {
const mcRes = cachedVersions.value[version] || (await getLoaderVersion('minecraft', version))
cachedVersions.value[version] = mcRes
if (!mcRes.downloads?.server) {
serverCheckError.value = "We couldn't find a server.jar for this version."
return
}
const loader = selectedLoader.value.toLowerCase()
if (loader === 'paper' || loader === 'purpur') {
const fetchFn = loader === 'paper' ? fetchPaperVersions : fetchPurpurVersions
const result = await fetchFn(version)
if (!result) {
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`
return
}
}
serverCheckError.value = ''
} catch (error) {
console.error(error)
serverCheckError.value = 'Failed to fetch versions.'
} finally {
loadingServerCheck.value = false
isLoading.value = false
}
}
watch(selectedMCVersion, checkVersionAvailability)
onMounted(() => {
fetchLoaderVersions()
})
const tags = useGeneratedState()
const mcVersions = computed(() =>
tags.value.gameVersions
.filter((x) =>
showSnapshots.value
? x.version_type === 'snapshot' || x.version_type === 'release'
: x.version_type === 'release',
)
.map((x) => x.version),
)
const isDangerous = computed(() => hardReset.value)
const canInstall = computed(() => {
const conds =
!selectedMCVersion.value ||
isLoading.value ||
loadingServerCheck.value ||
serverCheckError.value.trim().length > 0
if (selectedLoader.value.toLowerCase() === 'vanilla') {
return conds
}
return conds || !selectedLoaderVersion.value
})
const handleReinstall = async () => {
if (hardReset.value && !isSecondPhase.value) {
isSecondPhase.value = true
return
}
isLoading.value = true
try {
await client.archon.servers_v0.reinstall(
serverId,
{
loader: selectedLoader.value,
loader_version:
selectedLoader.value === 'Vanilla' ? undefined : selectedLoaderVersion.value || undefined,
game_version: selectedMCVersion.value,
},
props.initialSetup ? true : hardReset.value,
)
emit('reinstall', {
loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value,
})
hide()
} catch (error) {
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
addNotification({
title: 'Cannot reinstall server',
text: 'You are being rate limited. Please try again later.',
type: 'error',
})
} else {
addNotification({
title: 'Reinstall Failed',
text: 'An unexpected error occurred while reinstalling. Please try again later.',
type: 'error',
})
}
} finally {
isLoading.value = false
}
}
const onShow = () => {
selectedMCVersion.value = server.value?.mc_version || ''
if (isSnapshotSelected.value) {
showSnapshots.value = true
}
}
const onHide = () => {
hardReset.value = false
isSecondPhase.value = false
serverCheckError.value = ''
loadingServerCheck.value = false
isLoading.value = false
selectedMCVersion.value = ''
serverCheckError.value = ''
paperVersions.value = {}
purpurVersions.value = {}
}
const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = ''
}
selectedLoader.value = loader
selectedMCVersion.value = server.value?.mc_version || ''
versionSelectModal.value?.show()
}
const hide = () => versionSelectModal.value?.hide()
defineExpose({ show, hide })
</script>

View File

@@ -1,90 +0,0 @@
<template>
<Transition name="save-banner">
<div
v-if="props.isVisible"
data-pyro-save-banner
class="fixed bottom-16 left-0 right-0 z-[10] mx-auto h-fit w-full max-w-4xl transition-all duration-300 sm:bottom-8"
>
<div class="mx-2 rounded-2xl border-2 border-solid border-button-border bg-bg-raised p-4">
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
<span class="font-bold text-contrast">Careful, you have unsaved changes!</span>
<div class="flex gap-2">
<ButtonStyled type="transparent" color="standard">
<button :disabled="props.isUpdating" @click="props.reset">Reset</button>
</ButtonStyled>
<ButtonStyled type="standard" :color="props.restart ? 'standard' : 'brand'">
<button :disabled="props.isUpdating" @click="props.save">
{{ props.isUpdating ? 'Saving...' : 'Save' }}
</button>
</ButtonStyled>
<ButtonStyled v-if="props.restart" type="standard" color="brand">
<button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
{{ powerButtonLabel }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ButtonStyled, injectModrinthClient, injectModrinthServerContext } from '@modrinth/ui'
import { computed } from 'vue'
const props = defineProps<{
isUpdating: boolean
restart?: boolean
save: () => void
reset: () => void
isVisible: boolean
serverId: string
}>()
const client = injectModrinthClient()
const { powerState } = injectModrinthServerContext()
const isStopped = computed(() => powerState.value === 'stopped' || powerState.value === 'crashed')
const isTransitioning = computed(
() => powerState.value === 'starting' || powerState.value === 'stopping',
)
const powerButtonLabel = computed(() => {
if (props.isUpdating) return 'Saving...'
if (isTransitioning.value) return isStopped.value ? 'Save & start' : 'Save & restart'
return isStopped.value ? 'Save & start' : 'Save & restart'
})
const saveAndPower = async () => {
props.save()
await client.archon.servers_v0.power(props.serverId, isStopped.value ? 'Start' : 'Restart')
}
</script>
<style scoped>
.save-banner-enter-active {
transition:
opacity 300ms,
transform 300ms;
}
.save-banner-leave-active {
transition:
opacity 200ms,
transform 200ms;
}
.save-banner-enter-from,
.save-banner-leave-to {
opacity: 0;
transform: translateY(100%) scale(0.98);
}
.save-banner-enter-to,
.save-banner-leave-from {
opacity: 1;
transform: none;
}
</style>

View File

@@ -1,52 +0,0 @@
<template>
<div class="static w-full grid-cols-1 md:relative md:flex">
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
<div
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
:key="link.label"
>
<NuxtLink
:to="link.href"
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
>
<div class="flex items-center gap-2 font-bold">
<component :is="link.icon" class="size-6" />
{{ link.label }}
</div>
<div class="flex-grow" />
<RightArrowIcon v-if="link.external" class="size-4" />
</NuxtLink>
</div>
</div>
</div>
<div class="h-full w-full">
<NuxtPage :route="route" @reinstall="onReinstall" />
</div>
</div>
</template>
<script setup lang="ts">
import { RightArrowIcon } from '@modrinth/assets'
import type { RouteLocationNormalized } from 'vue-router'
const emit = defineEmits(['reinstall'])
defineProps<{
navLinks: {
label: string
href: string
icon: Component
external?: boolean
shown?: boolean
}[]
route: RouteLocationNormalized
}>()
const onReinstall = (...args: any[]) => {
emit('reinstall', ...args)
}
</script>

View File

@@ -1,256 +0,0 @@
<template>
<div
data-pyro-server-stats
style="font-variant-numeric: tabular-nums"
class="flex select-none flex-col items-center gap-6 md:flex-row"
:class="{ 'pointer-events-none': loading }"
:aria-hidden="loading"
>
<div
v-for="(metric, index) in metrics"
:key="index"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="relative z-10">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">
{{ metric.value }}
</h2>
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
</div>
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
{{ metric.title }}
<IssuesIcon
v-if="metric.warning && !loading"
v-tooltip="metric.warning"
class="size-5"
:style="{ color: 'var(--color-orange)' }"
/>
</h3>
</div>
<div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
</div>
<component
:is="metric.icon"
class="absolute right-10 top-10 z-10 size-8"
style="width: 2rem; height: 2rem"
/>
<div class="chart-space absolute bottom-0 left-0 right-0">
<ClientOnly>
<VueApexCharts
v-if="metric.showGraph && !loading"
type="area"
height="142"
:options="getChartOptions(metric.warning, index)"
:series="[{ name: metric.title, data: metric.data }]"
class="chart"
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
/>
</ClientOnly>
</div>
</div>
<nuxt-link
:to="loading ? undefined : `/hosting/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
{{ loading ? '0 B' : formatBytes(stats.storage_usage_bytes) }}
</h2>
</div>
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</nuxt-link>
</div>
</template>
<script setup lang="ts">
import { CpuIcon, DatabaseIcon, FolderOpenIcon, IssuesIcon } from '@modrinth/assets'
import type { Stats } from '@modrinth/utils'
import { useStorage } from '@vueuse/core'
import { computed, ref, shallowRef } from 'vue'
const flags = useFeatureFlags()
const route = useNativeRoute()
const serverId = route.params.id
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
const chartsReady = ref(new Set<number>())
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
ramAsNumber: false,
})
const props = withDefaults(defineProps<{ data?: Stats; loading?: boolean }>(), {
loading: false,
})
const stats = shallowRef(
props.data?.current || {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1, // Avoid division by zero
storage_usage_bytes: 0,
},
)
const onChartReady = (index: number) => {
chartsReady.value.add(index)
}
const formatBytes = (bytes: number) => {
const units = ['B', 'KB', 'MB', 'GB']
let value = bytes
let unit = 0
while (value >= 1024 && unit < units.length - 1) {
value /= 1024
unit++
}
return `${Math.round(value * 10) / 10} ${units[unit]}`
}
const cpuData = ref<number[]>(Array(20).fill(0))
const ramData = ref<number[]>(Array(20).fill(0))
const updateGraphData = (arr: number[], newValue: number) => {
arr.push(newValue)
arr.shift()
}
const metrics = computed(() => {
if (props.loading) {
return [
{
title: 'CPU usage',
value: '0.00%',
max: '100%',
icon: CpuIcon,
data: cpuData.value,
showGraph: false,
warning: null,
},
{
title: 'Memory usage',
value: '0.00%',
max: '100%',
icon: DatabaseIcon,
data: ramData.value,
showGraph: false,
warning: null,
},
]
}
const ramPercent = Math.min(
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
100,
)
const cpuPercent = Math.min(stats.value.cpu_percent, 100)
updateGraphData(cpuData.value, cpuPercent)
updateGraphData(ramData.value, ramPercent)
return [
{
title: 'CPU usage',
value: `${cpuPercent.toFixed(2)}%`,
max: '100%',
icon: CpuIcon,
data: cpuData.value,
showGraph: true,
warning: cpuPercent >= 90 ? 'CPU usage is very high' : null,
},
{
title: 'Memory usage',
value:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_usage_bytes)
: `${ramPercent.toFixed(2)}%`,
max:
userPreferences.value.ramAsNumber || flags.developerMode
? formatBytes(stats.value.ram_total_bytes)
: '100%',
icon: DatabaseIcon,
data: ramData.value,
showGraph: true,
warning: ramPercent >= 90 ? 'Memory usage is very high' : null,
},
]
})
const getChartOptions = (hasWarning: string | null, index: number) => ({
chart: {
type: 'area',
animations: { enabled: false },
sparkline: { enabled: true },
toolbar: { show: false },
padding: {
left: -10,
right: -10,
top: 0,
bottom: 0,
},
events: {
mounted: () => onChartReady(index),
updated: () => onChartReady(index),
},
},
stroke: { curve: 'smooth', width: 3 },
fill: {
type: 'gradient',
gradient: {
shadeIntensity: 1,
opacityFrom: 0.25,
opacityTo: 0.05,
stops: [0, 100],
},
},
tooltip: { enabled: false },
grid: { show: false },
xaxis: {
labels: { show: false },
axisBorder: { show: false },
type: 'numeric',
tickAmount: 20,
range: 20,
},
yaxis: {
show: false,
min: 0,
max: 100,
forceNiceScale: false,
},
colors: [hasWarning ? 'var(--color-orange)' : 'var(--color-brand)'],
dataLabels: {
enabled: false,
},
})
watch(
() => props.data?.current,
(newStats) => {
if (newStats) {
stats.value = newStats
}
},
)
</script>
<style scoped>
.chart-space {
height: 142px;
width: calc(100% + 48px);
margin-left: -24px;
margin-right: -24px;
}
.chart {
width: 100% !important;
height: 142px !important;
transition: opacity 0.3s ease-out;
}
</style>

View File

@@ -1,438 +0,0 @@
<template>
<div data-pyro-telepopover-wrapper class="relative">
<button
ref="triggerRef"
class="teleport-overflow-menu-trigger"
:aria-expanded="isOpen"
:aria-haspopup="true"
@mousedown="handleMouseDown"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
@click="toggleMenu"
>
<slot></slot>
</button>
<Teleport to="#teleports">
<Transition
enter-active-class="transition duration-125 ease-out"
enter-from-class="transform scale-75 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-125 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-75 opacity-0"
>
<div
v-if="isOpen"
ref="menuRef"
data-pyro-telepopover-root
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
:style="menuStyle"
role="menu"
tabindex="-1"
@mousedown.stop
@mouseleave="handleMouseLeave"
>
<template
v-for="(option, index) in filteredOptions"
:key="isDivider(option) ? `divider-${index}` : option.id"
>
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
<button
v-if="typeof option.action === 'function'"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
}
"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</button>
<nuxt-link
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
}
"
:to="option.action"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</nuxt-link>
<a
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
:ref="
(el) => {
if (el) menuItemsRef[index] = el as HTMLElement
}
"
:href="option.action"
target="_blank"
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
:aria-selected="index === selectedIndex"
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
@click="handleItemClick(option, index)"
@focus="selectedIndex = index"
@mouseover="handleMouseOver(index)"
>
<slot :name="option.id">{{ option.id }}</slot>
</a>
<span v-else>
<slot :name="option.id">{{ option.id }}</slot>
</span>
</ButtonStyled>
</template>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from '@modrinth/ui'
import { onClickOutside, useElementHover } from '@vueuse/core'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
interface Option {
id: string
action?: (() => void) | string
shown?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
}
type Divider = {
divider: true
shown?: boolean
}
type Item = Option | Divider
function isDivider(item: Item): item is Divider {
return (item as Divider).divider
}
const props = withDefaults(
defineProps<{
options: Item[]
hoverable?: boolean
}>(),
{
hoverable: false,
},
)
const emit = defineEmits<{
(e: 'select', option: Option): void
}>()
const isOpen = ref(false)
const selectedIndex = ref(-1)
const menuRef = ref<HTMLElement | null>(null)
const triggerRef = ref<HTMLElement | null>(null)
const isMouseDown = ref(false)
const typeAheadBuffer = ref('')
const typeAheadTimeout = ref<number | null>(null)
const menuItemsRef = ref<HTMLElement[]>([])
const hoveringTrigger = useElementHover(triggerRef)
const hoveringMenu = useElementHover(menuRef)
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
const menuStyle = ref({
top: '0px',
left: '0px',
})
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
const calculateMenuPosition = () => {
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
const triggerRect = triggerRef.value.getBoundingClientRect()
const menuRect = menuRef.value.getBoundingClientRect()
const menuWidth = menuRect.width
const menuHeight = menuRect.height
const margin = 8
let top: number
let left: number
// okay gang lets calculate this shit
// from the top now yall
// y
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
top = triggerRect.bottom + margin
} else if (triggerRect.top - menuHeight - margin >= 0) {
top = triggerRect.top - menuHeight - margin
} else {
top = Math.max(margin, window.innerHeight - menuHeight - margin)
}
// x
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
left = triggerRect.left
} else if (triggerRect.right - menuWidth - margin >= 0) {
left = triggerRect.right - menuWidth
} else {
left = Math.max(margin, window.innerWidth - menuWidth - margin)
}
return {
top: `${top}px`,
left: `${left}px`,
}
}
const toggleMenu = (event: MouseEvent) => {
event.stopPropagation()
if (!props.hoverable) {
if (isOpen.value) {
closeMenu()
} else {
openMenu()
}
}
}
const openMenu = () => {
isOpen.value = true
disableBodyScroll()
nextTick(() => {
menuStyle.value = calculateMenuPosition()
document.addEventListener('mousemove', handleMouseMove)
focusFirstMenuItem()
})
}
const closeMenu = () => {
isOpen.value = false
selectedIndex.value = -1
enableBodyScroll()
document.removeEventListener('mousemove', handleMouseMove)
}
const selectOption = (option: Option) => {
emit('select', option)
if (typeof option.action === 'function') {
option.action()
}
closeMenu()
}
const handleMouseDown = (event: MouseEvent) => {
event.preventDefault()
isMouseDown.value = true
}
const handleMouseEnter = () => {
if (props.hoverable) {
openMenu()
}
}
const handleMouseLeave = () => {
if (props.hoverable) {
setTimeout(() => {
if (!hovering.value) {
closeMenu()
}
}, 250)
}
}
const handleMouseMove = (event: MouseEvent) => {
if (!isOpen.value || !isMouseDown.value) return
const menuRect = menuRef.value?.getBoundingClientRect()
if (!menuRect) return
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
if (!menuItems) return
for (let i = 0; i < menuItems.length; i++) {
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
if (
event.clientX >= itemRect.left &&
event.clientX <= itemRect.right &&
event.clientY >= itemRect.top &&
event.clientY <= itemRect.bottom
) {
selectedIndex.value = i
break
}
}
}
const handleItemClick = (option: Option, index: number) => {
selectedIndex.value = index
selectOption(option)
}
const handleMouseOver = (index: number) => {
selectedIndex.value = index
menuItemsRef.value[selectedIndex.value].focus?.()
}
// Scrolling is disabled for keyboard navigation
const disableBodyScroll = () => {
document.body.style.overflow = 'hidden'
}
const enableBodyScroll = () => {
document.body.style.overflow = ''
}
const focusFirstMenuItem = () => {
if (menuItemsRef.value.length > 0) {
menuItemsRef.value[0].focus?.()
}
}
const handleKeydown = (event: KeyboardEvent) => {
if (!isOpen.value) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
openMenu()
}
return
}
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
menuItemsRef.value[selectedIndex.value].focus?.()
break
case 'ArrowUp':
event.preventDefault()
selectedIndex.value =
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
menuItemsRef.value[selectedIndex.value].focus?.()
break
case 'Home':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
selectedIndex.value = 0
menuItemsRef.value[selectedIndex.value].focus?.()
}
break
case 'End':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
selectedIndex.value = filteredOptions.value.length - 1
menuItemsRef.value[selectedIndex.value].focus?.()
}
break
case 'Enter':
case ' ':
event.preventDefault()
if (selectedIndex.value >= 0) {
const option = filteredOptions.value[selectedIndex.value]
if (isDivider(option)) break
selectOption(option)
}
break
case 'Escape':
event.preventDefault()
closeMenu()
triggerRef.value?.focus?.()
break
case 'Tab':
event.preventDefault()
if (menuItemsRef.value.length > 0) {
if (event.shiftKey) {
selectedIndex.value =
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
} else {
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
}
menuItemsRef.value[selectedIndex.value].focus?.()
}
break
default:
if (event.key.length === 1) {
typeAheadBuffer.value += event.key.toLowerCase()
const matchIndex = filteredOptions.value.findIndex(
(option) =>
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
)
if (matchIndex !== -1) {
selectedIndex.value = matchIndex
menuItemsRef.value[selectedIndex.value].focus?.()
}
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value)
}
typeAheadTimeout.value = setTimeout(() => {
typeAheadBuffer.value = ''
}, 1000) as unknown as number
}
break
}
}
const handleResizeOrScroll = () => {
if (isOpen.value) {
menuStyle.value = calculateMenuPosition()
}
}
const throttle = (func: (...args: any[]) => void, limit: number): ((...args: any[]) => void) => {
let inThrottle: boolean
return function (...args: any[]) {
if (!inThrottle) {
func(...args)
inThrottle = true
setTimeout(() => (inThrottle = false), limit)
}
}
}
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
onMounted(() => {
triggerRef.value?.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', throttledHandleResizeOrScroll)
window.addEventListener('scroll', throttledHandleResizeOrScroll)
})
onUnmounted(() => {
triggerRef.value?.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', throttledHandleResizeOrScroll)
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
document.removeEventListener('mousemove', handleMouseMove)
if (typeAheadTimeout.value) {
clearTimeout(typeAheadTimeout.value)
}
enableBodyScroll()
})
watch(isOpen, (newValue) => {
if (newValue) {
nextTick(() => {
menuRef.value?.addEventListener('keydown', handleKeydown)
})
} else {
menuRef.value?.removeEventListener('keydown', handleKeydown)
}
})
onClickOutside(menuRef, (event) => {
if (!triggerRef.value?.contains(event.target as Node)) {
closeMenu()
}
})
</script>

View File

@@ -1,16 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-down"
>
<path d="m6 9 6 6 6-6" />
</svg>
</template>

View File

@@ -1,16 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-chevron-up"
>
<path d="m18 15-6-6-6 6" />
</svg>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M10 12.5 8 15l2 2.5" />
<path d="m14 12.5 2 2.5-2 2.5" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
</svg>
</template>

View File

@@ -1,26 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<circle cx="18" cy="18" r="3" />
<path
d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v3.3"
/>
<path d="m21.7 19.4-.9-.3" />
<path d="m15.2 16.9-.9-.3" />
<path d="m16.6 21.7.3-.9" />
<path d="m19.1 15.2.3-.9" />
<path d="m19.6 21.7-.4-1" />
<path d="m16.8 15.3-.4-1" />
<path d="m14.3 19.6 1-.4" />
<path d="m20.7 16.8 1-.4" />
</svg>
</template>

View File

@@ -1,20 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
<path
d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"
/>
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
<circle cx="12" cy="12" r="10" />
</svg>
</template>

View File

@@ -1,19 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5"
>
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
</svg>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<circle cx="10" cy="12" r="2" />
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
</svg>
</template>

View File

@@ -1,15 +0,0 @@
<template>
<component :is="icon" v-if="icon" />
<LoaderIcon v-else />
</template>
<script setup lang="ts">
import { getLoaderIcon, LoaderIcon } from '@modrinth/assets'
import { computed } from 'vue'
const props = defineProps<{
loader: string
}>()
const icon = computed(() => getLoaderIcon(props.loader))
</script>

View File

@@ -1,9 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
clip-rule="evenodd"
/>
</svg>
</template>

View File

@@ -1,19 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="size-5"
>
<path d="m15 15 6 6m-6-6v4.8m0-4.8h4.8" />
<path d="M9 19.8V15m0 0H4.2M9 15l-6 6" />
<path d="M15 4.2V9m0 0h4.8M15 9l6-6" />
<path d="M9 4.2V9m0 0H4.2M9 9 3 3" />
</svg>
</template>

View File

@@ -1,16 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-8 text-[#FF496E]"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
/>
</svg>
</template>

View File

@@ -1,10 +0,0 @@
<template>
<svg height="32" viewBox="0 0 32 32" width="32">
<path
d="M22 5L9 28"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
></path>
</svg>
</template>

View File

@@ -1,20 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-file-text"
>
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
<path d="M10 9H8" />
<path d="M16 13H8" />
<path d="M16 17H8" />
</svg>
</template>

View File

@@ -1,17 +0,0 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="10" x2="14" y1="2" y2="2" />
<line x1="12" x2="15" y1="14" y2="11" />
<circle cx="12" cy="14" r="8" />
</svg>
</template>

View File

@@ -1,158 +0,0 @@
<template>
<div
class="medal-promotion relative flex w-full flex-row items-center justify-between rounded-2xl p-4 shadow-xl"
>
<MedalBackgroundImage />
<div class="z-10 mr-2 flex flex-col gap-1">
<Transition
enter-from-class="opacity-0 translate-y-1"
enter-active-class="transition-all duration-300"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150"
leave-to-class="opacity-0 -translate-y-1"
>
<div
v-if="expiryDate"
class="flex items-center gap-2 whitespace-nowrap font-semibold text-contrast"
>
<ClockIcon class="clock-glow text-medal-orange size-5 shrink-0" />
<span class="w-full text-wrap text-lg">
Your <span class="text-medal-orange">Medal</span>-powered Modrinth Server will expire in
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.days }}</span> days
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.hours }}</span> hours
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.minutes }}</span> minutes
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.seconds }}</span>
seconds.
</span>
</div>
</Transition>
</div>
<ButtonStyled color="medal-promo" type="outlined" size="large">
<button class="z-10 my-auto" @click="openUpgradeModal"><RocketIcon /> Upgrade</button>
</ButtonStyled>
</div>
<ServersUpgradeModalWrapper ref="upgradeModal" />
</template>
<script setup lang="ts">
import { ClockIcon, RocketIcon } from '@modrinth/assets'
import { ButtonStyled, MedalBackgroundImage } from '@modrinth/ui'
import type { UserSubscription } from '@modrinth/utils'
import dayjs from 'dayjs'
import dayjsDuration from 'dayjs/plugin/duration'
import type { ComponentPublicInstance } from 'vue'
import ServersUpgradeModalWrapper from '../ServersUpgradeModalWrapper.vue'
dayjs.extend(dayjsDuration)
type UpgradeWrapperRef = ComponentPublicInstance<{ open: (id?: string) => void | Promise<void> }>
const upgradeModal = ref<UpgradeWrapperRef | null>(null)
const props = defineProps<{
serverId?: string
}>()
const { data: subscriptions } = await useLazyAsyncData(
'countdown-subscriptions',
() =>
useBaseFetch(`billing/subscriptions`, {
internal: true,
}) as Promise<UserSubscription[]>,
)
const expiryDate = computed(() => {
for (const subscription of subscriptions.value || []) {
if (subscription.metadata?.id === props.serverId) {
return dayjs(subscription.created).add(5, 'days')
}
}
return undefined
})
function openUpgradeModal() {
upgradeModal.value?.open(props.serverId)
}
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 })
function updateCountdown() {
if (!expiryDate.value) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
return
}
const now = dayjs()
const diff = expiryDate.value.diff(now)
if (diff <= 0) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
return
}
const duration = dayjs.duration(diff)
timeLeftCountdown.value = {
days: duration.days(),
hours: duration.hours(),
minutes: duration.minutes(),
seconds: duration.seconds(),
}
}
updateCountdown()
const intervalId = ref<NodeJS.Timeout | null>(null)
onMounted(() => {
intervalId.value = setInterval(updateCountdown, 1000)
})
onUnmounted(() => {
if (intervalId.value) clearInterval(intervalId.value)
})
</script>
<style scoped lang="scss">
.medal-promotion {
position: relative;
border: 1px solid var(--medal-promotion-bg-orange);
background: inherit; // allows overlay + pattern to take over
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--medal-promotion-bg-gradient);
z-index: 1;
border-radius: inherit;
}
.background-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--medal-promotion-bg);
border-radius: inherit;
color: var(--medal-promotion-text-orange);
}
.clock-glow {
filter: drop-shadow(0 0 72px var(--color-orange)) drop-shadow(0 0 36px var(--color-orange))
drop-shadow(0 0 18px var(--color-orange));
}
.text-medal-orange {
color: var(--medal-promotion-text-orange);
font-weight: bold;
}
</style>

View File

@@ -1,127 +0,0 @@
<script setup lang="ts">
import { SettingsIcon } from '@modrinth/assets'
import {
CopyCode,
getDismissableMetadata,
NOTICE_LEVELS,
ServerNotice,
TagItem,
useFormatDateTime,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
})
const formatDateTimeShortMonth = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'medium',
})
defineProps<{
notice: ServerNoticeType
}>()
</script>
<template>
<div class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4">
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
<div>
<CopyCode :text="`${notice.id}`" />
</div>
<div class="text-sm">
<span v-if="notice.announce_at">
{{ formatDateTimeShortMonth(notice.announce_at) }} ({{
formatRelativeTime(notice.announce_at)
}})
</span>
<template v-else> Never begins </template>
</div>
<div class="text-sm">
<span v-if="notice.expires" v-tooltip="formatDateTime(notice.expires)">
{{ formatRelativeTime(notice.expires) }}
</span>
<template v-else> Never expires </template>
</div>
<div
:style="
NOTICE_LEVELS[notice.level]
? {
'--_color': NOTICE_LEVELS[notice.level].colors.text,
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
}
: undefined
"
>
<TagItem>
{{
NOTICE_LEVELS[notice.level]
? formatMessage(NOTICE_LEVELS[notice.level].name)
: notice.level
}}
</TagItem>
</div>
<div
:style="{
'--_color': getDismissableMetadata(notice.dismissable).colors.text,
'--_bg-color': getDismissableMetadata(notice.dismissable).colors.bg,
}"
>
<TagItem>
{{ formatMessage(getDismissableMetadata(notice.dismissable).name) }}
</TagItem>
</div>
<div class="col-span-2 flex gap-2 md:col-span-1">
<!-- <ButtonStyled>
<button @click="() => startEditing(notice)">
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="() => deleteNotice(notice)">
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
</button>
</ButtonStyled> -->
</div>
</div>
<div class="col-span-full grid">
<ServerNotice
:level="notice.level"
:message="notice.message"
:dismissable="notice.dismissable"
:title="notice.title"
preview
/>
<div class="mt-4 flex items-center gap-2">
<span v-if="!notice.assigned || notice.assigned.length === 0"
>Not assigned to any servers</span
>
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
Assigned to
{{ notice.assigned.filter((n) => n.kind === 'node').length }} nodes
</span>
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
Assigned to
{{ notice.assigned.filter((n) => n.kind === 'server').length }} servers
</span>
<span v-else>
Assigned to
{{ notice.assigned.filter((n) => n.kind === 'server').length }} servers and
{{ notice.assigned.filter((n) => n.kind === 'node').length }} nodes
</span>
<button
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
@click="() => startEditing(notice, true)"
>
<SettingsIcon />
Edit assignments
</button>
</div>
</div>
</div>
</template>

View File

@@ -74,7 +74,7 @@
</template>
<script setup lang="ts" generic="T">
import { MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
import { ChevronDownIcon, MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
import type { QuickReply } from '@modrinth/moderation'
import {
ButtonStyled,
@@ -90,7 +90,6 @@ import dayjs from 'dayjs'
import { useImageUpload } from '~/composables/image-upload.ts'
import { isStaff } from '~/helpers/users.js'
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
import ThreadMessage from './ThreadMessage.vue'
const { addNotification } = injectNotificationManager()

View File

@@ -1,131 +0,0 @@
import type { Archon } from '@modrinth/api-client'
import { injectModrinthClient } from '@modrinth/ui'
import { type ComputedRef, ref, watch } from 'vue'
// TODO: Remove and use V1 when available
export function useServerImage(
serverId: string,
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
) {
const client = injectModrinthClient()
const image = ref<string | undefined>()
const sharedImage = useState<string | undefined>(`server-icon-${serverId}`)
if (sharedImage.value) {
image.value = sharedImage.value
}
async function loadImage() {
if (sharedImage.value) {
image.value = sharedImage.value
return
}
if (import.meta.server) return
const cached = localStorage.getItem(`server-icon-${serverId}`)
if (cached) {
sharedImage.value = cached
image.value = cached
return
}
let projectIconUrl: string | undefined
const upstreamVal = upstream.value
if (upstreamVal?.project_id) {
try {
const project = await $fetch<{ icon_url?: string }>(
`https://api.modrinth.com/v2/project/${upstreamVal.project_id}`,
)
projectIconUrl = project.icon_url
} catch {
// project fetch failed, continue without icon url
}
}
try {
const fileData = await client.kyros.files_v0.downloadFile('/server-icon-original.png')
if (fileData instanceof Blob) {
const dataURL = await resizeImage(fileData, 512)
sharedImage.value = dataURL
localStorage.setItem(`server-icon-${serverId}`, dataURL)
image.value = dataURL
return
}
} catch (error: any) {
if (error?.statusCode >= 500) {
image.value = undefined
return
}
if (error?.statusCode === 404 && projectIconUrl) {
try {
const response = await fetch(projectIconUrl)
if (!response.ok) throw new Error('Failed to fetch icon')
const file = await response.blob()
const originalFile = new File([file], 'server-icon-original.png', {
type: 'image/png',
})
const dataURL = await new Promise<string>((resolve) => {
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(async (blob) => {
if (blob) {
const scaledFile = new File([blob], 'server-icon.png', {
type: 'image/png',
})
client.kyros.files_v0
.uploadFile('/server-icon.png', scaledFile)
.promise.catch(() => {})
client.kyros.files_v0
.uploadFile('/server-icon-original.png', originalFile)
.promise.catch(() => {})
}
}, 'image/png')
const result = canvas.toDataURL('image/png')
sharedImage.value = result
localStorage.setItem(`server-icon-${serverId}`, result)
resolve(result)
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(file)
})
image.value = dataURL
return
} catch (externalError: any) {
console.debug('Could not process external icon:', externalError.message)
}
}
}
image.value = undefined
}
watch(upstream, () => loadImage(), { immediate: true })
return image
}
function resizeImage(blob: Blob, size: number): Promise<string> {
return new Promise<string>((resolve) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = size
canvas.height = size
ctx?.drawImage(img, 0, 0, size, size)
const dataURL = canvas.toDataURL('image/png')
resolve(dataURL)
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(blob)
})
}

View File

@@ -1,17 +0,0 @@
import type { Archon } from '@modrinth/api-client'
import type { Project } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { $fetch } from 'ofetch'
import { computed, type ComputedRef } from 'vue'
// TODO: Remove and use v1
export function useServerProject(
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
) {
return useQuery({
queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]),
queryFn: () =>
$fetch<Project>(`https://api.modrinth.com/v2/project/${upstream.value!.project_id}`),
enabled: computed(() => !!upstream.value?.project_id),
})
}

View File

@@ -83,6 +83,7 @@ provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
openExternalUrl: (url) => window.open(url, '_blank'),
})
const { formatMessage } = useVIntl()

View File

@@ -281,7 +281,7 @@
"
>
<nuxt-link to="/hosting">
<ServerIcon aria-hidden="true" />
<ServerStackIcon aria-hidden="true" />
{{ formatMessage(navMenuMessages.hostAServer) }}
</nuxt-link>
</ButtonStyled>
@@ -463,7 +463,7 @@
<LibraryIcon aria-hidden="true" /> {{ formatMessage(commonMessages.collectionsLabel) }}
</template>
<template #servers>
<ServerIcon aria-hidden="true" /> {{ formatMessage(messages.myServers) }}
<ServerStackIcon aria-hidden="true" /> {{ formatMessage(messages.myServers) }}
</template>
<template #plus>
<ArrowBigUpDashIcon aria-hidden="true" />
@@ -722,6 +722,7 @@ import {
ScaleIcon,
SearchIcon,
ServerIcon,
ServerStackIcon,
SettingsIcon,
ShieldAlertIcon,
SunIcon,
@@ -742,6 +743,7 @@ import {
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import TeleportOverflowMenu from '@modrinth/ui/src/components/base/TeleportOverflowMenu.vue'
import { isAdmin, isStaff, UserBadge } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
@@ -760,7 +762,6 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
import OrganizationCreateModal from '~/components/ui/create/OrganizationCreateModal.vue'
import ProjectCreateModal from '~/components/ui/create/ProjectCreateModal.vue'
import ModrinthFooter from '~/components/ui/ModrinthFooter.vue'
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
import { getSignInRouteObj } from '~/composables/auth.js'
import { errors as generatedStateErrors } from '~/generated/state.json'
import { getProjectTypeMessage } from '~/utils/i18n-project-type.ts'

View File

@@ -1301,60 +1301,6 @@
"hosting-marketing.why.your-favorite-mods.description": {
"message": "Choose between Vanilla, Fabric, Forge, Quilt and NeoForge. If it's on Modrinth, it can run on your server."
},
"hosting.loader.failed-to-change-version": {
"message": "Failed to change modpack version"
},
"hosting.loader.failed-to-load-versions": {
"message": "Failed to load versions"
},
"hosting.loader.failed-to-reinstall": {
"message": "Failed to reinstall modpack"
},
"hosting.loader.failed-to-repair": {
"message": "Failed to repair server"
},
"hosting.loader.failed-to-reset-to-onboarding": {
"message": "Failed to reset server to onboarding"
},
"hosting.loader.failed-to-save-settings": {
"message": "Failed to save installation settings"
},
"hosting.loader.failed-to-unlink": {
"message": "Failed to unlink modpack"
},
"hosting.loader.loader-version": {
"message": "{loader, select, null {Loader} other {{loader}}} version"
},
"hosting.loader.repair-started-text": {
"message": "Your server installation has been repaired."
},
"hosting.loader.repair-started-title": {
"message": "Repair completed"
},
"hosting.loader.reset-server": {
"message": "Reset server"
},
"hosting.loader.reset-server-description": {
"message": "Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored."
},
"hosting.loader.reset-to-onboarding-button": {
"message": "Reset to onboarding"
},
"hosting.loader.reset-to-onboarding-modal-description": {
"message": "This will send the server back into onboarding so setup can be completed again. Are you sure you want to continue?"
},
"hosting.loader.reset-to-onboarding-modal-title": {
"message": "Reset to onboarding"
},
"hosting.loader.reset-to-onboarding-success-description": {
"message": "The server has been returned to the onboarding flow."
},
"hosting.loader.reset-to-onboarding-success-title": {
"message": "Server reset to onboarding"
},
"hosting.loader.support-options-title": {
"message": "Support options"
},
"hosting.plan.out-of-stock": {
"message": "Out of stock"
},
@@ -2831,18 +2777,6 @@
"search.filter.locked.server.sync": {
"message": "Sync with server"
},
"servers.busy.backup-creating": {
"message": "Backup creation in progress"
},
"servers.busy.backup-restoring": {
"message": "Backup restore in progress"
},
"servers.busy.installing": {
"message": "Server is installing"
},
"servers.busy.syncing-content": {
"message": "Content sync in progress"
},
"servers.notice.actions": {
"message": "Actions"
},
@@ -3221,6 +3155,12 @@
"settings.billing.interval.monthly": {
"message": "monthly"
},
"settings.billing.interval.quarter": {
"message": "quarter"
},
"settings.billing.interval.quarterly.adjective": {
"message": "quarterly"
},
"settings.billing.interval.year": {
"message": "year"
},
@@ -3336,7 +3276,7 @@
"message": "Error resubscribing"
},
"settings.billing.pyro.resubscribe.request-submitted.text": {
"message": "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made."
"message": "If the server is currently cancelled, it may take 10-15 minutes to set up the server."
},
"settings.billing.pyro.resubscribe.request-submitted.title": {
"message": "Resubscription request submitted"

View File

@@ -343,7 +343,7 @@ import { DEFAULT_CREDIT_EMAIL_MESSAGE } from '@modrinth/utils/utils.ts'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
import ModrinthServersIcon from '~/components/brand/ModrinthServersIcon.vue'
const { addNotification } = injectNotificationManager()
const { labrinth } = injectModrinthClient()
@@ -519,7 +519,7 @@ async function modifyCharge() {
})
addNotification({
title: 'Modifications made',
text: 'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
text: 'If the server is currently cancelled, it may take up to 10 minutes for another charge attempt to be made.',
type: 'success',
})
await refreshCharges()

View File

@@ -282,7 +282,7 @@ import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import AssignNoticeModal from '~/components/ui/servers/notice/AssignNoticeModal.vue'
import AssignNoticeModal from '~/components/ui/admin/AssignNoticeModal.vue'
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
const { addNotification } = injectNotificationManager()

View File

@@ -50,7 +50,6 @@ const selectableProjectTypes = [
<template>
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
<section class="normal-page__header mb-4 flex flex-col gap-4">
<div id="discover-header-prefix" class="empty:hidden"></div>
<NavTabs
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
:links="selectableProjectTypes"

File diff suppressed because it is too large Load Diff

View File

@@ -643,6 +643,7 @@ import {
injectModrinthClient,
injectNotificationManager,
IntlFormatted,
LoaderIcon,
ModrinthServersPurchaseModal,
useFormatPrice,
useVIntl,
@@ -652,7 +653,6 @@ import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import OptionGroup from '~/components/ui/OptionGroup.vue'
import LoaderIcon from '~/components/ui/servers/icons/LoaderIcon.vue'
import MedalPlanPromotion from '~/components/ui/servers/marketing/MedalPlanPromotion.vue'
import ServerPlanSelector from '~/components/ui/servers/marketing/ServerPlanSelector.vue'
import { products } from '~/generated/state.json'

File diff suppressed because it is too large Load Diff

View File

@@ -1,728 +1,13 @@
<template>
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
<Admonition v-if="backupBusyReason" type="warning" :header="backupBusyReason">
Your server is still accessible during this time.
</Admonition>
<Admonition
v-if="inspectingError && isConnected && !isWsAuthIncorrect"
data-pyro-servers-inspecting-error
type="critical"
:header="`${serverData?.name} shut down unexpectedly.`"
dismissible
@dismiss="clearError"
>
<template v-if="inspectingError.analysis.problems.length">
<p class="m-0 text-sm opacity-80">
We automatically analyzed the logs and found the following:
</p>
<div class="mt-2 flex flex-col gap-2">
<div
v-for="problem in inspectingError.analysis.problems"
:key="problem.message"
class="bg-raised-bg/30 rounded-xl px-3 py-2"
>
<p class="m-0 text-sm font-semibold">{{ problem.message }}</p>
<ul v-if="problem.solutions.length" class="m-0 ml-4 mt-1.5 flex flex-col gap-1">
<li
v-for="solution in problem.solutions"
:key="solution.message"
class="text-sm opacity-80"
>
{{ solution.message }}
</li>
</ul>
</div>
</div>
</template>
<template v-else-if="props.serverPowerState === 'crashed'">
<template v-if="props.powerStateDetails?.oom_killed">
The server stopped because it ran out of memory. There may be a memory leak caused by a
mod or plugin, or you may need to upgrade your Modrinth Server.
</template>
<template v-else-if="props.powerStateDetails?.exit_code !== undefined">
Your server exited with code {{ props.powerStateDetails.exit_code }}.
<template v-if="props.powerStateDetails.exit_code === 1">
There may be a mod or plugin causing the issue, or an issue with your server
configuration.
</template>
</template>
<template v-else> We could not determine the specific cause of the crash. </template>
<p class="m-0 mt-2">You can try restarting the server.</p>
</template>
<template v-else>
We could not find any specific problems, but you can try restarting the server.
</template>
</Admonition>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<ServerStats
: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 { TerminalSquareIcon } from '@modrinth/assets'
import {
Admonition,
injectModrinthClient,
injectModrinthServerContext,
useVIntl,
} from '@modrinth/ui'
import type { ServerState, Stats } from '@modrinth/utils'
import { injectModrinthServerContext, ServersManageOverviewPage } from '@modrinth/ui'
import PanelServerStatus from '~/components/ui/servers/PanelServerStatus.vue'
import PanelTerminal from '~/components/ui/servers/PanelTerminal.vue'
import ServerStats from '~/components/ui/servers/ServerStats.vue'
type ServerProps = {
isConnected: boolean
isWsAuthIncorrect: boolean
stats: Stats
serverPowerState: ServerState
powerStateDetails?: {
oom_killed?: boolean
exit_code?: number
}
isServerRunning: boolean
}
const props = defineProps<ServerProps>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { server: serverData, serverId, busyReasons } = injectModrinthServerContext()
const backupBusyReason = computed(() => {
const reason = busyReasons.value.find(
(r) =>
r.reason.id === 'servers.busy.backup-creating' ||
r.reason.id === 'servers.busy.backup-restoring',
)
return reason ? formatMessage(reason.reason) : null
})
interface ErrorData {
id: string
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 blob = await client.kyros.files_v0.downloadFile('/logs/latest.log')
const log = await blob.text()
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 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 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)
const { server } = injectModrinthServerContext()
useHead({
title: `Overview - ${serverData.value?.name ?? 'Server'} - Modrinth`,
title: computed(() => `Overview - ${server.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 (!props.isConnected || !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 {
client.archon.sockets.send(serverId, { 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>
<template>
<ServersManageOverviewPage />
</template>

View File

@@ -1,69 +0,0 @@
<template>
<div class="flex flex-col gap-4">
<ServerSidebar :route="route" :nav-links="navLinks" />
</div>
</template>
<script setup lang="ts">
import {
CardIcon,
InfoIcon,
ListIcon,
ModrinthIcon,
SettingsIcon,
TextQuoteIcon,
UserIcon,
VersionIcon,
WrenchIcon,
} from '@modrinth/assets'
import { injectModrinthServerContext } from '@modrinth/ui'
import { isAdmin as isUserAdmin, type User } from '@modrinth/utils'
import ServerSidebar from '~/components/ui/servers/ServerSidebar.vue'
const route = useRoute()
const serverId = route.params.id as string
const auth = await useAuth()
const { server } = injectModrinthServerContext()
useHead({
title: `Options - ${server.value?.name ?? 'Server'} - Modrinth`,
})
const ownerId = computed(() => server.value?.owner_id ?? 'Ghost')
const isOwner = computed(() => (auth.value?.user as User | null)?.id === ownerId.value)
const isAdmin = computed(() => isUserAdmin(auth.value?.user))
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`,
shown: server.value?.status !== 'installing',
},
{
icon: UserIcon,
label: 'Preferences',
href: `/hosting/manage/${serverId}/options/preferences`,
},
{
icon: CardIcon,
label: 'Billing',
href: `/settings/billing#server-${serverId}`,
external: true,
shown: isOwner.value,
},
{
icon: ModrinthIcon,
label: 'Admin Billing',
href: `/admin/billing/${ownerId.value}`,
external: true,
shown: isAdmin.value,
},
{ icon: InfoIcon, label: 'Info', href: `/hosting/manage/${serverId}/options/info` },
])
</script>

View File

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

View File

@@ -1,335 +0,0 @@
<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">
<StyledInput
id="server-name-field"
v-model="serverName"
wrapper-class="w-full md:w-[50%]"
:maxlength="48"
@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%]">
<StyledInput
id="server-subdomain"
v-model="serverSubdomain"
wrapper-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-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
:save="saveGeneral"
:reset="resetGeneral"
/>
</div>
</template>
<script setup lang="ts">
import { EditIcon, TransferIcon } from '@modrinth/assets'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
ServerIcon,
StyledInput,
} from '@modrinth/ui'
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
import { useQueryClient } from '@tanstack/vue-query'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { server, serverId, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const data = server
const serverName = ref(data.value?.name)
const serverSubdomain = ref(data.value?.net?.domain ?? '')
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5)
const isValidCharsSubdomain = computed(() => /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value))
const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidCharsSubdomain.value)
const icon = useState<string | undefined>(`server-icon-${serverId}`)
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 client.archon.servers_v0.updateName(serverId, serverName.value ?? '')
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
const result = await client.archon.servers_v0.checkSubdomainAvailability(
serverSubdomain.value,
)
const available = result.available
if (!available) {
addNotification({
type: 'error',
title: 'Subdomain not available',
text: 'The subdomain you entered is already in use.',
})
return
}
await client.archon.servers_v0.changeSubdomain(serverId, 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 queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
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 (icon.value) {
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
}
await client.kyros.files_v0.uploadFile('/server-icon.png', scaledFile).promise
await client.kyros.files_v0.uploadFile('/server-icon-original.png', file).promise
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-${serverId}`).value = 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 (icon.value) {
try {
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
useState(`server-icon-${serverId}`).value = undefined
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
addNotification({
type: 'success',
title: 'Server icon reset',
text: 'Your server icon was successfully reset.',
})
} catch (error) {
console.error('Error resetting icon:', error)
addNotification({
type: 'error',
title: 'Reset failed',
text: 'Failed to reset server icon.',
})
}
}
}
const onDragOver = (e: DragEvent) => {
e.preventDefault()
}
const onDragLeave = (e: DragEvent) => {
e.preventDefault()
}
const onDrop = (e: DragEvent) => {
e.preventDefault()
uploadFile(e)
}
const triggerFileInput = () => {
const input = document.createElement('input')
input.type = 'file'
input.id = 'server-icon-field'
input.accept = 'image/png,image/jpeg,image/gif,image/webp'
input.onchange = uploadFile
input.click()
}
</script>

View File

@@ -1,154 +0,0 @@
<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>
<a
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
class="!w-full sm:!w-auto"
:href="sftpUrl"
target="_blank"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</a>
</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,
injectModrinthServerContext,
injectNotificationManager,
} from '@modrinth/ui'
const { addNotification } = injectNotificationManager()
const { server: data, serverId } = injectModrinthServerContext()
const showPassword = ref(false)
const sftpUrl = computed(() => `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`)
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: serverId ?? 'Unknown' },
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
{ name: 'Kind', value: data.value?.upstream?.kind ?? data.value?.loader ?? 'Unknown' },
{ name: 'Project ID', value: data.value?.upstream?.project_id ?? 'Unknown' },
{ name: 'Version ID', value: data.value?.upstream?.version_id ?? 'Unknown' },
]
</script>

View File

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

View File

@@ -1,507 +0,0 @@
<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>
<StyledInput
id="new-allocation-name"
ref="newAllocationInput"
v-model="newAllocationName"
wrapper-class="w-full"
: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>
<StyledInput
id="edit-allocation-name"
ref="editAllocationInput"
v-model="newAllocationName"
wrapper-class="w-full"
: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="allocationsError"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<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">{{
allocationsError?.message ?? 'Unknown error'
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => refetchAllocations()">
<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>
<StyledInput
id="user-domain"
v-model="userDomain"
wrapper-class="w-full md:w-[50%]"
:maxlength="64"
: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-id="serverId"
: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,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
StyledInput,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
const { addNotification } = injectNotificationManager()
const { server, serverId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const isUpdating = ref(false)
const data = server
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 {
data: allocationsData,
error: allocationsError,
refetch: refetchAllocations,
} = useQuery({
queryKey: ['servers', 'allocations', serverId] as const,
queryFn: () => client.archon.servers_v0.getAllocations(serverId),
})
const allocations = allocationsData
const newAllocationModal = ref<typeof NewModal>()
const editAllocationModal = ref<typeof NewModal>()
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 client.archon.servers_v0.reserveAllocation(serverId, newAllocationName.value)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
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 client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
title: 'Allocation removed',
text: 'Your allocation has been removed.',
})
allocationToDelete.value = null
}
const editAllocation = async () => {
if (!newAllocationName.value) return
try {
await client.archon.servers_v0.updateAllocation(
serverId,
newAllocationPort.value,
newAllocationName.value,
)
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
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 result = await client.archon.servers_v0.checkSubdomainAvailability(serverSubdomain.value)
const available = result.available
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 client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await client.archon.servers_v0.updateAllocation(
serverId,
serverPrimaryPort.value,
newAllocationName.value,
)
}
await new Promise((resolve) => setTimeout(resolve, 500))
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server settings',
text: 'An error occurred while attempting to update your server settings.',
})
} finally {
isUpdating.value = false
}
}
const resetNetwork = () => {
serverSubdomain.value = data?.value?.net?.domain ?? ''
}
const dnsRecords = computed(() => {
const domain = userDomain.value === '' ? exampleDomain : userDomain.value
return [
{
type: 'A',
name: `${domain}`,
content: data.value?.net?.ip ?? '',
},
{
type: 'SRV',
name: `_minecraft._tcp.${domain}`,
content: `0 10 ${data.value?.net?.port} ${domain}`,
},
]
})
const exportDnsRecords = () => {
const records = dnsRecords.value.reduce(
(acc, record) => {
const type = record.type
if (!acc[type]) {
acc[type] = []
}
acc[type].push(record)
return acc
},
{} as Record<string, any[]>,
)
const text = Object.entries(records)
.map(([type, records]) => {
return `; ${type} Records\n${records.map((record) => `${record.name}. 1 IN ${record.type} ${record.content}${record.type === 'SRV' ? '.' : ''}`).join('\n')}\n`
})
.join('\n')
const blob = new Blob([text], { type: 'text/plain' })
const a = document.createElement('a')
a.href = window.URL.createObjectURL(blob)
a.download = `${userDomain.value}.txt`
a.click()
a.remove()
}
const copyText = (text: string) => {
navigator.clipboard.writeText(text)
addNotification({
type: 'success',
title: 'Text copied',
text: `${text} has been copied to your clipboard`,
})
}
</script>

View File

@@ -1,113 +0,0 @@
<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>
<Toggle
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="flex-none"
:disabled="prefConfig.implemented === false"
/>
</div>
</div>
</div>
<SaveBanner
:is-visible="hasUnsavedChanges"
:server-id="serverId"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
/>
</div>
</template>
<script setup lang="ts">
import { injectNotificationManager, Toggle } from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
const { addNotification } = injectNotificationManager()
const route = useNativeRoute()
const serverId = route.params.id as string
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,
},
} as const
type PreferenceKeys = keyof typeof preferences
type UserPreferences = {
[K in PreferenceKeys]: boolean
}
const defaultPreferences: UserPreferences = {
ramAsNumber: false,
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: 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>

View File

@@ -1,292 +0,0 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div v-if="propsData" class="flex h-full w-full flex-col justify-between gap-4 overflow-y-auto">
<Admonition
v-if="hasNoProperties"
type="warning"
body="Some expected properties are missing from your server.properties - this usually means the server hasn't completed its first startup yet."
/>
<div class="card flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Server properties</h2>
<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="w-full text-sm">
<label for="search-server-properties" class="sr-only"> Search server properties </label>
<StyledInput
id="search-server-properties"
v-model="searchInput"
wrapper-class="w-full"
type="search"
:icon="SearchIcon"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div
v-for="(_value, key) in filteredProperties"
:key="key"
class="flex flex-row flex-wrap items-center justify-between py-2"
>
<span :id="`property-label-${key}`">{{ formatPropertyName(key) }}</span>
<div
v-if="getPropertyDef(key).type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<Combobox
:id="`server-property-${key}`"
v-model="liveProperties[key]"
:name="formatPropertyName(key)"
:options="
(getPropertyDef(key) as DropdownPropertyDef).options.map((v) => ({
value: v,
label: formatPropertyName(v),
}))
"
:aria-labelledby="`property-label-${key}`"
:display-value="formatPropertyName(String(liveProperties[key] ?? 'Select...'))"
/>
</div>
<div v-else-if="getPropertyDef(key).type === 'toggle'" class="flex justify-end">
<Toggle
:id="`server-property-${key}`"
:model-value="liveProperties[key] === 'true'"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = $event ? 'true' : 'false'"
/>
</div>
<div v-else-if="getPropertyDef(key).type === 'number'" class="mt-2 w-full sm:w-[320px]">
<StyledInput
:id="`server-property-${key}`"
:model-value="liveProperties[key]"
type="number"
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
@update:model-value="liveProperties[key] = String($event)"
/>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<StyledInput
:id="`server-property-${key}`"
v-model="liveProperties[key]"
wrapper-class="w-full"
:aria-labelledby="`property-label-${key}`"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex h-full w-full items-center justify-center">
<SpinnerIcon class="animate-spin" />
</div>
<SaveBanner
:is-visible="hasUnsavedChanges"
:server-id="serverId"
:is-updating="isUpdating || busyReasons.length > 0"
restart
:save="() => saveProperties()"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { SearchIcon, SpinnerIcon } from '@modrinth/assets'
import {
Admonition,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
StyledInput,
Toggle,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import Fuse from 'fuse.js'
import { computed, ref, watch } from 'vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const queryClient = useQueryClient()
const searchInput = ref('')
type DropdownPropertyDef = { type: 'dropdown'; options: string[] }
type PropertyDef = { type: 'toggle' } | { type: 'number' } | { type: 'text' } | DropdownPropertyDef
const KNOWN_PROPERTIES: Record<string, PropertyDef> = {
allow_cheats: { type: 'toggle' },
allow_flight: { type: 'toggle' },
difficulty: { type: 'dropdown', options: ['peaceful', 'easy', 'normal', 'hard'] },
enforce_whitelist: { type: 'toggle' },
force_gamemode: { type: 'toggle' },
gamemode: { type: 'dropdown', options: ['survival', 'creative', 'adventure', 'spectator'] },
generate_structures: { type: 'toggle' },
generator_settings: { type: 'text' },
hardcore: { type: 'toggle' },
level_seed: { type: 'text' },
level_type: { type: 'text' },
max_players: { type: 'number' },
max_tick_time: { type: 'number' },
motd: { type: 'text' },
pause_when_empty_seconds: { type: 'number' },
player_idle_timeout: { type: 'number' },
require_resource_pack: { type: 'toggle' },
resource_pack: { type: 'text' },
resource_pack_id: { type: 'text' },
resource_pack_sha1: { type: 'text' },
simulation_distance: { type: 'number' },
spawn_protection: { type: 'number' },
sync_chunk_writes: { type: 'toggle' },
view_distance: { type: 'number' },
white_list: { type: 'toggle' },
}
function getPropertyDef(key: string): PropertyDef {
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
}
const queryKey = computed(() => ['servers', 'properties', 'v1', serverId, worldId.value])
const { data: propsData } = useQuery({
queryKey,
queryFn: () => client.archon.properties_v1.getProperties(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
function flattenProperties(data: Archon.Content.v1.PropertiesFields): Record<string, string> {
const result: Record<string, string> = {}
if (data.known) {
for (const [key, value] of Object.entries(data.known)) {
if (value != null) result[key] = value
}
}
if (data.custom) {
for (const [key, value] of Object.entries(data.custom)) {
if (value != null) result[key] = value
}
}
return result
}
const liveProperties = ref<Record<string, string>>({})
const originalProperties = ref<Record<string, string>>({})
function syncFormFromData() {
if (!propsData.value) return
const flat = flattenProperties(propsData.value)
liveProperties.value = { ...flat }
originalProperties.value = { ...flat }
}
const hasNoProperties = computed(() => Object.keys(liveProperties.value).length === 0)
const hasUnsavedChanges = computed(() =>
Object.keys(liveProperties.value).some(
(key) => liveProperties.value[key] !== originalProperties.value[key],
),
)
watch(
propsData,
(newData) => {
if (newData && !hasUnsavedChanges.value) {
syncFormFromData()
}
},
{ immediate: true },
)
watch(powerState, () => {
queryClient.invalidateQueries({ queryKey: queryKey.value })
})
function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
const known: Record<string, string> = {}
const custom: Record<string, string> = {}
for (const key of Object.keys(liveProperties.value)) {
if (liveProperties.value[key] === originalProperties.value[key]) continue
if (key in KNOWN_PROPERTIES) {
known[key] = liveProperties.value[key]
} else {
custom[key] = liveProperties.value[key]
}
}
const patch: Archon.Content.v1.PatchPropertiesFields = {}
if (Object.keys(known).length > 0) {
patch.known = known as Archon.Content.v1.KnownPropertiesFields
}
if (Object.keys(custom).length > 0) {
patch.custom = custom
}
return patch
}
const { mutate: saveProperties, isPending: isUpdating } = useMutation({
mutationFn: () =>
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: queryKey.value })
syncFormFromData()
addNotification({
type: 'success',
title: 'Server properties updated',
text: 'Your server properties were successfully changed.',
})
},
onError: (error) => {
addNotification({
type: 'error',
title: 'Failed to update server properties',
text: error instanceof Error ? error.message : 'An error occurred.',
})
},
})
function resetProperties() {
syncFormFromData()
}
const fuse = computed(() => {
const entries = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}))
return new Fuse(entries, { keys: ['key', 'value'], threshold: 0.2 })
})
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) return liveProperties.value
const results = fuse.value.search(searchInput.value)
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
})
function formatPropertyName(name: string): string {
return name
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
</script>

View File

@@ -1,287 +0,0 @@
<template>
<div class="relative h-full w-full">
<div class="flex h-full w-full flex-col gap-4">
<div
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
>
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="isStartupLoading || startupCommand === defaultStartupCommand"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Restore default command
</button>
</ButtonStyled>
</div>
<div class="relative">
<StyledInput
id="startup-command-field"
v-model="startupCommand"
multiline
resize="vertical"
input-class="min-h-[270px] font-[family-name:var(--mono-font)]"
:disabled="isStartupLoading"
/>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
</div>
</div>
</div>
<div class="card flex flex-col gap-8">
<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="relative max-w-xs">
<Combobox
:id="'java-version-field'"
v-model="javaVersion"
name="java-version"
:options="displayedJavaVersions"
:display-value="javaVersionLabel ?? 'Java Version'"
:disabled="isStartupLoading"
>
<template #dropdown-footer>
<button
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
@mousedown.prevent
@click="showAllVersions = !showAllVersions"
>
<EyeOffIcon v-if="showAllVersions" class="size-4" />
<EyeIcon v-else class="size-4" />
{{ showAllVersions ? 'Hide extra versions' : 'Show all versions' }}
</button>
</template>
</Combobox>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
</div>
</div>
</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>
<div class="relative max-w-xs">
<Combobox
:id="'runtime-field'"
v-model="jreVendor"
name="runtime"
:options="JRE_VENDORS"
:display-value="jreVendorLabel ?? 'Runtime'"
:disabled="isStartupLoading"
/>
<div
v-if="isStartupLoading"
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
>
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
</div>
</div>
</div>
</div>
</div>
</div>
<SaveBanner
:is-visible="!!hasUnsavedChanges"
:server-id="serverId"
:is-updating="isPending"
:save="() => saveStartup()"
:reset="resetStartup"
/>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { EyeIcon, EyeOffIcon, SpinnerIcon, UpdatedIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
StyledInput,
} from '@modrinth/ui'
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
const { addNotification } = injectNotificationManager()
const { server, serverId, worldId } = injectModrinthServerContext()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const startupQueryKey = computed(() => ['servers', 'startup', 'v1', serverId, worldId.value])
const { data: startupData, isLoading: isStartupLoading } = useQuery({
queryKey: startupQueryKey,
queryFn: () => client.archon.options_v1.getStartup(serverId, worldId.value!),
enabled: computed(() => worldId.value !== null),
})
const JAVA_VERSIONS = [
{ value: 8, label: 'Java 8' },
{ value: 11, label: 'Java 11' },
{ value: 17, label: 'Java 17' },
{ value: 21, label: 'Java 21' },
{ value: 25, label: 'Java 25' },
]
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
{ value: 'corretto', label: 'Corretto' },
{ value: 'temurin', label: 'Temurin' },
{ value: 'graal', label: 'GraalVM' },
]
// Saved state derived directly from query
const savedStartupCommand = computed(() => startupData.value?.startup_command ?? '')
const savedJavaVersion = computed(() => startupData.value?.java_version ?? undefined)
const savedJreVendor = computed(() => startupData.value?.jre_vendor ?? undefined)
const defaultStartupCommand = computed(
() => startupData.value?.original_invocation ?? savedStartupCommand.value,
)
// Local form state
const startupCommand = ref('')
const javaVersion = ref<number>()
const jreVendor = ref<Archon.Content.v1.JreVendor>()
// Display labels for comboboxes
const javaVersionLabel = computed(
() => JAVA_VERSIONS.find((v) => v.value === javaVersion.value)?.label,
)
const jreVendorLabel = computed(() => JRE_VENDORS.find((v) => v.value === jreVendor.value)?.label)
function syncFormFromData() {
startupCommand.value = savedStartupCommand.value
javaVersion.value = savedJavaVersion.value
jreVendor.value = savedJreVendor.value
}
watch(
startupData,
(newData, oldData) => {
if (newData && !oldData) {
syncFormFromData()
}
},
{ immediate: true },
)
const hasUnsavedChanges = computed(
() =>
startupCommand.value !== savedStartupCommand.value ||
javaVersion.value !== savedJavaVersion.value ||
jreVendor.value !== savedJreVendor.value,
)
// Java version filtering
const showAllVersions = ref(false)
type MinecraftReleaseVersion = {
major: number
minor: number
}
function parseMinecraftReleaseVersion(version: string): MinecraftReleaseVersion | null {
const [majorPart, minorPart] = version.split('.')
if (!majorPart || !minorPart) return null
const major = Number(majorPart)
const minor = Number(minorPart)
if (!Number.isInteger(major) || !Number.isInteger(minor)) return null
return { major, minor }
}
function filterJavaVersions(compatibleVersions: number[]) {
return JAVA_VERSIONS.filter((version) => compatibleVersions.includes(version.value))
}
const displayedJavaVersions = computed(() => {
if (showAllVersions.value) return JAVA_VERSIONS
const mcVersion = server.value?.mc_version ?? ''
if (!mcVersion) return JAVA_VERSIONS
const releaseVersion = parseMinecraftReleaseVersion(mcVersion)
if (!releaseVersion) return JAVA_VERSIONS
if (releaseVersion.major > 1) {
if (releaseVersion.major >= 26) {
return filterJavaVersions([25])
}
return JAVA_VERSIONS
}
if (releaseVersion.minor >= 20) return filterJavaVersions([21])
if (releaseVersion.minor >= 17) return filterJavaVersions([17, 21])
if (releaseVersion.minor >= 12) return filterJavaVersions([8, 11, 17, 21])
if (releaseVersion.minor >= 6) return filterJavaVersions([8, 11])
return filterJavaVersions([8])
})
// Save mutation
const { mutate: saveStartup, isPending } = useMutation({
mutationFn: () =>
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
startup_command: startupCommand.value || null,
java_version: javaVersion.value ?? null,
jre_vendor: jreVendor.value ?? null,
}),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: startupQueryKey.value })
syncFormFromData()
addNotification({
type: 'success',
title: 'Server settings updated',
text: 'Your server settings were successfully changed.',
})
},
onError: (error) => {
console.error(error)
addNotification({
type: 'error',
title: 'Failed to update server arguments',
text: 'Please try again later.',
})
},
})
function resetStartup() {
syncFormFromData()
}
function resetToDefault() {
startupCommand.value = defaultStartupCommand.value
}
</script>

View File

@@ -8,7 +8,7 @@ definePageMeta({
})
useHead({
title: 'Servers - Modrinth',
title: 'Hosting - Modrinth',
})
const config = useRuntimeConfig()
@@ -20,5 +20,6 @@ const generatedState = useGeneratedState()
:stripe-publishable-key="config.public.stripePublishableKey"
:site-url="config.public.siteUrl"
:products="generatedState.products || []"
class="max-w-[1280px] py-0"
/>
</template>

View File

@@ -1,5 +1,6 @@
<template>
<ServersUpgradeModalWrapper ref="upgradeModal" />
<ResubscribeModal ref="pyroResubscribeModal" @resubscribe="handlePyroResubscribeConfirm" />
<section class="universal-card experimental-styles-within">
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
@@ -284,6 +285,8 @@
v-if="subscription.serverInfo"
v-bind="subscription.serverInfo"
:pending-change="getPendingChange(subscription)"
:cancellation-date="getCancellationDate(subscription)"
:on-download-backup="getBackupDownloadForServer(subscription.serverInfo)"
/>
<div v-else class="w-fit">
<p>
@@ -514,15 +517,9 @@
"
color="green"
>
<button
@click="
resubscribePyro(
subscription.id,
$dayjs(getPyroCharge(subscription).due).isBefore($dayjs()),
)
"
>
{{ formatMessage(messages.resubscribe) }} <RightArrowIcon />
<button @click="openPyroResubscribeModal(subscription)">
{{ formatMessage(messages.resubscribe) }}
<RightArrowIcon />
</button>
</ButtonStyled>
</div>
@@ -709,21 +706,25 @@ import {
OverflowMenu,
paymentMethodMessages,
PurchaseModal,
ResubscribeModal,
ServerListing,
useFormatDateTime,
useFormatPrice,
useServerBackupDownload,
useVIntl,
} from '@modrinth/ui'
import { calculateSavings, getCurrency } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
import ModrinthServersIcon from '~/components/brand/ModrinthServersIcon.vue'
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
import { products } from '~/generated/state.json'
const { addNotification, handleError } = injectNotificationManager()
const client = injectModrinthClient()
const { getLatestBackupDownload } = useServerBackupDownload()
definePageMeta({
middleware: 'auth',
})
@@ -835,6 +836,14 @@ const messages = defineMessages({
id: 'settings.billing.interval.year',
defaultMessage: 'year',
},
intervalQuarter: {
id: 'settings.billing.interval.quarter',
defaultMessage: 'quarter',
},
intervalQuarterly: {
id: 'settings.billing.interval.quarterly.adjective',
defaultMessage: 'quarterly',
},
intervalMonthly: {
id: 'settings.billing.interval.monthly',
defaultMessage: 'monthly',
@@ -998,7 +1007,7 @@ const messages = defineMessages({
pyroResubscribeRequestSubmittedText: {
id: 'settings.billing.pyro.resubscribe.request-submitted.text',
defaultMessage:
'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
'If the server is currently cancelled, it may take 10-15 minutes to set up the server.',
},
pyroResubscribeSuccessText: {
id: 'settings.billing.pyro.resubscribe.success.text',
@@ -1015,15 +1024,20 @@ const messages = defineMessages({
})
function getIntervalNounLabel(interval) {
console.log(interval)
return interval === 'yearly'
? formatMessage(messages.intervalYear)
: formatMessage(messages.intervalMonth)
: interval === 'quarterly'
? formatMessage(messages.intervalQuarter)
: formatMessage(messages.intervalMonth)
}
function getIntervalAdjectiveLabel(interval) {
return interval === 'yearly'
? formatMessage(messages.intervalYearly)
: formatMessage(messages.intervalMonthly)
: interval === 'quarterly'
? formatMessage(messages.intervalQuarterly)
: formatMessage(messages.intervalMonthly)
}
const queryClient = useQueryClient()
@@ -1053,6 +1067,11 @@ const { data: serversData } = useQuery({
queryFn: () => client.archon.servers_v0.list(),
})
const { data: serverFullList } = useQuery({
queryKey: ['servers', 'v1'],
queryFn: () => client.archon.servers_v1.list(),
})
const midasProduct = ref(products.find((x) => x.metadata?.type === 'midas'))
const midasSubscription = computed(() =>
subscriptions.value?.find(
@@ -1082,16 +1101,38 @@ const pyroSubscriptions = computed(() => {
const pyroSubs = subscriptions.value?.filter((s) => s?.metadata?.type === 'pyro') || []
const servers = serversData.value?.servers || []
return pyroSubs.map((subscription) => {
const server = servers.find((s) => s.server_id === subscription.metadata.id)
return {
...subscription,
serverInfo: server,
}
})
return pyroSubs
.map((subscription) => {
const server = servers.find((s) => s.server_id === subscription.metadata.id)
const charge = getPyroCharge(subscription)
return {
...subscription,
serverInfo: {
...server,
isProvisioning:
subscription.status === 'unprovisioned' &&
(charge?.status === 'processing' || charge?.status === 'open'),
},
}
})
.filter((subscription) => {
// files expire 30 days after cancellation
const cancellationDate = getCancellationDate(subscription)
if (
!cancellationDate ||
subscription.serverInfo?.status !== 'suspended' ||
subscription.serverInfo?.suspension_reason !== 'cancelled'
)
return true
const cancellation = new Date(cancellationDate)
const thirtyDaysLater = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000)
return new Date() <= thirtyDaysLater
})
})
const midasPurchaseModal = ref()
const pyroResubscribeModal = ref()
const country = useUserCountry()
const price = computed(() =>
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
@@ -1201,13 +1242,20 @@ const getProductFromPriceId = (priceId) => {
return productsData.value.find((p) => p.prices?.some((x) => x.id === priceId))
}
const getPyroCharge = (subscription) => {
function getPyroCharge(subscription) {
if (!subscription || !charges.value) return null
return charges.value.find(
(charge) => charge.subscription_id === subscription.id && charge.status !== 'succeeded',
)
}
function getCancellationDate(subscription) {
const charge = getPyroCharge(subscription)
if (!charge) return null
if (charge.status === 'cancelled') return charge.due
return null
}
const getProductSize = (product) => {
if (!product || !product.metadata) return formatMessage(commonMessages.planUnknownLabel)
const ramSize = product.metadata.ram
@@ -1243,12 +1291,38 @@ const showPyroUpgradeModal = (subscription) => {
upgradeModal.value?.open(subscription?.metadata?.id)
}
const CHARGE_POLL_INTERVAL_MS = 20_000
const hasProvisioningSubscription = computed(() =>
pyroSubscriptions.value?.some((s) => s.serverInfo?.isProvisioning),
)
const { pause: pauseChargePoll, resume: resumeChargePoll } = useIntervalFn(
() => {
queryClient.invalidateQueries({ queryKey: ['billing', 'payments'] })
queryClient.invalidateQueries({ queryKey: ['billing', 'subscriptions'] })
},
CHARGE_POLL_INTERVAL_MS,
{ immediate: false },
)
watch(
hasProvisioningSubscription,
(isProvisioning) => {
if (isProvisioning) {
resumeChargePoll()
} else {
pauseChargePoll()
}
},
{ immediate: true },
)
const resubscribePyro = async (subscriptionId, wasSuspended) => {
try {
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
cancelled: false,
})
await refresh()
if (wasSuspended) {
addNotification({
title: formatMessage(messages.pyroResubscribeRequestSubmittedTitle),
@@ -1271,6 +1345,35 @@ const resubscribePyro = async (subscriptionId, wasSuspended) => {
}
}
function openPyroResubscribeModal(subscription) {
const charge = getPyroCharge(subscription)
const product = getPyroProduct(subscription)
const interval = charge?.subscription_interval || subscription?.interval
const productPrice = getProductPrice(product, interval)
pyroResubscribeModal.value?.show({
subscriptionId: subscription?.id ?? '',
wasSuspended: charge?.due ? new Date(charge.due).getTime() < Date.now() : false,
serverName: subscription?.serverInfo?.name ?? 'this server',
planName: `${getProductSize(product)} plan`,
ramGb: product?.metadata?.ram ? product.metadata.ram / 1024 : undefined,
storageGb: product?.metadata?.storage ? product.metadata.storage / 1024 : undefined,
sharedCpus: product?.metadata?.cpu ? product.metadata.cpu / 2 : undefined,
priceCents: charge?.amount ?? productPrice?.prices?.intervals?.[interval],
currencyCode: charge?.currency_code ?? productPrice?.currency_code,
interval,
nextChargeDate: charge?.due,
})
}
function handlePyroResubscribeConfirm({ subscriptionId, wasSuspended }) {
return resubscribePyro(subscriptionId, wasSuspended)
}
function getBackupDownloadForServer(serverInfo) {
return getLatestBackupDownload(serverInfo.server_id, serverFullList.value)
}
const refresh = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing'] }),

View File

@@ -13,6 +13,7 @@ export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
const authProvider: AuthProvider = {
session_token: sessionToken,
user,
isReady: ref(true),
requestSignIn: async (redirectPath: string) => {
await router.push({
path: '/auth/sign-in',

View File

@@ -7,6 +7,7 @@ export function setupPageContextProvider() {
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
openExternalUrl: (url) => window.open(url, '_blank'),
})
provideModalBehavior({
noblur: computed(() => !(cosmetics.value?.advancedRendering ?? true)),

View File

@@ -1,164 +1 @@
import { createGlobalState } from '@vueuse/core'
import { type Ref, shallowRef } from 'vue'
/**
* Maximum number of console output lines to store
* @type {number}
*/
const maxLines = 10000
const batchTimeout = 300 // ms
const initialBatchSize = 256
/**
* Provides a global console output state management system
* Allows adding, storing, and clearing console output with a maximum line limit
*
* @returns {Object} Console state management methods and reactive state
* @property {Ref<string[]>} consoleOutput - Reactive array of console output lines
* @property {function(string): void} addConsoleOutput - Method to add a new console output line
* @property {function(): void} clear - Method to clear all console output
*/
export const useModrinthServersConsole = createGlobalState(() => {
/**
* Reactive array storing console output lines
* @type {Ref<string[]>}
*/
const output: Ref<string[]> = shallowRef<string[]>([])
const searchQuery: Ref<string> = shallowRef('')
const filteredOutput: Ref<string[]> = shallowRef([])
let searchRegex: RegExp | null = null
let lineBuffer: string[] = []
let batchTimer: NodeJS.Timeout | null = null
let isProcessingInitialBatch = false
let refilterTimer: NodeJS.Timeout | null = null
const refilterTimeout = 100 // ms
const updateFilter = () => {
if (!searchQuery.value) {
filteredOutput.value = []
return
}
if (!searchRegex) {
searchRegex = new RegExp(searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
}
filteredOutput.value = output.value.filter((line) => searchRegex?.test(line) ?? false)
}
const scheduleRefilter = () => {
if (refilterTimer) clearTimeout(refilterTimer)
refilterTimer = setTimeout(updateFilter, refilterTimeout)
}
const flushBuffer = () => {
if (lineBuffer.length === 0) return
const processedLines = lineBuffer.flatMap((line) => line.split('\n').filter(Boolean))
if (isProcessingInitialBatch && processedLines.length >= initialBatchSize) {
isProcessingInitialBatch = false
output.value = processedLines.slice(-maxLines)
} else {
const newOutput = [...output.value, ...processedLines]
output.value = newOutput.slice(-maxLines)
}
lineBuffer = []
batchTimer = null
if (searchQuery.value) {
scheduleRefilter()
}
}
/**
* Adds a new output line to the console output
* Automatically removes the oldest line if max output is exceeded
*
* @param {string} line - The console output line to add
*/
const addLine = (line: string): void => {
lineBuffer.push(line)
if (!batchTimer) {
batchTimer = setTimeout(flushBuffer, batchTimeout)
}
}
/**
* Adds multiple output lines to the console output
* Automatically removes the oldest lines if max output is exceeded
*
* @param {string[]} lines - The console output lines to add
* @returns {void}
*/
const addLines = (lines: string[]): void => {
if (output.value.length === 0 && lines.length >= initialBatchSize) {
isProcessingInitialBatch = true
lineBuffer = lines
flushBuffer()
return
}
lineBuffer.push(...lines)
if (!batchTimer) {
batchTimer = setTimeout(flushBuffer, batchTimeout)
}
}
/**
* Sets the search query and filters the output based on the query
*
* @param {string} query - The search query
*/
const setSearchQuery = (query: string): void => {
searchQuery.value = query
searchRegex = null
updateFilter()
}
/**
* Clears all console output lines
*/
const clear = (): void => {
output.value = []
filteredOutput.value = []
searchQuery.value = ''
lineBuffer = []
isProcessingInitialBatch = false
if (batchTimer) {
clearTimeout(batchTimer)
batchTimer = null
}
if (refilterTimer) {
clearTimeout(refilterTimer)
refilterTimer = null
}
searchRegex = null
}
/**
* Finds the index of a line in the main output
*
* @param {string} line - The line to find
* @returns {number} The index of the line, or -1 if not found
*/
const findLineIndex = (line: string): number => {
return output.value.findIndex((l) => l === line)
}
return {
output,
searchQuery,
filteredOutput,
addLine,
addLines,
setSearchQuery,
clear,
findLineIndex,
}
})
export { useModrinthServersConsole } from '@modrinth/ui'