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

@@ -1,5 +1,12 @@
<script setup>
import { AuthFeature, PanelVersionFeature, TauriModrinthClient } from '@modrinth/api-client'
import {
AuthFeature,
NodeAuthFeature,
nodeAuthState,
PanelVersionFeature,
TauriModrinthClient,
VerboseLoggingFeature,
} from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
@@ -19,7 +26,7 @@ import {
RefreshCwIcon,
RestoreIcon,
RightArrowIcon,
ServerIcon,
ServerStackIcon,
SettingsIcon,
UserIcon,
WorldIcon,
@@ -80,6 +87,7 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import { config } from '@/config'
import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js'
import { debugAnalytics, initAnalytics, trackEvent } from '@/helpers/analytics'
import { check_reachable } from '@/helpers/auth.js'
@@ -127,17 +135,29 @@ const { addPopupNotification } = popupNotificationManager
const tauriApiClient = new TauriModrinthClient({
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
labrinthBaseUrl: config.labrinthBaseUrl,
archonBaseUrl: config.archonBaseUrl,
features: [
new NodeAuthFeature({
getAuth: () => nodeAuthState.getAuth?.() ?? null,
refreshAuth: async () => {
if (nodeAuthState.refreshAuth) {
await nodeAuthState.refreshAuth()
}
},
}),
new AuthFeature({
token: async () => (await getCreds()).session,
token: async () => (await getCreds())?.session,
}),
new PanelVersionFeature(),
new VerboseLoggingFeature(),
],
})
provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
openExternalUrl: (url) => openUrl(url),
})
provideModalBehavior({
noblur: computed(() => !themeStore.advancedRendering),
@@ -395,17 +415,30 @@ const handleClose = async () => {
}
const router = useRouter()
const route = useRoute()
const loading = useLoading()
loading.setEnabled(false)
loading.startLoading()
let suspensePending = false
router.beforeEach(() => {
suspensePending = false
loading.startLoading()
})
router.afterEach((to, from, failure) => {
trackEvent('PageView', {
path: to.path,
fromPath: from.path,
failed: failure,
})
setTimeout(() => {
if (!suspensePending) {
loading.stopLoading()
}
}, 100)
})
const route = useRoute()
const loading = useLoading()
loading.setEnabled(false)
const error = useError()
const errorModal = ref()
@@ -982,13 +1015,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
<WorldIcon />
</NavButton>
<NavButton
v-if="themeStore.featureFlags.servers_in_app"
v-tooltip.right="'Servers'"
to="/hosting/manage"
>
<ServerIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
to="/browse/modpack"
@@ -1003,6 +1029,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
<NavButton
v-tooltip.right="'Library'"
to="/library"
:is-primary="(r) => r.path === '/library' || r.path === '/library'"
:is-subpage="
() =>
route.path.startsWith('/instance') ||
@@ -1012,6 +1039,14 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<LibraryIcon />
</NavButton>
<NavButton
v-tooltip.right="'Modrinth Hosting'"
to="/hosting/manage"
:is-primary="(r) => r.path === '/hosting/manage' || r.path === '/hosting/manage/'"
:is-subpage="(r) => r.path.startsWith('/hosting/manage/') && r.path !== '/hosting/manage/'"
>
<ServerStackIcon />
</NavButton>
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
<suspense>
<QuickInstanceSwitcher />
@@ -1181,7 +1216,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
</transition>
<div
class="loading-indicator-container h-8 fixed z-50"
class="loading-indicator-container h-8 fixed z-50 pointer-events-none"
:style="{
top: 'calc(var(--top-bar-height))',
left: 'calc(var(--left-bar-width))',
@@ -1224,7 +1259,15 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</Admonition>
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense @pending="loading.startLoading()" @resolve="loading.stopLoading()">
<Suspense
@pending="
() => {
suspensePending = true
loading.startLoading()
}
"
@resolve="loading.stopLoading()"
>
<component :is="Component"></component>
</Suspense>
</template>
@@ -1250,11 +1293,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
<div class="py-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
<suspense>
<FriendsList
:credentials="credentials"
:sign-in="() => signIn()"
:refresh-credentials="fetchCredentials"
/>
<FriendsList :credentials="credentials" :sign-in="() => signIn()" />
</suspense>
</div>
<div v-if="news && news.length > 0" class="p-4 pr-1 flex flex-col items-center">
@@ -1287,8 +1326,8 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
</div>
</div>
<I18nDebugPanel />
<NotificationPanel has-sidebar />
<PopupNotificationPanel has-sidebar />
<NotificationPanel :has-sidebar="sidebarVisible" />
<PopupNotificationPanel :has-sidebar="sidebarVisible" />
<ErrorModal ref="errorModal" />
<MinecraftAuthErrorModal ref="minecraftAuthErrorModal" />
<ContentInstallModal

View File

@@ -45,6 +45,14 @@
color-scheme: dark;
--view-width: calc(100% - 5rem);
--expanded-view-width: calc(100% - 13rem);
--medal-promotion-bg: #000;
--medal-promotion-bg-orange: rgba(208, 246, 255, 0.25);
--medal-promotion-text-orange: #42abff;
--medal-promotion-bg-gradient: linear-gradient(
90deg,
rgba(66, 170, 255, 0.15),
rgba(0, 0, 0, 0) 100%
);
}
body {
@@ -77,12 +85,10 @@ body {
}
a {
color: var(--color-link);
color: inherit;
text-decoration: none;
&:hover {
text-decoration: none;
}
-webkit-font-smoothing: antialiased;
will-change: filter;
}
.badge {
@@ -174,4 +180,63 @@ img {
}
}
button,
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
}
@import '@modrinth/assets/omorphia.scss';
input {
border-radius: var(--size-rounded-sm);
box-sizing: border-box;
border: 2px solid transparent;
// safari iOS rounds inputs by default
// set the appearance to none to prevent this
appearance: none !important;
}
pre {
font-weight: var(--font-weight-regular);
}
input,
textarea {
background: var(--color-button-bg);
color: var(--color-text);
padding: 0.5rem 1rem;
font-weight: var(--font-weight-medium);
border: none;
outline: 2px solid transparent;
box-shadow:
var(--shadow-inset-sm),
0 0 0 0 transparent;
transition: box-shadow 0.1s ease-in-out;
min-height: 36px;
&:focus,
&:focus-visible {
box-shadow:
inset 0 0 0 transparent,
0 0 0 0.25rem var(--color-brand-shadow);
color: var(--color-button-text-active);
}
&:disabled,
&[disabled='true'] {
opacity: 0.6;
pointer-events: none;
cursor: not-allowed;
}
&:focus::placeholder {
opacity: 0.8;
}
&::placeholder {
color: var(--color-button-text);
opacity: 0.6;
}
}

View File

@@ -3,6 +3,7 @@
v-if="typeof to === 'string'"
:to="to"
v-bind="$attrs"
:active-class="isSubpage ? '' : undefined"
:class="{
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),

View File

@@ -1,142 +0,0 @@
<template>
<ProjectCard
:title="project.name"
:link="
() => {
emit('open')
$router.push({
path: `/project/${project.project_id ?? project.id}`,
query: { i: props.instance ? props.instance.path : undefined },
})
}
"
:author="{ name: project.author, link: `https://modrinth.com/user/${project.author}` }"
:icon-url="project.icon_url"
:summary="project.summary"
:tags="project.display_categories"
:all-tags="project.categories"
:downloads="project.downloads"
:followers="project.follows"
:date-updated="project.date_modified"
:banner="project.featured_gallery ?? undefined"
:color="project.color ?? undefined"
:environment="
['mod', 'modpack'].includes(projectType)
? {
clientSide: project.client_side?.[0],
serverSide: project.server_side?.[0],
}
: undefined
"
layout="list"
>
<template #actions>
<ButtonStyled color="brand" type="outlined">
<button
:disabled="installed || installing"
class="shrink-0 no-wrap"
@click.stop="install()"
>
<SpinnerIcon v-if="installing" class="animate-spin" />
<template v-else-if="!installed">
<DownloadIcon v-if="modpack || instance" />
<PlusIcon v-else />
</template>
<CheckIcon v-else />
{{
installing
? 'Installing'
: installed
? 'Installed'
: modpack || instance
? 'Install'
: 'Add to an instance'
}}
</button>
</ButtonStyled>
</template>
</ProjectCard>
</template>
<script setup>
import { CheckIcon, DownloadIcon, PlusIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { injectContentInstall } from '@/providers/content-install'
const { install: installVersion } = injectContentInstall()
dayjs.extend(relativeTime)
const { handleError } = injectNotificationManager()
const router = useRouter()
const props = defineProps({
backgroundImage: {
type: String,
default: null,
},
project: {
type: Object,
required: true,
},
instance: {
type: Object,
default: null,
},
featured: {
type: Boolean,
default: false,
},
installed: {
type: Boolean,
default: false,
},
projectType: {
type: String,
default: undefined,
},
activeLoader: {
type: String,
default: null,
},
activeGameVersion: {
type: String,
default: null,
},
})
const emit = defineEmits(['open', 'install'])
const installing = ref(false)
async function install() {
installing.value = true
await installVersion(
props.project.project_id ?? props.project.id,
null,
props.instance ? props.instance.path : null,
'SearchCard',
(versionId) => {
installing.value = false
if (versionId) {
emit('install', props.project.project_id ?? props.project.id)
}
},
(profile) => {
router.push(`/instance/${profile}`)
},
{
preferredLoader: props.activeLoader ?? undefined,
preferredGameVersion: props.activeGameVersion ?? undefined,
},
).catch(handleError)
}
const modpack = computed(() => props.project.project_types?.includes('modpack'))
</script>

View File

@@ -1,11 +1,9 @@
<script setup>
import { Button, injectNotificationManager } from '@modrinth/ui'
import { Button, injectNotificationManager, ProjectCard } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SearchCard from '@/components/ui/SearchCard.vue'
import { get_project, get_version } from '@/helpers/cache.js'
import { get_categories } from '@/helpers/tags.js'
import { get_project_v3, get_version } from '@/helpers/cache.js'
import { injectContentInstall } from '@/providers/content-install'
const { handleError } = injectNotificationManager()
@@ -14,26 +12,22 @@ const { install: installVersion } = injectContentInstall()
const confirmModal = ref(null)
const project = ref(null)
const version = ref(null)
const categories = ref(null)
const installing = ref(false)
defineExpose({
async show(event) {
if (event.event === 'InstallVersion') {
version.value = await get_version(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project(version.value.project_id, 'must_revalidate').catch(
project.value = await get_project_v3(version.value.project_id, 'must_revalidate').catch(
handleError,
)
} else {
project.value = await get_project(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project_v3(event.id, 'must_revalidate').catch(handleError)
version.value = await get_version(
project.value.versions[project.value.versions.length - 1],
'must_revalidate',
).catch(handleError)
}
categories.value = (await get_categories().catch(handleError)).filter(
(cat) => project.value.categories.includes(cat.name) && cat.project_type === 'mod',
)
confirmModal.value.show()
},
})
@@ -52,13 +46,22 @@ async function install() {
</script>
<template>
<ModalWrapper ref="confirmModal" :header="`Install ${project?.title}`">
<ModalWrapper ref="confirmModal" :header="`Install ${project?.name}`">
<div class="modal-body">
<SearchCard
:project="project"
<ProjectCard
:title="project.name"
:link="() => confirmModal.hide()"
:icon-url="project.icon_url"
:summary="project.summary"
:tags="project.display_categories"
:all-tags="project.categories"
:downloads="project.downloads"
:followers="project.follows"
:date-updated="project.date_modified"
:banner="project.featured_gallery ?? undefined"
:color="project.color ?? undefined"
layout="list"
class="project-card"
:categories="categories"
@open="confirmModal.hide()"
/>
<div class="button-row">
<div class="markdown-body">

View File

@@ -198,45 +198,48 @@ const messages = defineMessages({
<template>
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeProfile" />
<div class="block">
<div class="float-end ml-4 relative group">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="!border-4 group-hover:brightness-75"
:tint-by="instance.path"
no-shadow
/>
<div class="absolute top-0 right-0 m-2">
<div
class="p-2 m-0 text-primary flex items-center justify-center aspect-square bg-button-bg rounded-full border-button-border border-solid border-[1px] hovering-icon-shadow"
<div class="float-end ml-10 relative group w-fit">
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
>
<EditIcon aria-hidden="true" class="h-4 w-4 text-primary" />
</div>
<Avatar
:src="icon ? convertFileSrc(icon) : icon"
size="108px"
class="transition-[filter] group-hover:brightness-75"
:tint-by="instance.path"
no-shadow
/>
<div
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
>
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</div>
</div>
<label for="instance-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
<label for="instance-name" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.name) }}
</label>
<div class="flex">
@@ -249,76 +252,82 @@ const messages = defineMessages({
/>
</div>
<template v-if="instance.install_stage == 'installed'">
<div>
<h2
id="duplicate-instance-label"
class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block"
>
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.duplicateInstance) }}
</h2>
<p class="m-0 mb-2">
<ButtonStyled>
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
class="w-max !shadow-none"
@click="duplicateProfile"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
</ButtonStyled>
<p class="m-0">
{{ formatMessage(messages.duplicateInstanceDescription) }}
</p>
</div>
<ButtonStyled>
</template>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newCategoryInput"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
class="w-full max-w-[300px]"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit !shadow-none" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
</div>
</div>
<p class="m-0">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<ButtonStyled color="red">
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
@click="duplicateProfile"
aria-labelledby="delete-instance-label"
:disabled="removing"
class="w-fit !shadow-none"
@click="deleteConfirmModal.show()"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
</template>
<h2 class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.libraryGroups) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.libraryGroupsDescription) }}
</p>
<div class="flex flex-col gap-1">
<Checkbox
v-for="group in availableGroups"
:key="group"
:model-value="groups.includes(group)"
:label="group"
@click="toggleGroup(group)"
/>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newCategoryInput"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
@submit="() => addCategory"
/>
<ButtonStyled>
<button class="w-fit" @click="() => addCategory()">
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
</button>
</ButtonStyled>
</div>
<p class="m-0">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
</div>
<h2 id="delete-instance-label" class="m-0 mt-4 mb-1 text-lg font-extrabold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<p class="m-0 mb-2">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
<ButtonStyled color="red">
<button
aria-labelledby="delete-instance-label"
:disabled="removing"
@click="deleteConfirmModal.show()"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
</div>
</template>
<style scoped lang="scss">

View File

@@ -101,57 +101,57 @@ const messages = defineMessages({
<template>
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
<h2 class="m-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hooks) }}
</h2>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
<p class="m-0">
{{ formatMessage(messages.hooksDescription) }}
</p>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="mt-2" />
<h2 class="mt-2 mb-1 text-lg font-extrabold text-contrast">
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<StyledInput
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.preLaunchEnter)"
wrapper-class="w-full mt-2"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapper) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<StyledInput
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.wrapperEnter)"
wrapper-class="w-full mt-2"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast">
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExit) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
<StyledInput
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full mt-2"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
</div>
</template>

View File

@@ -111,10 +111,10 @@ const messages = defineMessages({
<template>
<div>
<h2 id="project-name" class="m-0 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="m-0 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2" />
<Checkbox v-model="overrideJavaInstall" label="Custom Java installation" class="mb-2.5" />
<template v-if="!overrideJavaInstall">
<div class="flex my-2 items-center gap-2 font-semibold">
<template v-if="javaInstall">
@@ -144,10 +144,10 @@ const messages = defineMessages({
</div>
</template>
<JavaSelector v-if="overrideJavaInstall || !javaInstall" v-model="javaInstall" />
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2" />
<Checkbox v-model="overrideMemorySettings" label="Custom memory allocation" class="mb-2.5" />
<Slider
id="max-memory"
v-model="memory.maximum"
@@ -159,7 +159,7 @@ const messages = defineMessages({
:snap-range="512"
unit="MB"
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox v-model="overrideJavaArgs" label="Custom java arguments" class="my-2" />
@@ -171,10 +171,10 @@ const messages = defineMessages({
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
<h2 id="project-name" class="mt-6 mb-2.5 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2" />
<Checkbox v-model="overrideEnvVars" label="Custom environment variables" class="mb-2.5" />
<StyledInput
id="env-vars"
v-model="envVars"

View File

@@ -94,14 +94,14 @@ const messages = defineMessages({
</script>
<template>
<div>
<div class="flex flex-col gap-6">
<Checkbox
v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)"
/>
<div class="mt-2 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreen) }}
</h2>
<p class="m-0">
@@ -120,9 +120,9 @@ const messages = defineMessages({
/>
</div>
<div class="mt-4 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.width) }}
</h2>
<p class="m-0">
@@ -139,9 +139,9 @@ const messages = defineMessages({
/>
</div>
<div class="mt-4 flex items-center gap-4 justify-between">
<div>
<h2 class="m-0 mb-1 text-lg font-extrabold text-contrast">
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.height) }}
</h2>
<p class="m-0">

View File

@@ -177,7 +177,7 @@ const messages = defineMessages({
>
<ModrinthIcon class="w-6 h-6" />
</button>
<div>
<div class="max-w-[200px]">
<p class="m-0">Modrinth App {{ version }}</p>
<p class="m-0">
<span v-if="osPlatform === 'macos'">macOS</span>

View File

@@ -21,7 +21,7 @@ watch(
)
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Color theme</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Color theme</h2>
<p class="m-0 mt-1">Select your preferred color theme for Modrinth App.</p>
<ThemeSelector
@@ -36,9 +36,9 @@ watch(
system-theme-color="system"
/>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Advanced rendering</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Advanced rendering</h2>
<p class="m-0 mt-1">
Enables advanced rendering such as blur effects that may cause performance issues without
hardware-accelerated rendering.
@@ -57,48 +57,48 @@ watch(
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Hide nametag</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Hide nametag</h2>
<p class="m-0 mt-1">Disables the nametag above your player on the skins page.</p>
</div>
<Toggle id="hide-nametag-skins-page" v-model="settings.hide_nametag_skins_page" />
</div>
<div v-if="os !== 'MacOS'" class="mt-4 flex items-center justify-between gap-4">
<div v-if="os !== 'MacOS'" class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Native decorations</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Native decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Default landing page</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Default landing page</h2>
<p class="m-0 mt-1">Change the page to which the launcher opens on.</p>
</div>
<Combobox
id="opening-page"
v-model="settings.default_page"
name="Opening page dropdown"
class="w-40"
class="max-w-40"
:options="['Home', 'Library'].map((v) => ({ value: v, label: v }))"
:display-value="settings.default_page ?? 'Select an option'"
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Jump back into worlds</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Jump back into worlds</h2>
<p class="m-0 mt-1">Includes recent worlds in the "Jump back in" section on the Home page.</p>
</div>
<Toggle
@@ -113,9 +113,9 @@ watch(
/>
</div>
<div class="mt-4 flex items-center justify-between">
<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Toggle sidebar</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Toggle sidebar</h2>
<p class="m-0 mt-1">Enables the ability to toggle the sidebar.</p>
</div>
<Toggle

View File

@@ -52,127 +52,135 @@ watch(
<template>
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Window size</h2>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Fullscreen</h3>
<p class="m-0 leading-tight">
Overwrites the options.txt file to start in full screen when launched.
</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Fullscreen</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Overwrites the options.txt file to start in full screen when launched.
</p>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Width</h3>
<p class="m-0 leading-tight">The width of the game window when launched.</p>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Width</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The width of the game window when launched.
</p>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
</div>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter width..."
/>
<div class="flex items-center justify-between gap-4">
<div class="flex flex-col gap-1">
<h3 class="m-0 text-lg font-semibold text-contrast">Height</h3>
<p class="m-0 leading-tight">The height of the game window when launched.</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter height..."
/>
</div>
</div>
<div class="flex items-center justify-between gap-4">
<div>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Height</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The height of the game window when launched.
</p>
<hr class="my-6 bg-button-border border-none h-[1px]" />
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Memory allocated</h2>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
</div>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
placeholder="Enter height..."
/>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Java arguments</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">Environmental variables</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
</div>
</div>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
<hr class="my-6 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Memory allocated</h2>
<p class="m-0 mt-1 leading-tight">The memory allocated to each instance when it is ran.</p>
<Slider
id="max-memory"
v-model="settings.memory.maximum"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Pre launch hook</h3>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran before the instance is launched.</p>
</div>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Java arguments</h2>
<StyledInput
id="java-args"
v-model="settings.launchArgs"
autocomplete="off"
type="text"
placeholder="Enter java arguments..."
wrapper-class="w-full"
/>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Wrapper hook</h3>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Wrapper command for launching Minecraft.</p>
</div>
<h2 class="mt-4 mb-2 text-lg font-extrabold text-contrast">Environmental variables</h2>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
placeholder="Enter environmental variables..."
wrapper-class="w-full"
/>
<hr class="mt-4 bg-button-border border-none h-[1px]" />
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Hooks</h2>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Pre launch</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran before the instance is launched.</p>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
placeholder="Enter pre-launch command..."
wrapper-class="w-full"
/>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Wrapper</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
Wrapper command for launching Minecraft.
</p>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
placeholder="Enter wrapper command..."
wrapper-class="w-full"
/>
<h3 class="mt-2 m-0 text-base font-extrabold text-primary">Post exit</h3>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">Ran after the game closes.</p>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
/>
<div class="flex flex-col gap-2.5">
<h3 class="m-0 text-lg font-semibold text-contrast">Post exit hook</h3>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
placeholder="Enter post-exit command..."
wrapper-class="w-full"
/>
<p class="m-0 leading-tight">Ran after the game closes.</p>
</div>
</div>
</div>
</template>

View File

@@ -25,26 +25,28 @@ watch(
)
</script>
<template>
<div v-for="option in options" :key="option" class="mt-4 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
</h2>
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent">
<button
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</button>
</ButtonStyled>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
<div class="flex flex-col gap-2.5 min-w-[600px]">
<div v-for="option in options" :key="option" class="flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast capitalize">
{{ option.replaceAll('_', ' ') }}
</h2>
</div>
<div class="flex items-center gap-2">
<ButtonStyled type="transparent">
<button
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
Reset to default
</button>
</ButtonStyled>
<Toggle
id="advanced-rendering"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
</div>
</div>
</div>
</template>

View File

@@ -21,15 +21,21 @@ async function updateJavaVersion(version) {
}
</script>
<template>
<div v-for="(javaVersion, index) in [25, 21, 17, 8]" :key="`java-${javaVersion}`">
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
<div class="flex flex-col gap-6">
<div
v-for="(javaVersion, index) in [25, 21, 17, 8]"
:key="`java-${javaVersion}`"
class="flex flex-col gap-2.5"
>
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
</h2>
<JavaSelector
:id="'java-selector-' + javaVersion"
v-model="javaVersions[javaVersion]"
:version="javaVersion"
@update:model-value="updateJavaVersion"
/>
</div>
</div>
</template>

View File

@@ -43,7 +43,7 @@ async function onLocaleChange(newLocale: string) {
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">Language</h2>
<h2 class="m-0 text-lg font-semibold text-contrast">Language</h2>
<Admonition type="warning" class="mt-2 mb-4">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}

View File

@@ -25,8 +25,8 @@ watch(
<template>
<div class="flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Personalized ads</h2>
<p class="m-0 text-sm">
<h2 class="m-0 text-lg font-semibold text-contrast">Personalized ads</h2>
<p class="m-0 mt-1 text-sm">
Modrinth's ad provider, Aditude, shows ads based on your preferences. By disabling this
option, you opt out and ads will no longer be shown based on your interests.
</p>
@@ -36,8 +36,8 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Telemetry</h2>
<p class="m-0 text-sm">
<h2 class="m-0 text-lg font-semibold text-contrast">Telemetry</h2>
<p class="m-0 mt-1 text-sm">
Modrinth collects anonymized analytics and usage data to improve our user experience and
customize your experience. By disabling this option, you opt out and your data will no
longer be collected.
@@ -48,8 +48,8 @@ watch(
<div class="mt-4 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-extrabold text-contrast">Discord RPC</h2>
<p class="m-0 text-sm">
<h2 class="m-0 text-lg font-semibold text-contrast">Discord RPC</h2>
<p class="m-0 mt-1 text-sm">
Manages the Discord Rich Presence integration. Disabling this will cause 'Modrinth' to no
longer show up as a game or app you are using on your Discord profile.
</p>

View File

@@ -62,67 +62,77 @@ async function findLauncherDir() {
</script>
<template>
<h2 class="m-0 text-lg font-extrabold text-contrast">App directory</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast">App directory</h2>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<Button class="ml-1.5" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</template>
</StyledInput>
<p class="m-0 leading-tight text-secondary">
The directory where the launcher stores all of its files. Changes will be applied after
restarting the launcher.
</p>
</div>
<div class="m-1 my-2">
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
wrapper-class="w-full"
>
<template #right>
<Button class="r-btn" @click="findLauncherDir">
<FolderSearchIcon />
</Button>
</template>
</StyledInput>
<div class="flex flex-col gap-2.5">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-semibold text-contrast">App cache</h2>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
<p class="m-0 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="m-0 text-lg font-semibold text-contrast mt-4">Maximum concurrent downloads</h2>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
</div>
<div class="flex flex-col gap-2.5">
<h2 class="mt-0 m-0 text-lg font-semibold text-contrast">Maximum concurrent writes</h2>
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
<p class="m-0 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
</div>
</div>
<div>
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
title="Are you sure you want to purge the cache?"
description="If you proceed, your entire cache will be purged. This may slow down the app temporarily."
:has-to-type="false"
proceed-label="Purge cache"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<h2 class="m-0 text-lg font-extrabold text-contrast">App cache</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The Modrinth app stores a cache of data to speed up loading. This can be purged to force the
app to reload data. This may slow down the app temporarily.
</p>
</div>
<button id="purge-cache" class="btn min-w-max" @click="$refs.purgeCacheConfirmModal.show()">
<TrashIcon />
Purge cache
</button>
<h2 class="m-0 text-lg font-extrabold text-contrast mt-4">Maximum concurrent downloads</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can download at the same time. Set this to a lower
value if you have a poor internet connection. (app restart required to take effect)
</p>
<Slider
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="10"
:step="1"
/>
<h2 class="mt-4 m-0 text-lg font-extrabold text-contrast">Maximum concurrent writes</h2>
<p class="m-0 mt-1 mb-2 leading-tight text-secondary">
The maximum amount of files the launcher can write to the disk at once. Set this to a lower
value if you are frequently getting I/O errors. (app restart required to take effect)
</p>
<Slider id="max-writes" v-model="settings.max_concurrent_writes" :min="1" :max="50" :step="1" />
</template>

View File

@@ -0,0 +1,92 @@
import { createConsoleState } from '@modrinth/ui'
import { clear_log_buffer, get_live_log_buffer, get_logs } from '@/helpers/logs'
type ConsoleState = ReturnType<typeof createConsoleState>
interface LogEntry {
filename: string
name?: string
log_type: string
stdout?: string
age?: number
live?: boolean
}
interface InstanceConsoleEntry {
liveConsole: ConsoleState
historicalConsole: ConsoleState
historicalCache: Map<string, string>
logList: LogEntry[] | null
}
const instances = new Map<string, InstanceConsoleEntry>()
function getOrCreate(profilePathId: string): InstanceConsoleEntry {
let entry = instances.get(profilePathId)
if (entry) return entry
entry = {
liveConsole: createConsoleState(),
historicalConsole: createConsoleState(),
historicalCache: new Map(),
logList: null,
}
instances.set(profilePathId, entry)
return entry
}
async function hydrate(profilePathId: string): Promise<void> {
const entry = getOrCreate(profilePathId)
if (entry.liveConsole.output.value.length > 0) return
const buffer = await get_live_log_buffer(profilePathId)
if (buffer) {
entry.liveConsole.addLegacyLog(buffer)
}
}
async function getHistoricalLogs(profilePathId: string, instancePath: string): Promise<LogEntry[]> {
const entry = getOrCreate(profilePathId)
if (entry.logList) return entry.logList
const logs: LogEntry[] = await get_logs(instancePath, false)
entry.logList = logs
for (const log of logs) {
if (log.stdout && log.stdout !== '') {
entry.historicalCache.set(log.filename, log.stdout)
}
}
return logs
}
function getHistoricalContent(profilePathId: string, filename: string): string | undefined {
return instances.get(profilePathId)?.historicalCache.get(filename)
}
function invalidate(profilePathId: string): void {
const entry = instances.get(profilePathId)
if (!entry) return
entry.historicalCache.clear()
entry.logList = null
}
async function destroy(profilePathId: string): Promise<void> {
instances.delete(profilePathId)
await clear_log_buffer(profilePathId).catch(() => {})
}
export function useInstanceConsole(profilePathId: string) {
const entry = getOrCreate(profilePathId)
return {
liveConsole: entry.liveConsole,
historicalConsole: entry.historicalConsole,
hydrate: () => hydrate(profilePathId),
getHistoricalLogs: (instancePath: string) => getHistoricalLogs(profilePathId, instancePath),
getHistoricalContent: (filename: string) => getHistoricalContent(profilePathId, filename),
invalidate: () => invalidate(profilePathId),
destroy: () => destroy(profilePathId),
}
}

View File

@@ -0,0 +1,18 @@
const trimTrailingSlash = (url: string) => url.replace(/\/$/, '')
const siteUrl = trimTrailingSlash(import.meta.env.MODRINTH_URL || 'https://modrinth.com')
const labrinthBaseUrl = trimTrailingSlash(
import.meta.env.MODRINTH_API_BASE_URL || 'https://api.modrinth.com',
)
const archonBaseUrl = trimTrailingSlash(
import.meta.env.MODRINTH_ARCHON_BASE_URL || 'https://archon.modrinth.com',
)
export const config = {
siteUrl,
stripePublishableKey:
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ||
'pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b',
labrinthBaseUrl,
archonBaseUrl,
}

View File

@@ -97,3 +97,23 @@ export async function warning_listener(callback) {
export async function friend_listener(callback) {
return await listen('friend', (event) => callback(event.payload))
}
/// Payload for the 'log' event
/*
LogPayload {
profile_path_id: string,
type: "log4j" | "legacy",
// log4j fields (when type === "log4j"):
timestamp_millis?: number,
logger_name?: string,
level?: string,
thread_name?: string,
message?: string,
throwable?: string,
// legacy fields (when type === "legacy"):
message?: string,
}
*/
export async function log_listener(callback) {
return await listen('log', (event) => callback(event.payload))
}

View File

@@ -63,3 +63,13 @@ export async function delete_logs(profilePath) {
export async function get_latest_log_cursor(profilePath, cursor) {
return await invoke('plugin:logs|logs_get_latest_log_cursor', { profilePath, cursor })
}
/// Get all buffered live log lines for a profile from the Rust ring buffer
export async function get_live_log_buffer(profilePath) {
return await invoke('plugin:logs|logs_get_live_log_buffer', { profilePath })
}
/// Clear the live log buffer for a profile on the Rust side
export async function clear_log_buffer(profilePath) {
return await invoke('plugin:logs|logs_clear_live_log_buffer', { profilePath })
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { config } from '../config'
const stripePublishableKey = (config.stripePublishableKey as string) || ''
const client = injectModrinthClient()
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
})
const resolvedProducts = computed<Labrinth.Billing.Internal.Product[]>(() => products.value ?? [])
</script>
<template>
<ServersManagePageIndex
:stripe-publishable-key="stripePublishableKey"
:products="resolvedProducts"
/>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
import { injectModrinthServerContext, ServersManageBackupsPage } from '@modrinth/ui'
const { isServerRunning } = injectModrinthServerContext()
</script>
<template>
<ServersManageBackupsPage :is-server-running="isServerRunning" />
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { ServersManageContentPage } from '@modrinth/ui'
</script>
<template>
<ServersManageContentPage />
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { ServersManageFilesPage } from '@modrinth/ui'
</script>
<template>
<ServersManageFilesPage />
</template>

View File

@@ -0,0 +1,113 @@
<template>
<div class="h-full w-full py-6">
<ServersManageRootLayout
:server-id="serverId"
:reload-page="() => router.go(0)"
:resolve-viewer="resolveViewer"
:show-copy-id-action="themeStore.devMode"
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
:navigate-to-servers="() => router.push('/hosting/manage')"
:browse-modpacks="
({ serverId: sid, worldId: wid, from }) => {
router.push({
path: '/browse/modpack',
query: { sid, wid: wid ?? undefined, from },
})
}
"
:browse-content="
({ serverId: sid, worldId: wid, type }) => {
router.push({
path: `/browse/${type}`,
query: { sid, wid: wid ?? undefined },
})
}
"
>
<template #default="{ onReinstall, onReinstallFailed }">
<RouterView v-slot="{ Component }">
<template v-if="Component">
<Suspense>
<component
:is="Component"
@reinstall="onReinstall"
@reinstall-failed="onReinstallFailed"
/>
<template #fallback>
<LoadingIndicator />
</template>
</Suspense>
</template>
</RouterView>
</template>
</ServersManageRootLayout>
</div>
</template>
<script setup lang="ts">
import type { Archon, Labrinth } from '@modrinth/api-client'
import { injectAuth, LoadingIndicator, ServersManageRootLayout } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { get_user } from '@/helpers/cache'
import { get as getCreds } from '@/helpers/mr_auth'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { useTheming } from '@/store/theme'
const route = useRoute()
const router = useRouter()
const auth = injectAuth()
const themeStore = useTheming()
const breadcrumbs = useBreadcrumbs()
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
})
const { data: serverData } = useQuery({
queryKey: computed(() => ['servers', 'detail', serverId.value]),
queryFn: () => null as unknown as Archon.Servers.v0.Server,
enabled: false,
})
watch(
serverData,
(server) => {
if (server?.name) {
breadcrumbs.setName('Server', server.name)
breadcrumbs.setContext({
name: server.name,
link: `/hosting/manage/${serverId.value}/content`,
})
}
},
{ immediate: true },
)
watch(
() => auth.user.value,
(user, previousUser) => {
if (user || !previousUser) return
if (route.path === '/hosting/manage' || route.path === '/hosting/manage/') return
void router.replace('/hosting/manage')
},
)
async function resolveViewer(): Promise<{ userId: string | null; userRole: string | null }> {
const credentials = await getCreds().catch(() => null)
if (!credentials?.user_id) {
return { userId: null, userRole: null }
}
const user = await get_user(credentials.user_id, 'bypass').catch(() => null)
const typedUser = user as Labrinth.Users.v2.User | null
return {
userId: credentials.user_id,
userRole: typedUser?.role ?? null,
}
}
</script>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
import { ServersManageOverviewPage } from '@modrinth/ui'
</script>
<template>
<ServersManageOverviewPage />
</template>

View File

@@ -0,0 +1,7 @@
import Backups from './Backups.vue'
import Content from './Content.vue'
import Files from './Files.vue'
import Index from './Index.vue'
import Overview from './Overview.vue'
export { Backups, Content, Files, Index, Overview }

View File

@@ -1,6 +1,7 @@
import Browse from './Browse.vue'
import Index from './Index.vue'
import Servers from './Servers.vue'
import Skins from './Skins.vue'
import Worlds from './Worlds.vue'
export { Browse, Index, Skins, Worlds }
export { Browse, Index, Servers, Skins, Worlds }

View File

@@ -114,9 +114,9 @@
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<button :disabled="stopping" @click="stopInstance('InstancePage')">
<StopCircleIcon />
Stop
{{ stopping ? 'Stopping...' : 'Stop' }}
</button>
</ButtonStyled>
<ButtonStyled
@@ -172,7 +172,7 @@
color="brand"
size="large"
>
<button disabled>Loading...</button>
<button disabled>Starting...</button>
</ButtonStyled>
<ButtonStyled circular size="large">
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
@@ -312,6 +312,7 @@ import ContextMenu from '@/components/ui/ContextMenu.vue'
import ExportModal from '@/components/ui/ExportModal.vue'
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3 } from '@/helpers/cache.js'
import { process_listener, profile_listener } from '@/helpers/events'
@@ -345,6 +346,7 @@ window.addEventListener('online', () => {
const instance = ref<GameInstance>()
const playing = ref(false)
const loading = ref(false)
const stopping = ref(false)
const exportModal = ref<InstanceType<typeof ExportModal>>()
const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
@@ -494,8 +496,10 @@ const startInstance = async (context: string) => {
}
const stopInstance = async (context: string) => {
playing.value = false
stopping.value = true
await kill(route.params.id as string).catch(handleError)
stopping.value = false
playing.value = false
if (!instance.value) return
trackEvent('InstanceStop', {
@@ -644,6 +648,11 @@ const timePlayedHumanized = computed(() => {
onUnmounted(() => {
unlistenProcesses()
unlistenProfiles()
const profilePath = route.params.id
if (profilePath) {
const { destroy } = useInstanceConsole(profilePath)
destroy()
}
})
</script>

View File

@@ -1,127 +1,24 @@
<template>
<Card class="log-card">
<div class="button-row">
<DropdownSelect
v-model="selectedLogIndex"
:default-value="0"
name="Log date"
:options="logs.map((_, index) => index)"
:display-name="(option) => logs[option]?.name"
:disabled="logs.length === 0"
/>
<div class="button-group">
<Button :disabled="!logs[selectedLogIndex]" @click="copyLog()">
<ClipboardCopyIcon v-if="!copied" />
<CheckIcon v-else />
{{ copied ? 'Copied' : 'Copy' }}
</Button>
<Button color="primary" :disabled="offline || !logs[selectedLogIndex]" @click="share">
<ShareIcon aria-hidden="true" />
Share
</Button>
<Button
v-if="logs[selectedLogIndex] && logs[selectedLogIndex].live === true"
@click="clearLiveLog()"
>
<TrashIcon aria-hidden="true" />
Clear
</Button>
<Button
v-else
:disabled="!logs[selectedLogIndex] || logs[selectedLogIndex].live === true"
color="danger"
@click="deleteLog()"
>
<TrashIcon aria-hidden="true" />
Delete
</Button>
</div>
</div>
<div class="button-row">
<StyledInput
id="text-filter"
v-model="searchFilter"
autocomplete="off"
:icon="SearchIcon"
type="search"
input-class="text-filter"
placeholder="Type to filter logs..."
/>
<div class="filter-group">
<Checkbox
v-for="level in levels"
:key="level.toLowerCase()"
v-model="levelFilters[level.toLowerCase()]"
class="filter-checkbox"
>
{{ level }}
</Checkbox>
</div>
</div>
<div class="log-text">
<RecycleScroller
v-slot="{ item }"
ref="logContainer"
class="scroller"
:items="displayProcessedLogs"
direction="vertical"
:item-size="20"
key-field="id"
buffer="200"
>
<div class="user no-wrap">
<span :style="{ color: item.prefixColor, 'font-weight': item.weight }">{{
item.prefix
}}</span>
<span :style="{ color: item.textColor }">{{ item.text }}</span>
</div>
</RecycleScroller>
</div>
<ShareModalWrapper
ref="shareModal"
header="Share Log"
share-title="Instance Log"
share-text="Check out this log from an instance on the Modrinth App"
:open-in-new-tab="false"
link
/>
</Card>
<div class="flex flex-col gap-4 h-full">
<ConsolePageLayout />
</div>
</template>
<script setup>
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import { CheckIcon, ClipboardCopyIcon, SearchIcon, ShareIcon, TrashIcon } from '@modrinth/assets'
import {
Button,
Card,
Checkbox,
DropdownSelect,
ConsolePageLayout,
injectModrinthClient,
injectNotificationManager,
StyledInput,
provideConsoleManager,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import isToday from 'dayjs/plugin/isToday'
import isYesterday from 'dayjs/plugin/isYesterday'
import { ofetch } from 'ofetch'
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
import { useRoute } from 'vue-router'
import { RecycleScroller } from 'vue-virtual-scroller'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { process_listener } from '@/helpers/events.js'
import {
delete_logs_by_filename,
get_latest_log_cursor,
get_logs,
get_output_by_filename,
} from '@/helpers/logs.js'
import { get_by_profile_path } from '@/helpers/process.js'
dayjs.extend(isToday)
dayjs.extend(isYesterday)
import { useInstanceConsole } from '@/composables/useInstanceConsole'
import { log_listener, process_listener } from '@/helpers/events.js'
import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js'
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const route = useRoute()
@@ -158,414 +55,179 @@ const props = defineProps({
},
})
const currentLiveLog = ref(null)
const currentLiveLogCursor = ref(0)
const emptyText = ['No live game detected.', 'Start your game to proceed.']
const profilePathId = computed(() => route.params.id)
const {
liveConsole,
historicalConsole,
hydrate,
getHistoricalLogs,
getHistoricalContent,
invalidate,
} = useInstanceConsole(profilePathId.value)
const logs = ref([])
await setLogs()
await hydrate()
const logsColored = true
function buildLogList(rawLogs) {
return [
{ name: 'Live Log', live: true },
...rawLogs
.filter(
(log) =>
log.filename !== 'latest_stdout.log' &&
log.filename !== 'latest_stdout' &&
log.filename !== 'launcher_log.txt' &&
log.stdout !== '' &&
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
)
.map((log) => ({
...log,
name: log.filename || 'Unknown',
})),
]
}
const logs = ref(buildLogList([]))
void getHistoricalLogs(props.instance.path)
.then((allLogs) => {
logs.value = buildLogList(allLogs)
})
.catch(handleError)
const selectedLogIndex = ref(0)
const copied = ref(false)
const logContainer = ref(null)
const interval = ref(null)
const userScrolled = ref(false)
const isAutoScrolling = ref(false)
const shareModal = ref(null)
const isLive = computed(() => selectedLogIndex.value === 0)
const levels = ['Comment', 'Error', 'Warn', 'Info', 'Debug', 'Trace']
const levelFilters = ref({})
levels.forEach((level) => {
levelFilters.value[level.toLowerCase()] = true
const filteredLogs = computed(() =>
props.playing ? logs.value.filter((l) => l.live || l.name !== 'latest.log') : logs.value,
)
const logSources = computed(() =>
filteredLogs.value.map((l, i) => ({
id: String(i),
name: l?.name ?? `Log ${i}`,
live: l?.live ?? false,
})),
)
const activeConsole = computed(() => (isLive.value ? liveConsole : historicalConsole))
const logLines = shallowRef(activeConsole.value.output.value)
watchEffect(() => {
logLines.value = activeConsole.value.output.value
triggerRef(logLines)
})
const searchFilter = ref('')
function shouldDisplay(processedLine) {
if (!processedLine.level) {
return true
}
const crashAnalysis = ref(null)
if (!levelFilters.value[processedLine.level.toLowerCase()]) {
return false
}
if (searchFilter.value !== '') {
if (!processedLine.text.toLowerCase().includes(searchFilter.value.toLowerCase())) {
return false
async function analyseForCrash() {
const lines = liveConsole.output.value
if (lines.length === 0) return
const content = lines.map((l) => l.text).join('\n')
try {
const data = await client.mclogs.insights_v1.analyse(content)
if (data.analysis?.problems?.length > 0) {
crashAnalysis.value = data
}
} catch {
// Crash analysis is best-effort
}
return true
}
// Selects from the processed logs which ones should be displayed (shouldDisplay)
// In addition, splits each line by \n. Each split line is given the same properties as the original line
const displayProcessedLogs = computed(() => {
return processedLogs.value.filter((l) => shouldDisplay(l))
const selectedLog = computed(() => filteredLogs.value[selectedLogIndex.value])
const deleteDisabled = computed(() => {
const log = selectedLog.value
if (!log || log.live) return true
return log.filename === 'latest.log' && props.playing
})
const processedLogs = computed(() => {
// split based on newline and timestamp lookahead
// (not just newline because of multiline messages)
const splitPattern = /\n(?=(?:#|\[\d\d:\d\d:\d\d\]))/
const lines = logs.value[selectedLogIndex.value]?.stdout.split(splitPattern) || []
const processed = []
let id = 0
for (let i = 0; i < lines.length; i++) {
// Then split off of \n.
// Lines that are not the first have prefix = null
const text = getLineText(lines[i])
const prefix = getLinePrefix(lines[i])
const prefixColor = getLineColor(lines[i], true)
const textColor = getLineColor(lines[i], false)
const weight = getLineWeight(lines[i])
const level = getLineLevel(lines[i])
text.split('\n').forEach((line, index) => {
processed.push({
id: id,
text: line,
prefix: index === 0 ? prefix : null,
prefixColor: prefixColor,
textColor: textColor,
weight: weight,
level: level,
})
id += 1
})
}
return processed
})
async function getLiveStdLog() {
if (route.params.id) {
const processes = await get_by_profile_path(route.params.id).catch(handleError)
let returnValue
if (processes.length === 0) {
returnValue = emptyText.join('\n')
} else {
const logCursor = await get_latest_log_cursor(
props.instance.path,
currentLiveLogCursor.value,
).catch(handleError)
if (logCursor.new_file) {
currentLiveLog.value = ''
}
currentLiveLog.value = currentLiveLog.value + logCursor.output
currentLiveLogCursor.value = logCursor.cursor
returnValue = currentLiveLog.value
}
return { name: 'Live Log', stdout: returnValue, live: true }
}
return null
}
async function getLogs() {
return (await get_logs(props.instance.path, true).catch(handleError))
.filter(
// filter out latest_stdout.log or anything without .log in it
(log) =>
log.filename !== 'latest_stdout.log' &&
log.filename !== 'latest_stdout' &&
log.stdout !== '' &&
(log.filename.includes('.log') || log.filename.endsWith('.txt')),
)
.map((log) => {
log.name = log.filename || 'Unknown'
log.stdout = 'Loading...'
return log
})
}
async function setLogs() {
const [liveStd, allLogs] = await Promise.all([getLiveStdLog(), getLogs()])
logs.value = [liveStd, ...allLogs]
}
const copyLog = () => {
if (logs.value.length > 0 && logs.value[selectedLogIndex.value]) {
navigator.clipboard.writeText(logs.value[selectedLogIndex.value].stdout)
copied.value = true
}
}
const share = async () => {
if (logs.value.length > 0 && logs.value[selectedLogIndex.value]) {
const url = await ofetch('https://api.mclo.gs/1/log', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `content=${encodeURIComponent(logs.value[selectedLogIndex.value].stdout)}`,
}).catch(handleError)
shareModal.value.show(url.url)
}
}
watch(selectedLogIndex, async (newIndex) => {
copied.value = false
userScrolled.value = false
if (logs.value.length > 1 && newIndex !== 0) {
logs.value[newIndex].stdout = 'Loading...'
logs.value[newIndex].stdout = await get_output_by_filename(
props.instance.path,
logs.value[newIndex].log_type,
logs.value[newIndex].filename,
).catch(handleError)
}
})
if (logs.value.length > 1 && !props.playing) {
selectedLogIndex.value = 1
} else {
async function deleteSelectedLog() {
const log = selectedLog.value
if (!log || log.live) return
await delete_logs_by_filename(props.instance.path, log.log_type, log.filename)
invalidate()
const freshLogs = await getHistoricalLogs(props.instance.path)
logs.value = buildLogList(freshLogs)
selectedLogIndex.value = 0
}
const deleteLog = async () => {
if (logs.value[selectedLogIndex.value] && selectedLogIndex.value !== 0) {
const deleteIndex = selectedLogIndex.value
selectedLogIndex.value = deleteIndex - 1
await delete_logs_by_filename(
props.instance.path,
logs.value[deleteIndex].log_type,
logs.value[deleteIndex].filename,
).catch(handleError)
await setLogs()
provideConsoleManager({
logLines,
logSources,
activeLogSourceIndex: selectedLogIndex,
showCommandInput: false,
loading: ref(false),
onClear: () => {
activeConsole.value.clear()
},
onDelete: deleteSelectedLog,
deleteDisabled,
deleteDisabledTooltip: 'Cannot delete latest.log while the instance is running',
shareDisabled: computed(() => props.offline),
emptyStateType: 'instance',
crashAnalysis,
onDismissCrash: () => {
crashAnalysis.value = null
},
})
watch(selectedLogIndex, async (newIndex) => {
if (newIndex === 0) return
const log = filteredLogs.value[newIndex]
if (!log) return
const cached = getHistoricalContent(log.filename)
if (cached) {
historicalConsole.clear()
historicalConsole.addLegacyLog(cached)
return
}
const output = await get_output_by_filename(
props.instance.path,
log.log_type,
log.filename,
).catch(handleError)
if (output) {
historicalConsole.clear()
historicalConsole.addLegacyLog(output)
}
})
selectedLogIndex.value = 0
if (!props.playing) {
void analyseForCrash()
}
const clearLiveLog = async () => {
currentLiveLog.value = ''
// does not reset cursor
}
const unlistenLog = await log_listener((payload) => {
if (payload.profile_path_id !== profilePathId.value) return
const isLineLevel = (text, level) => {
if ((text.includes('/INFO') || text.includes('[System] [CHAT]')) && level === 'info') {
return true
if (payload.type === 'log4j') {
liveConsole.addLog4jEvent(payload)
} else if (payload.type === 'legacy') {
liveConsole.addLegacyLog(payload.message)
}
if (text.includes('/WARN') && level === 'warn') {
return true
}
if (text.includes('/DEBUG') && level === 'debug') {
return true
}
if (text.includes('/TRACE') && level === 'trace') {
return true
}
const errorTriggers = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', ' at']
if (level === 'error') {
for (const trigger of errorTriggers) {
if (text.includes(trigger)) return true
}
}
if (text.trim()[0] === '#' && level === 'comment') {
return true
}
return false
}
const getLineWeight = (text) => {
if (
!logsColored ||
isLineLevel(text, 'info') ||
isLineLevel(text, 'debug') ||
isLineLevel(text, 'trace')
) {
return 'normal'
}
if (isLineLevel(text, 'error') || isLineLevel(text, 'warn')) {
return 'bold'
}
}
const getLineLevel = (text) => {
for (const level of levels) {
if (isLineLevel(text, level.toLowerCase())) {
return level
}
}
}
const getLineColor = (text, prefix) => {
if (isLineLevel(text, 'comment')) {
return 'var(--color-green)'
}
if (!logsColored || text.includes('[System] [CHAT]')) {
return 'var(--color-white)'
}
if (
(isLineLevel(text, 'info') || isLineLevel(text, 'debug') || isLineLevel(text, 'trace')) &&
prefix
) {
return 'var(--color-blue)'
}
if (isLineLevel(text, 'warn')) {
return 'var(--color-orange)'
}
if (isLineLevel(text, 'error')) {
return 'var(--color-red)'
}
}
const getLinePrefix = (text) => {
if (text.includes(']:')) {
return text.split(']:')[0] + ']:'
}
}
const getLineText = (text) => {
if (text.includes(']:')) {
if (text.split(']:').length > 2) {
return text.split(']:').slice(1).join(']:')
}
return text.split(']:')[1]
} else {
return text
}
}
function handleUserScroll() {
if (!isAutoScrolling.value) {
userScrolled.value = true
}
}
interval.value = setInterval(async () => {
if (logs.value.length > 0) {
logs.value[0] = await getLiveStdLog()
const scroll = logContainer.value.getScroll()
// Allow resetting of userScrolled if the user scrolls to the bottom
if (selectedLogIndex.value === 0) {
if (scroll.end >= logContainer.value.$el.scrollHeight - 10) userScrolled.value = false
if (!userScrolled.value) {
await nextTick()
isAutoScrolling.value = true
logContainer.value.scrollToItem(displayProcessedLogs.value.length - 1)
setTimeout(() => (isAutoScrolling.value = false), 50)
}
}
}
}, 250)
})
const unlistenProcesses = await process_listener(async (e) => {
if (e.profile_path_id !== profilePathId.value) return
if (e.event === 'launched') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
liveConsole.clear()
invalidate()
selectedLogIndex.value = 0
}
if (e.event === 'finished') {
currentLiveLog.value = ''
currentLiveLogCursor.value = 0
userScrolled.value = false
await setLogs()
selectedLogIndex.value = 1
invalidate()
const freshLogs = await getHistoricalLogs(props.instance.path)
logs.value = buildLogList(freshLogs)
void analyseForCrash()
}
})
onMounted(() => {
logContainer.value.$el.addEventListener('scroll', handleUserScroll)
})
onBeforeUnmount(() => {
logContainer.value.$el.removeEventListener('scroll', handleUserScroll)
})
onUnmounted(() => {
clearInterval(interval.value)
unlistenLog()
unlistenProcesses()
})
</script>
<style scoped lang="scss">
.log-card {
display: flex;
flex-direction: column;
gap: 1rem;
height: 100vh;
}
.button-row {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 0.5rem;
}
.button-group {
display: flex;
flex-direction: row;
gap: 0.5rem;
}
.log-text {
width: 100%;
height: 100%;
font-family: var(--mono-font);
background-color: var(--color-accent-contrast);
color: var(--color-contrast);
border-radius: var(--radius-lg);
padding-top: 1.5rem;
overflow-x: auto; /* Enables horizontal scrolling */
overflow-y: hidden; /* Disables vertical scrolling on this wrapper */
white-space: nowrap; /* Keeps content on a single line */
white-space: normal;
color-scheme: dark;
}
.filter-checkbox {
margin-bottom: 0.3rem;
font-size: 1rem;
svg {
display: flex;
align-self: center;
justify-self: center;
}
}
.filter-group {
display: flex;
padding: 0.6rem;
flex-direction: row;
overflow: auto;
gap: 0.5rem;
&::-webkit-scrollbar-track,
&::-webkit-scrollbar-thumb {
border-radius: 10px;
}
}
:deep(.vue-recycle-scroller__item-wrapper) {
overflow: visible; /* Enables horizontal scrolling */
}
:deep(.vue-recycle-scroller) {
&::-webkit-scrollbar-corner {
background-color: var(--color-bg);
border-radius: 0 0 10px 0;
}
}
.scroller {
height: 100%;
}
.user {
height: 32%;
padding: 0 1.5rem;
display: flex;
align-items: center;
user-select: text;
}
</style>

View File

@@ -357,9 +357,7 @@ const MAX_LINUX_REFRESHES = 3
const isLinux = platform() === 'linux'
const linuxRefreshCount = ref(0)
const protocolVersion = ref<ProtocolVersion | null>(
await get_profile_protocol_version(instance.value.path),
)
const protocolVersion = ref<ProtocolVersion | null>(null)
const managedServerName = ref<string | null>(null)
const managedServerAddress = ref<string | null>(null)
@@ -424,22 +422,27 @@ watch(
{ immediate: true },
)
const unlistenProfile = await profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
const [unlistenProfile, , resolvedProtocolVersion, resolvedGameVersions] = await Promise.all([
profile_listener(async (e: ProfileEvent) => {
if (e.profile_path_id !== instance.value.path) return
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
console.info(`Handling profile event '${e.event}' for profile: ${e.profile_path_id}`)
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
if (isLinux) linuxRefreshCount.value++
if (e.event === 'servers_updated') {
if (isLinux && linuxRefreshCount.value >= MAX_LINUX_REFRESHES) return
if (isLinux) linuxRefreshCount.value++
await refreshAllWorlds()
}
await refreshAllWorlds()
}
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
})
await handleDefaultProfileUpdateEvent(worlds.value, instance.value.path, e)
}),
refreshAllWorlds(),
get_profile_protocol_version(instance.value.path).catch(() => null),
get_game_versions().catch(() => [] as GameVersion[]),
])
await refreshAllWorlds()
protocolVersion.value = resolvedProtocolVersion
async function refreshServer(address: string) {
if (!serverData.value[address]) {
@@ -589,7 +592,7 @@ function worldsMatch(world: World, other: World | undefined) {
return false
}
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
const gameVersions = ref<GameVersion[]>(resolvedGameVersions)
const supportsServerQuickPlay = computed(() =>
hasServerQuickPlaySupport(gameVersions.value, instance.value.game_version),
)

View File

@@ -1,6 +1,6 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { type Ref, ref, watchEffect } from 'vue'
import { computed, type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
session?: string | null
@@ -13,10 +13,12 @@ export function setupAuthProvider(
) {
const sessionToken = ref<string | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const isReady = computed(() => credentials.value !== undefined)
const authProvider: AuthProvider = {
session_token: sessionToken,
user,
isReady,
requestSignIn,
}

View File

@@ -0,0 +1,393 @@
import type { Archon, Labrinth } from '@modrinth/api-client'
import {
createContext,
type CreationFlowContextValue,
injectModrinthClient,
injectNotificationManager,
} from '@modrinth/ui'
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
type ServerFlowFrom = 'onboarding' | 'reset-server'
type ServerInstallableType = 'modpack' | 'mod' | 'plugin' | 'datapack'
type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
installing?: boolean
installed?: boolean
}
interface ServerModpackSelectionRequest {
projectId: string
versionId: string
name: string
iconUrl?: string
}
interface ServerSetupModalHandle {
show: () => void | Promise<void>
hide: () => void
ctx?: CreationFlowContextValue | null
}
export interface ServerInstallContentContext {
serverIdQuery: ComputedRef<string | null>
worldIdQuery: ComputedRef<string | null>
browseFrom: ComputedRef<string | null>
serverFlowFrom: ComputedRef<ServerFlowFrom | null>
isFromWorlds: ComputedRef<boolean>
isServerContext: ComputedRef<boolean>
isSetupServerContext: ComputedRef<boolean>
effectiveServerWorldId: ComputedRef<string | null>
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
serverContentProjectIds: Ref<Set<string>>
serverBackUrl: ComputedRef<string>
serverBackLabel: ComputedRef<string>
serverBrowseHeading: ComputedRef<string>
initServerContext: () => Promise<void>
watchServerContextChanges: () => void
searchServerModpacks: (
query: string,
limit?: number,
) => Promise<Labrinth.Projects.v2.SearchResult>
getServerProjectVersions: (projectId: string) => Promise<{ id: string }[]>
enforceSetupModpackRoute: (currentProjectType: string | undefined) => void
installProjectToServer: (project: InstallableSearchResult) => Promise<boolean>
onServerFlowBack: () => void
handleServerModpackFlowCreate: (config: CreationFlowContextValue) => Promise<void>
markServerProjectInstalled: (id: string) => void
}
export const [injectServerInstallContent, provideServerInstallContent] =
createContext<ServerInstallContentContext>('Browse', 'serverInstallContent')
function readQueryString(value: unknown): string | null {
if (Array.isArray(value)) return value[0] ?? null
return typeof value === 'string' && value.length > 0 ? value : null
}
export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
}) {
const { serverSetupModalRef } = opts
const route = useRoute()
const router = useRouter()
const client = injectModrinthClient()
const { handleError } = injectNotificationManager()
const serverIdQuery = computed(() => readQueryString(route.query.sid))
const worldIdQuery = computed(() => readQueryString(route.query.wid))
const browseFrom = computed(() => readQueryString(route.query.from))
const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
? browseFrom.value
: null,
)
const isFromWorlds = computed(() => browseFrom.value === 'worlds')
const isServerContext = computed(() => !!serverIdQuery.value)
const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value)
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
const serverContentProjectIds = ref<Set<string>>(new Set())
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
const serverBackUrl = computed(() => {
const sid = serverIdQuery.value
if (!sid) return '/hosting/manage'
if (serverFlowFrom.value === 'onboarding') {
return `/hosting/manage/${sid}?resumeModal=setup-type`
}
if (serverFlowFrom.value === 'reset-server') {
return `/hosting/manage/${sid}?openSettings=installation`
}
return `/hosting/manage/${sid}/content`
})
const serverBackLabel = computed(() => {
if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
if (serverFlowFrom.value === 'reset-server') return 'Cancel reset'
return 'Back to server'
})
const serverBrowseHeading = computed(() => {
if (serverFlowFrom.value === 'reset-server') {
return 'Select modpack to install after reset'
}
return 'Install content to server'
})
async function resolveServerContextWorldId(serverId: string) {
try {
const server = await client.archon.servers_v1.get(serverId)
const activeWorld = server.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server.worlds[0]?.id ?? null
} catch (err) {
handleError(err as Error)
return null
}
}
async function refreshServerInstalledContent(serverId: string, worldId: string) {
try {
const content = await client.archon.content_v1.getAddons(serverId, worldId)
const ids = new Set(
(content.addons ?? [])
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
serverContentProjectIds.value = ids
} catch (err) {
handleError(err as Error)
}
}
async function initServerContext() {
const sid = serverIdQuery.value
if (!sid) return
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
}
let resolvedWorldId = effectiveServerWorldId.value
if (!resolvedWorldId) {
resolvedWorldId = await resolveServerContextWorldId(sid)
if (resolvedWorldId) {
serverContextWorldId.value = resolvedWorldId
}
}
if (resolvedWorldId) {
await refreshServerInstalledContent(sid, resolvedWorldId)
}
}
function watchServerContextChanges() {
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
if (!sid) {
serverContextServerData.value = null
serverContentProjectIds.value = new Set()
return
}
if (sid !== prevSid) {
serverContentProjectIds.value = new Set()
try {
serverContextServerData.value = await client.archon.servers_v0.get(sid)
} catch (err) {
handleError(err as Error)
}
}
if (wid && (sid !== prevSid || wid !== prevWid)) {
await refreshServerInstalledContent(sid, wid)
}
})
}
function normalizeLoader(loader: string) {
return loader.toLowerCase().replaceAll('_', '').replaceAll('-', '').replaceAll(' ', '')
}
function getCompatibleLoaders(loader: string) {
const normalized = normalizeLoader(loader)
if (!normalized) return new Set<string>()
if (normalized === 'paper' || normalized === 'purpur' || normalized === 'spigot') {
return new Set(['paper', 'purpur', 'spigot', 'bukkit'])
}
if (normalized === 'neoforge' || normalized === 'neo') {
return new Set(['neoforge', 'neo'])
}
return new Set([normalized])
}
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
router.replace({
path: '/browse/modpack',
query: route.query,
})
}
async function searchServerModpacks(query: string, limit: number = 10) {
return client.labrinth.projects_v2.search({
query: query || undefined,
new_filters:
'project_types = "modpack" AND (client_side = "optional" OR client_side = "required") AND server_side = "required"',
limit,
})
}
async function getServerProjectVersions(projectId: string) {
const versions = await client.labrinth.versions_v3.getProjectVersions(projectId)
return versions.map((version) => ({ id: version.id }))
}
async function openServerModpackInstallFlow(request: ServerModpackSelectionRequest) {
if (!serverIdQuery.value || !effectiveServerWorldId.value) {
throw new Error('Missing server context')
}
const modalInstance = serverSetupModalRef.value
if (!modalInstance) return
modalInstance.show()
await nextTick()
const ctx = modalInstance.ctx
if (!ctx) return
ctx.setupType.value = 'modpack'
ctx.modpackSelection.value = {
projectId: request.projectId,
versionId: request.versionId,
name: request.name,
iconUrl: request.iconUrl,
}
ctx.modal.value?.setStage('final-config')
}
function getCurrentServerInstallType(): ServerInstallableType {
const raw = Array.isArray(route.params.projectType)
? route.params.projectType[0]
: route.params.projectType
if (raw === 'modpack' || raw === 'mod' || raw === 'plugin' || raw === 'datapack') {
return raw
}
throw new Error('This content type cannot be installed to a server from browse.')
}
async function installProjectToServer(project: InstallableSearchResult) {
const contentType = getCurrentServerInstallType()
const sid = serverIdQuery.value
const wid = effectiveServerWorldId.value
if (!sid || !wid) {
throw new Error('No server world is available for install.')
}
if (contentType === 'modpack') {
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const versionId = versions[0]?.id ?? project.version_id
if (!versionId) {
throw new Error('No version found for this modpack')
}
await openServerModpackInstallFlow({
projectId: project.project_id,
versionId,
name: project.name,
iconUrl: project.icon_url ?? undefined,
})
return false
}
const versions = await client.labrinth.versions_v2.getProjectVersions(project.project_id, {
include_changelog: false,
})
const serverLoader = (serverContextServerData.value?.loader ?? '').toLowerCase()
const serverGameVersion = (serverContextServerData.value?.mc_version ?? '').trim()
const compatibleLoaders = getCompatibleLoaders(serverLoader)
const hasGameVersionMatch = (version: Labrinth.Versions.v2.Version) =>
!serverGameVersion || version.game_versions.includes(serverGameVersion)
const hasLoaderMatch = (version: Labrinth.Versions.v2.Version) => {
if (contentType === 'datapack') return true
if (compatibleLoaders.size === 0) return true
return version.loaders.some((loader) => compatibleLoaders.has(normalizeLoader(loader)))
}
let matchingVersion = versions.find(
(version) => hasGameVersionMatch(version) && hasLoaderMatch(version),
)
if (!matchingVersion) {
matchingVersion = versions.find((version) => hasLoaderMatch(version))
}
if (!matchingVersion) {
matchingVersion = versions.find((version) => hasGameVersionMatch(version))
}
if (!matchingVersion) {
matchingVersion = versions[0]
}
if (!matchingVersion) {
throw new Error('No installable version was found for this project.')
}
await client.archon.content_v1.addAddon(sid, wid, {
project_id: matchingVersion.project_id,
version_id: matchingVersion.id,
})
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, project.project_id])
return true
}
function onServerFlowBack() {
serverSetupModalRef.value?.hide()
}
async function handleServerModpackFlowCreate(config: CreationFlowContextValue) {
const sid = serverIdQuery.value
const wid = effectiveServerWorldId.value
if (!sid || !wid || !config.modpackSelection.value) {
config.loading.value = false
return
}
try {
await client.archon.content_v1.installContent(sid, wid, {
content_variant: 'modpack',
spec: {
platform: 'modrinth',
project_id: config.modpackSelection.value.projectId,
version_id: config.modpackSelection.value.versionId,
},
soft_override: false,
properties: config.buildProperties(),
} satisfies Archon.Content.v1.InstallWorldContent)
serverSetupModalRef.value?.hide()
if (serverFlowFrom.value === 'onboarding') {
await client.archon.servers_v1.endIntro(sid)
await router.push(`/hosting/manage/${sid}/content`)
return
}
await router.push(`/hosting/manage/${sid}?openSettings=installation`)
} catch (err) {
handleError(err as Error)
config.loading.value = false
}
}
function markServerProjectInstalled(id: string) {
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id])
}
return {
serverIdQuery,
worldIdQuery,
browseFrom,
serverFlowFrom,
isFromWorlds,
isServerContext,
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContentProjectIds,
serverBackUrl,
serverBackLabel,
serverBrowseHeading,
initServerContext,
watchServerContextChanges,
searchServerModpacks,
getServerProjectVersions,
enforceSetupModpackRoute,
installProjectToServer,
onServerFlowBack,
handleServerModpackFlowCreate,
markServerProjectInstalled,
}
}

View File

@@ -1,7 +1,7 @@
import { ServersManagePageIndex } from '@modrinth/ui'
import { createRouter, createWebHistory } from 'vue-router'
import * as Pages from '@/pages'
import * as Hosting from '@/pages/hosting/manage'
import * as Instance from '@/pages/instance'
import * as Library from '@/pages/library'
import * as Project from '@/pages/project'
@@ -31,11 +31,50 @@ export default new createRouter({
{
path: '/hosting/manage/',
name: 'Servers',
component: ServersManagePageIndex,
component: Pages.Servers,
meta: {
breadcrumb: [{ name: 'Servers' }],
},
},
{
path: '/hosting/manage/:id',
name: 'ServerManage',
component: Hosting.Index,
children: [
{
path: '',
name: 'ServerManageOverview',
component: Hosting.Overview,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'content',
name: 'ServerManageContent',
component: Hosting.Content,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'files',
name: 'ServerManageFiles',
component: Hosting.Files,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
{
path: 'backups',
name: 'ServerManageBackups',
component: Hosting.Backups,
meta: {
breadcrumb: [{ name: '?Server' }],
},
},
],
},
{
path: '/browse/:projectType',
name: 'Discover content',
@@ -88,6 +127,13 @@ export default new createRouter({
},
],
},
{
path: '/:projectType(mod|plugin|datapack|resourcepack|shader|modpack)/:id/:rest(.*)*',
redirect: (to) => {
const rest = to.params.rest ? `/${[].concat(to.params.rest).join('/')}` : ''
return `/project/${to.params.id}${rest}${to.hash}`
},
},
{
path: '/project/:id',
name: 'Project',
@@ -202,7 +248,8 @@ export default new createRouter({
],
linkActiveClass: 'router-link-active',
linkExactActiveClass: 'router-link-exact-active',
scrollBehavior() {
scrollBehavior(to, from) {
if (to.path === from.path) return
// Sometimes Vue's scroll behavior is not working as expected, so we need to manually scroll to top (especially on Linux)
document.querySelector('.app-viewport')?.scrollTo(0, 0)
return {

View File

@@ -5,7 +5,6 @@ export const DEFAULT_FEATURE_FLAGS = {
page_path: false,
worlds_tab: false,
worlds_in_home: true,
servers_in_app: false,
server_project_qa: false,
i18n_debug: false,
}

View File

@@ -3,7 +3,7 @@
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
@@ -16,6 +16,8 @@
"strict": true,
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
}

View File

@@ -1,4 +1,5 @@
import vue from '@vitejs/plugin-vue'
import { existsSync, readFileSync } from 'fs'
import { resolve } from 'path'
import { defineConfig } from 'vite'
import svgLoader from 'vite-svg-loader'
@@ -6,6 +7,23 @@ import svgLoader from 'vite-svg-loader'
import tauriConf from '../app/tauri.conf.json'
const projectRootDir = resolve(__dirname)
const appLibEnvDir = resolve(projectRootDir, '../../packages/app-lib')
// Load .env from app-lib manually instead of using Vite's envDir, which would auto-load .env.local and override values
const envFilePath = resolve(appLibEnvDir, '.env')
if (existsSync(envFilePath)) {
for (const line of readFileSync(envFilePath, 'utf-8').split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#')) continue
const eqIndex = trimmed.indexOf('=')
if (eqIndex === -1) continue
const key = trimmed.slice(0, eqIndex)
const value = trimmed.slice(eqIndex + 1)
if (!(key in process.env)) {
process.env[key] = value
}
}
}
// https://vitejs.dev/config/
export default defineConfig({
@@ -68,7 +86,7 @@ export default defineConfig({
},
// to make use of `TAURI_ENV_DEBUG` and other env variables
// https://v2.tauri.app/reference/environment-variables/#tauri-cli-hook-commands
envPrefix: ['VITE_', 'TAURI_'],
envPrefix: ['VITE_', 'TAURI_', 'MODRINTH_'],
build: {
rolldownOptions: {
onwarn(warning, defaultHandler) {

View File

@@ -89,6 +89,8 @@ fn main() {
"logs_delete_logs",
"logs_delete_logs_by_filename",
"logs_get_latest_log_cursor",
"logs_get_live_log_buffer",
"logs_clear_live_log_buffer",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,

View File

@@ -22,7 +22,12 @@
{
"identifier": "http:default",
"allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
"allow": [
{ "url": "https://modrinth.com/*" },
{ "url": "https://*.modrinth.com/*" },
{ "url": "https://*.nodes.modrinth.com/*" },
{ "url": "https://api.mclo.gs/*" }
]
},
"dialog:allow-save",

View File

@@ -21,6 +21,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
logs_delete_logs,
logs_delete_logs_by_filename,
logs_get_latest_log_cursor,
logs_get_live_log_buffer,
logs_clear_live_log_buffer,
])
.build()
}
@@ -83,3 +85,18 @@ pub async fn logs_get_latest_log_cursor(
) -> Result<LatestLogCursor> {
Ok(logs::get_latest_log_cursor(profile_path, cursor).await?)
}
/// Get all buffered live log lines for a profile
#[tauri::command]
pub async fn logs_get_live_log_buffer(
profile_path: &str,
) -> Result<CensoredString> {
Ok(logs::get_live_log_buffer(profile_path).await?)
}
/// Clear the live log buffer for a profile
#[tauri::command]
pub async fn logs_clear_live_log_buffer(profile_path: &str) -> Result<()> {
logs::clear_live_log_buffer(profile_path);
Ok(())
}

View File

@@ -7,6 +7,7 @@
use native_dialog::{DialogBuilder, MessageLevel};
use std::env;
use tauri::{Listener, Manager};
use tauri_plugin_fs::FsExt;
use theseus::prelude::*;
mod api;
@@ -35,6 +36,8 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
.allow_directory(state.directories.caches_dir(), true)?;
app.asset_protocol_scope()
.allow_directory(state.directories.caches_dir().join("icons"), true)?;
app.fs_scope()
.allow_directory(state.directories.profiles_dir(), true)?;
Ok(())
}

View File

@@ -87,12 +87,12 @@
"capabilities": ["ads", "core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net 'self' data: blob:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com wss://*.ts.net 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com https://tally.so/widgets/embed.js 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ 'self'",
"script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://tally.so/widgets/embed.js 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com 'self'",
"media-src": "https://*.githubusercontent.com"
}
}

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>

Some files were not shown because too many files have changed in this diff Show More