Pyro Integration (#2503)

* fix

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor(fileitem): optimize

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore(fileitem): fixed width timestamp

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(fileitem): allow editing json5/jsonc

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: motd pt 1, auto backups scaffolding, editing navbar changes

* feat: fancy sidebar animations

* fix: files

* fix: files pt2

* fix: faulty name validation disallowing spaces in file names

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: fileitem props

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: upload files not refreshing files list

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(imgviewer): handle invalid/empty images

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: return of the sticky files header

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: prevent servericon from shrinking

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: wtf were we thinking with this anyway

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: further mobile optimization

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: propagate margin

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: truncation fixes

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: track navbar with sentinel

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(files): a11y

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: improve inspector styles

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: console preformance improvements, decrease blur

* feat(mobile): new server header

* fix: linting

* fix: useless z indeces

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: adjust file filter names

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(files): true breadcrumbs

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(marketing): make custom responsive

* fix(marketing): mobile file manager card

* feat: trackable navtabs

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: oh no

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: smartly truncate

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(terminal): z-indexes

* fix: autofocus more inputs

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: color

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: adjust copy

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: backup modal usability improvements

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: padding

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: title

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(content): update banner mobile support

* fix: server listing icons

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: ignore clicks in server listing for labels

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(mobile): backup card

* fix(backups): make plural conditional

* fix: debounce file item selectitem

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: lint

Signed-off-by: Evan Song <theevansong@gmail.com>

* stuff

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: temp sidebar fix until i can be smart

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: explictly set button type in file modals

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: properly sort backups

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: add getautobackup method to pyroservers

Signed-off-by: Evan Song <theevansong@gmail.com>

* choer: update autobackup params

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: update autobackup methods (REALLY GUYS)

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: implement autobackups

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: implement backup-while-running preference

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: make server labels a component

* feat: implement 'All details' modal

* fix(mobile): server manage page

* feat(files): mobile compatible

* fix(info labels): wrap

* chore(inspector): clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(backup settings): swap + and -

* fix(manage): new -> plans instead of modal

* feat: more small mobile fixes

* fix(auto backup modal): manual input validation

* fix(file browse navbar): home margin

* feat(purchase modal): mobile support

* fix(marketing): faded line alignments

* feat: add servers to mobile nav

* feat(network): dns record fixes

* feat: make all settings work on mobile

* fix(loader settings): modpack mobile

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(marketing): add 'Manage your servers' button

* fix(marketing): only check servers if logged in

* fix(network): allocation edit & delete button

* fix(backups): use UiServersTeleportOverflowMenu

* chore: linting

* chore: but here comes the sentence case

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(marketing): make buttons consistent

* lint

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(loader): prevent multiline version names in dropdown

Signed-off-by: Evan Song <theevansong@gmail.com>

* lint

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: copy

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: sentence case

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: linting

* chore: rename dumbass preference key

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: rewrite power action buttons

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: robust download logic

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(loader mobile): modpack dropdown width

* fix: sentence case

* fix(save & 'working on it'): look good on mobile

* fix(TeleportDropdown): width

* fix(inspecting error): mobile

* fix: show action button dropdown when installing

* fix(navtabs): temp fix for mobile scrolling issue

* fix(install error): mobile compatible

* chore: just remove tracking

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: cleanup

* fix: broken svg clr in checkbox when using experimental styles

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: adjust vanilla icon

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: adjust loader props

Signed-off-by: Evan Song <theevansong@gmail.com>

* revert changes to serversidebar

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: server properties flicker

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(backups): plural

* fix: cases where the telepoverflow would clash with viewport edge

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(backups): auto-backups label

* fix(network): titlecase

* feat(fileitem): new rename icon

* fix(properties): wiki proper noun

* fix: disable motd for the time being

* chore: adjust wording for power conifmration

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: "external" to billing

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: icon

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: add EULA checkbox

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* me and bro deciding which case rules to enforce

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(sftp): copy address & username, launch tooltip

* feat(files): better move

* chore: attempt to mitigate excessive stack depth type

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(loader): prevent versions 1.2.4 and below

* feat(dns table): placeholder improvements

* feat(pyroServer): error handling

* fix: intrinsic size on loader icon

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: adjust wording

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: sentence case

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: adjust wording

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: types

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: "implemented" key in preference

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat(connection lost): redesign

* feat(connection error): make icon orange

* fix: cleanup

* chore(connection lost): redesign pt 2

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: OOOOHHH MY GOD

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: implement capacity api on marketing

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: update createdat backup type

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: all of backups

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: update backup types

Signed-off-by: Evan Song <theevansong@gmail.com>

* refactor: backups pt 2

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: comically small icons

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: align designs

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: hide ram graph if ram as bytes enabled

Signed-off-by: Evan Song <theevansong@gmail.com>

* base add content page

* Fix conflict

* feat(content): mobile-compatible header, sticky

* fix(marketing): md instead of sm for custom

* fix: compiler macro warning

Signed-off-by: Evan Song <theevansong@gmail.com>

* again

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: loader type error

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: default uptime seconds prop

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: hydration errors on server listing

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: move custom URL to general

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: indiviudally checkj capacities

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: falsey

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: missing prop

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: Derive On That Thang

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: adjust gap

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: add default name for backups

* fix: the backup number should PROBABLY be computed lol

* fix(backups): truncate text, mobile fixes

* fix(loader): modpack mobile fix

* feat(plans): add vcpus

* fix(backup modal): blank by default, maxlength

* fix(subdomain): separate length & valid chars

* feat: mrpack installs functionality (untested), forbidden handling, backups grammar

* feat(content): make responsive on mobile

* fix: disable plan buttons separately

* fix(backup modal): update name max length

* fix(purchase): wrapping on eula, eula link

* fix: move skeleton

* fix(server mobile header): truncation

* fix(server header): proper alignment

* Finish content page fixes

* fix: who up rinthing

Signed-off-by: Evan Song <theevansong@gmail.com>

* wip

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(staging & email banner): z-index

* feat: make eula tickbox more visible

* fix: move "powered by pyro" below buttons on hero

* fix: oops sorry ellie, also updated the main screenshot

* feat: update content screenshot

* fix: content page card should hide image on lg

* feat: hide total storage for now

* fix: terminal card now uses terminal icon

* fix(marketing): make medium plan card border solid

* feat: modloader card, move pyro BACK below buttons, beta release pill

* fix: spinning logo should be behind hero

* feat: surgically remove the hero's massive forehead

* feat(marketing): mobile UI screenshot

* fix(hero): z-index goes over mobile nav

* fix: consistent borders, files breakpoints

* chore: update turbo

* chore: adjust hero sizing

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: mention region restrictions

* chore: double check if we are at capcity

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: measure twice cut once

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: bro cut twice and measured once 💀

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(marketing): login first

* fix: out of capacity text when logged out

* fix(slider): reset some values for frontend

* feat: wip hero section

Signed-off-by: Evan Song <theevansong@gmail.com>

* New navigation to support the new products (#2879)

* Nav

* oops extra file

* feat: mrpack uploading with existing modpack, fix: choose modpack duplicate

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: update features section

Signed-off-by: Evan Song <theevansong@gmail.com>

* Nav adjustments

* fix: server manager empty state clashing with loading state

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: query param hard

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: do not count uptime if crashed

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: grammar

Signed-off-by: Evan Song <theevansong@gmail.com>

* hide hero img on lg breakpoints

* Make plugins a plug

* chore: prep for buffered text selection terminal

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix: marketing responsive stuff, n fixes

* fix hoverable prop

* fix: edit mod spacing

* fix: type error for display name in dropdown

Signed-off-by: Evan Song <theevansong@gmail.com>

* feat: custom plans

* fix: no more console.log

* fix: properly linked prop label

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix(install hero mobile): padding

* fix: prevent x overflow on servers page

Signed-off-by: Evan Song <theevansong@gmail.com>

* fix lint oh ym fucking god yal

Signed-off-by: Evan Song <theevansong@gmail.com>

* Migrate modpack install to search

* fix(custom plan): warning icon variable

* fix: loading probally and modal loader things

* fix(marketing): login icon colours

* fix(marketing): responsiveness

* fix(marketing): responsiveness v2

* fix: sync button for icon tm

* fix(marketing): responsiveness v3

* fix: hero image

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: clean

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: switch to cdn links

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: switch to cdn links

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: switch to cdn links

Signed-off-by: Evan Song <theevansong@gmail.com>

* chore: switch to cdn links

Signed-off-by: Evan Song <theevansong@gmail.com>

* Remove prod override

---------

Signed-off-by: Evan Song <theevansong@gmail.com>
Co-authored-by: Evan Song <theevansong@gmail.com>
Co-authored-by: TheWander02 <48934424+thewander02@users.noreply.github.com>
Co-authored-by: he3als <65787561+he3als@users.noreply.github.com>
Co-authored-by: Evan Song <52982404+ferothefox@users.noreply.github.com>
Co-authored-by: Lio <git@lio.cat>
Co-authored-by: Jai A <jaiagr+gpg@pm.me>
Co-authored-by: not-nullptr <needhelpwithrift@gmail.com>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: Prospector <prospectordev@gmail.com>
Co-authored-by: sticks <tanner@teamhydra.dev>
This commit is contained in:
Elizabeth
2024-11-02 23:14:00 -05:00
committed by GitHub
parent f165665a35
commit 185dd47668
126 changed files with 19390 additions and 432 deletions
@@ -0,0 +1,12 @@
<template>
<div class="universal-card">
<p>You can manage your server's billing from Settings > Billing and subscriptions.</p>
<ButtonStyled>
<NuxtLink to="/settings/billing">Go to Billing</NuxtLink>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
</script>
@@ -0,0 +1,322 @@
<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> Change the name of your server. This name is only visible on Modrinth.</span>
</label>
<div class="flex flex-col gap-2">
<input
id="server-name-field"
v-model="serverName"
class="w-full md:w-[50%]"
maxlength="48"
minlength="1"
@keyup.enter="!serverName && saveGeneral"
/>
<span v-if="!serverName" class="text-sm text-rose-400">
Server name must be at least 1 character long.
</span>
<span v-if="!isValidServerName" class="text-sm text-rose-400">
Server name can contain any character.
</span>
</div>
</div>
<!-- WIP - disable for now
<div class="card flex flex-col gap-4">
<label for="server-motd-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server MOTD</span>
<span>
The message of the day is the message that players see when they log in to the server.
</span>
</label>
<UiServersMOTDEditor :server="props.server" />
</div>
-->
<div class="card flex flex-col gap-4">
<label for="server-subdomain" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Custom URL</span>
<span> Your friends can connect to your server using this URL. </span>
</label>
<div class="flex w-full items-center gap-2 md:w-[60%]">
<input
id="server-subdomain"
v-model="serverSubdomain"
class="h-[50%] w-[63%]"
maxlength="32"
@keyup.enter="saveGeneral"
/>
.modrinth.gg
</div>
<div 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 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>
Change your server's icon. Changes 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-[6rem] 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>
<img
v-if="icon"
no-shadow
alt="Server Icon"
class="h-[6rem] w-[6rem] rounded-xl"
:src="icon"
/>
<img
v-else
no-shadow
alt="Server Icon"
class="h-[6rem] w-[6rem] rounded-xl"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
</div>
<ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
<TransferIcon class="h-6 w-6" />
<span>Sync icon</span>
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<UiServersPyroLoading v-else />
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
:server="props.server"
:is-updating="isUpdating"
:save="saveGeneral"
:reset="resetGeneral"
/>
</div>
</template>
<script setup lang="ts">
import { EditIcon, TransferIcon } from "@modrinth/assets";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const data = computed(() => props.server.general);
const serverName = ref(data.value?.name);
const serverSubdomain = ref(data.value?.net?.domain ?? "");
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5);
const isValidCharsSubdomain = computed(() => /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value));
const isValidSubdomain = computed(
() => isValidLengthSubdomain.value && isValidCharsSubdomain.value,
);
const icon = computed(() => data.value?.image);
const isUpdating = ref(false);
const hasUnsavedChanges = computed(
() =>
(serverName.value && serverName.value !== data.value?.name) ||
serverSubdomain.value !== data.value?.net?.domain,
);
const isValidServerName = computed(() => (serverName.value?.length ?? 0) > 0);
watch(serverName, (oldValue) => {
if (!isValidServerName.value) {
serverName.value = oldValue;
}
});
const saveGeneral = async () => {
if (!isValidServerName.value || !isValidSubdomain.value) return;
try {
isUpdating.value = true;
if (serverName.value !== data.value?.name) {
await data.value?.updateName(serverName.value ?? "");
}
if (serverSubdomain.value !== data.value?.net?.domain) {
try {
// type shit backend makes me do
const response = await props.server.network?.checkSubdomainAvailability(
serverSubdomain.value,
);
if (response === undefined) {
throw new Error("Failed to check subdomain availability");
}
if (typeof response === "object" && response !== null && "available" in response) {
const typedResponse = response as { available: boolean };
if (!typedResponse.available) {
addNotification({
group: "serverOptions",
type: "error",
title: "Subdomain not available",
text: "The subdomain you entered is already in use.",
});
return;
}
} else {
throw new Error("Invalid response format from availability check");
}
await props.server.network?.changeSubdomain(serverSubdomain.value);
} catch (error) {
console.error("Error checking subdomain availability:", error);
addNotification({
group: "serverOptions",
type: "error",
title: "Error checking availability",
text: "Failed to verify if the subdomain is available.",
});
return;
}
}
await new Promise((resolve) => setTimeout(resolve, 500));
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
title: "Server settings updated",
text: "Your server settings were successfully changed.",
});
} catch (error) {
console.error(error);
addNotification({
group: "serverOptions",
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];
// down scale the image to 64x64
const scaledFile = await new Promise<File>((resolve, reject) => {
if (!file) {
addNotification({
group: "serverOptions",
type: "error",
title: "No file selected",
text: "Please select a file to upload.",
});
reject(new Error("No file selected"));
return;
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
// turn the downscaled image back to a png file
canvas.toBlob((blob) => {
if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" });
resolve(data);
} else {
reject(new Error("Canvas toBlob failed"));
}
}, "image/png");
};
img.onerror = reject;
});
if (!file) return;
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
}
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
await props.server.fs?.uploadFile("/server-icon-original.png", file);
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
title: "Server icon updated",
text: "Your server icon was successfully changed.",
});
};
const resetIcon = async () => {
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
await new Promise((resolve) => setTimeout(resolve, 2000));
await reloadNuxtApp();
addNotification({
group: "serverOptions",
type: "success",
title: "Server icon reset",
text: "Your server icon was successfully reset.",
});
}
};
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>
@@ -0,0 +1,156 @@
<template>
<div class="h-full w-full gap-2 overflow-y-auto">
<div class="card">
<div class="flex flex-col gap-4">
<div class="flex flex-col justify-between gap-4 sm:flex-row">
<label class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">SFTP</span>
<span> SFTP allows you to access your server's files from outside of Modrinth. </span>
</label>
<ButtonStyled>
<button
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
class="!w-full sm:!w-auto"
@click="openSftp"
>
<ExternalIcon class="h-5 w-5" />
Launch SFTP
</button>
</ButtonStyled>
</div>
<div
class="flex w-full flex-row justify-between gap-2 rounded-xl bg-table-alternateRow p-4"
>
<div class="flex flex-col gap-2">
<span class="cursor-pointer font-bold text-contrast">
{{ data?.sftp_host }}
</span>
<span class="text-xs uppercase 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 p-4"
>
<div class="flex 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 uppercase 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 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 uppercase 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">
<UiCopyCode :text="property.value" />
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { CopyIcon, ExternalIcon, EyeIcon, EyeOffIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const route = useNativeRoute();
const serverId = route.params.id as string;
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const data = computed(() => props.server.general);
const showPassword = ref(false);
const openSftp = () => {
const sftpUrl = `sftp://${data.value?.sftp_username}@${data.value?.sftp_host}`;
window.open(sftpUrl, "_blank");
};
const togglePassword = () => {
showPassword.value = !showPassword.value;
};
const copyToClipboard = (name: string, textToCopy?: string) => {
navigator.clipboard.writeText(textToCopy || "");
addNotification({
type: "success",
title: `${name} copied to clipboard!`,
});
};
const properties = [
{ name: "Server ID", value: serverId ?? "Unknown" },
{ name: "Node", value: data.value?.datacenter ?? "Unknown" },
{ name: "Kind", value: data.value?.upstream?.kind ?? data.value?.loader ?? "Unknown" },
{ name: "Project ID", value: data.value?.upstream?.project_id ?? "Unknown" },
{ name: "Version ID", value: data.value?.upstream?.version_id ?? "Unknown" },
];
</script>
@@ -0,0 +1,607 @@
<template>
<NewModal ref="editModal" header="Select modpack">
<UiServersProjectSelect type="modpack" @select="reinstallNew" />
</NewModal>
<NewModal
ref="versionSelectModal"
:header="isSecondPhase ? 'Confirm reinstallation' : 'Select version'"
@hide="onHide"
@show="onShow"
>
<div class="flex flex-col gap-4 md:w-[600px]">
<p
:style="{
lineHeight: isSecondPhase ? '1.5' : undefined,
marginBottom: isSecondPhase ? '-12px' : '0',
marginTop: isSecondPhase ? '-4px' : '-2px',
}"
>
{{
isSecondPhase
? "This will reinstall your server and erase all data. You may want to back up your server before proceeding. Are you sure you want to continue?"
: "Choose the version of Minecraft you want to use for this server."
}}
</p>
<div v-if="!isSecondPhase" class="flex flex-col gap-2">
<UiServersTeleportDropdownMenu
v-model="selectedMCVersion"
name="mcVersion"
:options="mcVersions"
placeholder="Select Minecraft version..."
/>
<UiServersTeleportDropdownMenu
v-if="selectedMCVersion && selectedLoader.toLowerCase() !== 'vanilla'"
v-model="selectedLoaderVersion"
name="loaderVersion"
:options="selectedLoaderVersions"
placeholder="Select loader version..."
/>
<div class="mt-2 flex items-center gap-2">
<input
id="hard-reset"
:checked="hardReset"
class="switch stylized-toggle"
type="checkbox"
@change="hardReset = ($event.target as HTMLInputElement).checked"
/>
<label for="hard-reset">Clean reinstall</label>
</div>
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button :disabled="canInstall" @click="handleReinstall">
<RightArrowIcon />
{{
isSecondPhase
? "Erase and install"
: loadingServerCheck
? "Loading..."
: isDangerous
? "Erase and install"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
:disabled="isLoading"
@click="
if (isSecondPhase) {
isSecondPhase = false;
} else {
versionSelectModal?.hide();
}
"
>
<XIcon />
{{ isSecondPhase ? "No" : "Cancel" }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<NewModal ref="mrpackModal" header="Upload mrpack" @hide="onHide" @show="onShow">
<div>
<div class="mt-2 flex items-center gap-2">
<input
id="hard-reset"
:checked="hardReset"
class="switch stylized-toggle"
type="checkbox"
@change="hardReset = ($event.target as HTMLInputElement).checked"
/>
<label for="hard-reset">Clean reinstall</label>
</div>
<input
type="file"
accept=".mrpack"
class="mt-4"
:disabled="isLoading"
@change="uploadMrpack"
/>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
<button :disabled="!mrpackFile" @click="reinstallMrpack">
<RightArrowIcon />
{{
isSecondPhase
? "Erase and install"
: loadingServerCheck
? "Loading..."
: isDangerous
? "Erase and install"
: "Install"
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isLoading" @click="mrpackModal?.hide">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="flex h-full w-full flex-col">
<div v-if="data && versions" class="flex w-full flex-col">
<div class="card flex flex-col gap-4">
<div class="flex flex-row items-center justify-between gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
<div v-if="data.upstream" class="flex gap-4">
<ButtonStyled>
<nuxt-link
:class="{
'looks-disabled': props.server.general?.status === 'installing' && isError,
}"
:to="`/modpacks?sid=${props.server.serverId}`"
>
<TransferIcon class="size-4" />
Change modpack
</nuxt-link>
</ButtonStyled>
<ButtonStyled>
<button class="!w-full sm:!w-auto" @click="mrpackModal.show()">
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<div
v-if="data.upstream"
class="flex w-full justify-between gap-2 rounded-3xl bg-table-alternateRow p-4"
>
<div class="flex flex-col gap-4 sm:flex-row">
<UiAvatar :src="data.project?.icon_url" size="120px" />
<div class="flex flex-col justify-between">
<div class="flex flex-col gap-2">
<h1 class="m-0 flex gap-2 text-2xl font-extrabold leading-none text-contrast">
{{ data.project?.title }}
</h1>
<span class="text-md text-secondary">
{{
data.project?.description && data.project.description.length > 150
? data.project.description.substring(0, 150) + "..."
: data.project?.description || ""
}}
</span>
</div>
<div
class="mt-2 flex w-full max-w-[24rem] flex-col items-center gap-2 sm:mt-0 sm:flex-row"
>
<UiServersTeleportDropdownMenu
v-if="versions && Array.isArray(versions) && versions.length > 0"
v-model="version"
:options="options"
placeholder="Change version"
name="version"
/>
<ButtonStyled>
<button
:disabled="
isLoading || (props.server.general?.status === 'installing' && isError)
"
class="!w-full sm:!w-auto"
@click="reinstallCurrent"
>
<DownloadIcon class="size-4" />
Reinstall
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
<ButtonStyled>
<nuxt-link class="!w-full sm:!w-auto" :to="`/modpacks?sid=${props.server.serverId}`">
<DownloadIcon class="size-4" /> Install a modpack
</nuxt-link>
</ButtonStyled>
<span class="hidden sm:block">or</span>
<ButtonStyled>
<button class="!w-full sm:!w-auto" @click="mrpackModal.show()">
<UploadIcon class="size-4" /> Upload .mrpack file
</button>
</ButtonStyled>
</div>
</div>
<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">Mod loader</h2>
<p class="m-0">Mod loaders allow you to run mods on your server.</p>
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
Your server was installed from a modpack, which automatically chooses the appropriate
mod loader.
</span>
</div>
</div>
<div
class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2"
:class="{
'pointer-events-none cursor-not-allowed select-none opacity-50':
props.server.general?.status === 'installing' && isError,
}"
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
>
<UiServersLoaderSelector :data="data" @select-loader="selectLoader" />
</div>
</div>
</div>
<UiServersPyroLoading v-else />
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import {
TransferIcon,
DownloadIcon,
UploadIcon,
InfoIcon,
RightArrowIcon,
XIcon,
} from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const route = useNativeRoute();
const serverId = route.params.id as string;
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const emit = defineEmits<{
reinstall: [any?];
}>();
const tags = useTags();
const isLoading = ref(false);
const hardReset = ref(false);
const backupServer = ref(false);
const isError = computed(() => props.server.general?.status === "error");
const isDangerous = computed(() => hardReset.value);
const isBackupLimited = computed(() => (props.server.backups?.data?.length || 0) >= 15);
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
const loaderVersions = (await Promise.all(
versionStrings.map(async (loader) => {
const runFetch = async (iterations: number) => {
if (iterations > 5) {
throw new Error("Failed to fetch loader versions");
}
try {
// get our info
const res = await $fetch(`/loader-versions?loader=${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);
}
}),
).then((res) => res.reduce((acc, val) => ({ ...acc, ...val }), {}))) as Record<
string,
{
// eslint-disable-next-line no-template-curly-in-string
id: "${modrinth.gameVersion}" | (string & {});
stable: boolean;
loaders: {
id: string;
url: string;
stable: boolean;
}[];
}[]
>;
const editModal = ref();
const versionSelectModal = ref();
const mrpackModal = ref();
const canInstall = computed(() => {
const conds =
!selectedMCVersion.value ||
isBackupLimited.value ||
isLoading.value ||
loadingServerCheck.value ||
serverCheckError.value.trim().length > 0;
if (selectedLoader.value.toLowerCase() === "vanilla") {
return conds;
}
return conds || !selectedLoaderVersion.value;
});
const mcVersions = tags.value.gameVersions
.filter((x) => x.version_type === "release")
.map((x) => x.version)
.filter((x) => {
const num = parseInt(x.replace(/\./g, ""), 10);
// Versions 1.2.4 and below don't have server jars from Mojang
return isNaN(num) || num >= 125;
});
const selectedLoaderVersions = computed(() => {
/*
loaderVersions[
selectedLoader.value.toLowerCase() === "neoforge" ? "neo" : selectedLoader.toLowerCase()
]
.find((x) => x.id === selectedMCVersion)
?.loaders.map((x) => x.id) || []
*/
let loader = selectedLoader.value.toLowerCase();
if (loader === "neoforge") {
loader = "neo";
}
const backwardsCompatibleVersion = loaderVersions[loader].find(
// eslint-disable-next-line no-template-curly-in-string
(x) => x.id === "${modrinth.gameVersion}",
);
if (backwardsCompatibleVersion) {
return backwardsCompatibleVersion.loaders.map((x) => x.id);
}
return (
loaderVersions[loader]
.find((x) => x.id === selectedMCVersion.value)
?.loaders.map((x) => x.id) || []
);
});
const data = computed(() => props.server.general);
watch(
() => data.value?.loader,
() => {
console.log("Loader:", data.value?.loader);
},
{
deep: true,
immediate: true,
},
);
const { data: versions } = data?.value?.upstream
? await useLazyAsyncData(
`content-loader-versions`,
() => useBaseFetch(`project/${data?.value?.upstream?.project_id}/version`) as any,
)
: { data: { value: [] } };
const options = computed(() => (versions?.value as any[]).map((x) => x.version_number));
const versionIds = computed(() =>
(versions?.value as any[]).map((x) => {
return { [x.version_number]: x.id };
}),
);
const version = ref();
const currentVersion = ref();
const selectedLoader = ref("");
const selectedMCVersion = ref("");
const selectedLoaderVersion = ref("");
const isSecondPhase = ref(false);
const updateData = async () => {
if (!data.value?.upstream?.version_id) {
return;
}
currentVersion.value = await useBaseFetch(`version/${data?.value?.upstream?.version_id}`);
version.value = currentVersion.value.version_number;
};
updateData();
const selectLoader = (loader: string) => {
selectedLoader.value = loader;
versionSelectModal.value.show();
};
const loadingServerCheck = ref(false);
const serverCheckError = ref("");
const cachedVersions: Record<string, any> = {};
watch(selectedMCVersion, async () => {
if (selectedMCVersion.value.trim().length < 3) return;
// const res = await fetch(
// `/loader-versions?loader=minecraft&version=${selectedMCVersion.value}`,
// ).then((r) => r.json());
loadingServerCheck.value = true;
const res =
cachedVersions[selectedMCVersion.value] ||
(await $fetch(`/loader-versions?loader=minecraft&version=${selectedMCVersion.value}`));
cachedVersions[selectedMCVersion.value] = res;
loadingServerCheck.value = false;
if (res.downloads.server) {
serverCheckError.value = "";
} else {
serverCheckError.value =
"We couldn't find a server.jar for this version. Please pick another one.";
}
});
const onShow = () => {
selectedMCVersion.value = "";
selectedLoaderVersion.value = "";
};
const onHide = () => {
hardReset.value = false;
backupServer.value = false;
isSecondPhase.value = false;
serverCheckError.value = "";
loadingServerCheck.value = false;
isLoading.value = false;
mrpackFile.value = null;
};
const handleReinstallError = (error: any) => {
if (error instanceof PyroFetchError && error.statusCode === 429) {
addNotification({
group: "server",
title: "Cannot reinstall server",
text: "You are being rate limited. Please try again later.",
type: "error",
});
} else {
addNotification({
group: "server",
title: "Reinstall Failed",
text: "An unexpected error occurred while reinstalling. Please try again later.",
type: "error",
});
}
};
const reinstallCurrent = async () => {
const projectId = data.value?.upstream?.project_id;
if (!projectId) {
throw new Error("Project ID not found");
}
const resolvedVersionIds = versionIds.value;
const versionId = resolvedVersionIds.find((entry: any) => entry[version.value])?.[version.value];
try {
await props.server.general?.reinstall(serverId, false, projectId, versionId);
emit("reinstall");
} catch (error) {
handleReinstallError(error);
}
};
const handleReinstall = async () => {
if (hardReset.value && !backupServer.value && !isSecondPhase.value) {
isSecondPhase.value = true;
return;
}
if (backupServer.value) {
try {
const date = new Date();
const format = date.toLocaleString(navigator.language || "en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZoneName: "short",
});
const backupName = `Reinstallation - ${format}`;
isLoading.value = true;
await props.server.backups?.create(backupName);
} catch {
addNotification({
group: "server",
title: "Backup Failed",
text: "An unexpected error occurred while backing up. Please try again later.",
type: "error",
});
isLoading.value = false;
return;
}
}
isLoading.value = true;
try {
await props.server.general?.reinstall(
serverId,
true,
selectedLoader.value,
selectedMCVersion.value,
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
hardReset.value,
);
emit("reinstall", {
loader: selectedLoader.value,
lVersion: selectedLoaderVersion.value,
mVersion: selectedMCVersion.value,
});
await nextTick();
window.scrollTo(0, 0);
} catch (error) {
handleReinstallError(error);
} finally {
isLoading.value = false;
versionSelectModal.value.hide();
}
};
const reinstallNew = async (project: any, versionNumber: string) => {
editModal.value.hide();
try {
const versions = (await useBaseFetch(`project/${project.project_id}/version`)) as any;
const version = versions.find((x: any) => x.version_number === versionNumber);
if (!version?.id) {
throw new Error("Version not found");
}
await props.server.general?.reinstall(serverId, false, project.project_id, version.id);
emit("reinstall");
await nextTick();
window.scrollTo(0, 0);
} catch (error) {
handleReinstallError(error);
}
};
const mrpackFile = ref<File | null>(null);
const uploadMrpack = (event: Event) => {
const target = event.target as HTMLInputElement;
if (!target.files || target.files.length === 0) {
return;
}
mrpackFile.value = target.files[0];
};
const reinstallMrpack = async () => {
if (!mrpackFile.value) {
return;
}
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
type: mrpackFile.value.type,
});
try {
isLoading.value = true;
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
emit("reinstall");
await nextTick();
window.scrollTo(0, 0);
} catch (error) {
handleReinstallError(error);
} finally {
isLoading.value = false;
mrpackModal.value.hide();
}
};
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>
@@ -0,0 +1,468 @@
<template>
<div class="contents">
<NewModal ref="newAllocationModal" header="New allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="addNewAllocation">
<label for="new-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
id="new-allocation-name"
ref="newAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<PlusIcon /> Create allocation
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="newAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<NewModal ref="editAllocationModal" header="Edit Allocation">
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
<input
id="edit-allocation-name"
ref="editAllocationInput"
v-model="newAllocationName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
maxlength="32"
placeholder="e.g. Secondary allocation"
/>
<div class="mb-1 mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!newAllocationName" type="submit">
<SaveIcon /> Update Allocation
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="editAllocationModal?.hide()">Cancel</button>
</ButtonStyled>
</div>
</form>
</NewModal>
<ConfirmModal
ref="confirmDeleteModal"
title="Deleting allocation"
:description="`You are deleting the allocation ${allocationToDelete}. This cannot be reserved again. Are you sure you want to proceed?`"
proceed-label="Delete"
@proceed="confirmDeleteAllocation"
/>
<div class="relative h-full w-full overflow-y-auto">
<div v-if="data" class="flex h-full w-full flex-col justify-between gap-4">
<div class="flex h-full flex-col">
<!-- Subdomain section -->
<div class="card flex flex-col gap-4">
<div class="flex w-full flex-col items-center justify-between gap-4 sm:flex-row">
<label for="user-domain" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Generated DNS records</span>
<span>
Set up your personal domain to connect to your server via custom DNS records.
</span>
</label>
<ButtonStyled>
<button
class="!w-full sm:!w-auto"
:disabled="userDomain == ''"
@click="exportDnsRecords"
>
<UploadIcon />
<span>Export DNS records</span>
</button>
</ButtonStyled>
</div>
<input
id="user-domain"
v-model="userDomain"
class="w-full md:w-[50%]"
maxlength="64"
minlength="1"
type="text"
:placeholder="exampleDomain"
/>
<div
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow p-4"
>
<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 uppercase 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 uppercase 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 uppercase 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" color="brand" @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>
<UiCopyCode :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 uppercase 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 uppercase text-secondary sm:block">port</span>
</div>
</div>
</div>
<div class="flex w-full flex-row items-center gap-2 sm:w-auto">
<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>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges && !!isValidSubdomain"
:server="props.server"
:is-updating="isUpdating"
:save="saveNetwork"
:reset="resetNetwork"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
PlusIcon,
TrashIcon,
EditIcon,
VersionIcon,
SaveIcon,
InfoIcon,
UploadIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const isUpdating = ref(false);
const data = computed(() => props.server.general);
const serverIP = ref(data?.value?.net?.ip ?? "");
const serverSubdomain = ref(data?.value?.net?.domain ?? "");
const serverPrimaryPort = ref(data?.value?.net?.port ?? 0);
const userDomain = ref("");
const exampleDomain = "play.example.com";
const network = computed(() => props.server.network);
const allocations = computed(() => network.value?.allocations);
const newAllocationModal = ref<typeof NewModal>();
const editAllocationModal = ref<typeof NewModal>();
const confirmDeleteModal = ref<typeof ConfirmModal>();
const newAllocationInput = ref<HTMLInputElement | null>(null);
const editAllocationInput = ref<HTMLInputElement | null>(null);
const newAllocationName = ref("");
const newAllocationPort = ref(0);
const allocationToDelete = ref<number | null>(null);
const hasUnsavedChanges = computed(() => serverSubdomain.value !== data?.value?.net?.domain);
const isValidSubdomain = computed(() => /^[a-zA-Z0-9-]{5,}$/.test(serverSubdomain.value));
const addNewAllocation = async () => {
if (!newAllocationName.value) return;
try {
await props.server.network?.reserveAllocation(newAllocationName.value);
newAllocationModal.value?.hide();
newAllocationName.value = "";
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
title: "Allocation reserved",
text: "Your allocation has been reserved.",
});
} catch (error) {
console.error("Failed to reserve new allocation:", error);
}
};
const showNewAllocationModal = () => {
newAllocationName.value = "";
newAllocationModal.value?.show();
nextTick(() => {
setTimeout(() => {
newAllocationInput.value?.focus();
}, 100);
});
};
const showEditAllocationModal = (port: number) => {
newAllocationPort.value = port;
editAllocationModal.value?.show();
nextTick(() => {
setTimeout(() => {
editAllocationInput.value?.focus();
}, 100);
});
};
const showConfirmDeleteModal = (port: number) => {
allocationToDelete.value = port;
confirmDeleteModal.value?.show();
};
const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return;
await props.server.network?.deleteAllocation(allocationToDelete.value);
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
title: "Allocation removed",
text: "Your allocation has been removed.",
});
allocationToDelete.value = null;
};
const editAllocation = async () => {
if (!newAllocationName.value) return;
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
editAllocationModal.value?.hide();
newAllocationName.value = "";
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
title: "Allocation updated",
text: "Your allocation has been updated.",
});
} catch (error) {
console.error("Failed to reserve new allocation:", error);
}
};
const saveNetwork = async () => {
if (!isValidSubdomain.value) return;
try {
isUpdating.value = true;
const available = await props.server.network?.checkSubdomainAvailability(serverSubdomain.value);
if (!available) {
addNotification({
group: "serverOptions",
type: "error",
title: "Subdomain not available",
text: "The subdomain you entered is already in use.",
});
return;
}
if (serverSubdomain.value !== data?.value?.net?.domain) {
await props.server.network?.changeSubdomain(serverSubdomain.value);
}
if (serverPrimaryPort.value !== data?.value?.net?.port) {
await props.server.network?.updateAllocation(
serverPrimaryPort.value,
newAllocationName.value,
);
}
await new Promise((resolve) => setTimeout(resolve, 500));
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
title: "Server settings updated",
text: "Your server settings were successfully changed.",
});
} catch (error) {
console.error(error);
addNotification({
group: "serverOptions",
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({
group: "serverOptions",
type: "success",
title: "Text copied",
text: `${text} has been copied to your clipboard`,
});
};
</script>
@@ -0,0 +1,122 @@
<template>
<div class="h-full w-full">
<div class="h-full w-full gap-2 overflow-y-auto">
<div class="card flex flex-col gap-4">
<h1 class="m-0 text-lg font-bold text-contrast">Server preferences</h1>
<p class="m-0">Preferences apply per server and changes are only saved in your browser.</p>
<div
v-for="(prefConfig, key) in preferences"
:key="key"
class="flex items-center justify-between gap-2"
>
<label :for="`pref-${key}`" class="flex flex-col gap-2">
<div class="flex flex-row gap-2">
<span class="text-lg font-bold text-contrast">{{ prefConfig.displayName }}</span>
<div
v-if="prefConfig.implemented === false"
class="hidden items-center gap-1 rounded-full bg-table-alternateRow p-1 px-1.5 text-xs font-semibold sm:flex"
>
Coming Soon
</div>
</div>
<span>{{ prefConfig.description }}</span>
</label>
<input
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="switch stylized-toggle flex-none"
type="checkbox"
:disabled="prefConfig.implemented === false"
/>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="false"
:save="savePreferences"
:reset="resetPreferences"
/>
</div>
</template>
<script setup lang="ts">
import { useStorage } from "@vueuse/core";
import type { Server } from "~/composables/pyroServers";
const route = useNativeRoute();
const serverId = route.params.id as string;
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
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,
},
autoRestart: {
displayName: "Auto restart",
description: "When enabled, your server will automatically restart if it crashes.",
implemented: false,
},
powerDontAskAgain: {
displayName: "Power actions confirmation",
description: "When enabled, you will be prompted before stopping and restarting your server.",
implemented: true,
},
backupWhileRunning: {
displayName: "Create backups while running",
description: "When enabled, backups will be created even if the server is running.",
implemented: true,
},
} as const;
type PreferenceKeys = keyof typeof preferences;
type UserPreferences = {
[K in PreferenceKeys]: boolean;
};
const defaultPreferences: UserPreferences = {
ramAsNumber: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,
};
const userPreferences = useStorage<UserPreferences>(
`pyro-server-${serverId}-preferences`,
defaultPreferences,
);
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)));
const hasUnsavedChanges = computed(() => {
return JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value);
});
const savePreferences = () => {
userPreferences.value = { ...newUserPreferences.value };
addNotification({
group: "serverOptions",
type: "success",
title: "Preferences saved",
text: "Your preferences have been saved.",
});
};
const resetPreferences = () => {
newUserPreferences.value = { ...userPreferences.value };
};
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>
@@ -0,0 +1,286 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<div 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 about each property.
</div>
</div>
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
<div class="relative w-full text-sm">
<label for="search-server-properties" class="sr-only">Search server properties</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-server-properties"
v-model="searchInput"
class="w-full pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search server properties..."
/>
</div>
<div
v-for="(property, index) in filteredProperties"
:key="index"
class="flex flex-row flex-wrap items-center justify-between py-2"
>
<div class="flex items-center">
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
<EyeIcon v-tooltip="overrides[index].info" />
</span>
</div>
<div
v-if="overrides[index] && overrides[index].type === 'dropdown'"
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
>
<UiServersTeleportDropdownMenu
:id="`server-property-${index}`"
v-model="liveProperties[index]"
:name="formatPropertyName(index)"
:options="overrides[index].options || []"
:aria-labelledby="`property-label-${index}`"
placeholder="Select..."
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="switch stylized-toggle"
type="checkbox"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="typeof property === 'number'" class="mt-2 w-full sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model.number="liveProperties[index]"
type="number"
class="w-full border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
<textarea
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="w-full resize-y rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
></textarea>
</div>
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
<input
:id="`server-property-${index}`"
v-model="liveProperties[index]"
type="text"
class="w-full rounded-xl border p-2"
:aria-labelledby="`property-label-${index}`"
/>
</div>
</div>
</div>
</div>
</div>
<div v-else class="card flex h-full w-full items-center justify-center">
<p class="text-contrast">
The server properties file has not been generated yet. Start up your server to generate it.
</p>
</div>
<UiServersSaveBanner
:is-visible="hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
restart
:save="saveProperties"
:reset="resetProperties"
/>
</div>
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { EyeIcon, SearchIcon } from "@modrinth/assets";
import Fuse from "fuse.js";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const tags = useTags();
const isUpdating = ref(false);
const searchInput = ref("");
const data = computed(() => props.server.general);
const { data: propsData, status } = await useAsyncData(
"ServerProperties",
async () => await props.server.general?.fetchConfigFile("ServerProperties"),
);
const liveProperties = ref<Record<string, any>>({});
const originalProperties = ref<Record<string, any>>({});
watch(
propsData,
(newPropsData) => {
if (newPropsData) {
liveProperties.value = JSON.parse(JSON.stringify(newPropsData));
originalProperties.value = JSON.parse(JSON.stringify(newPropsData));
}
},
{ immediate: true },
);
const hasUnsavedChanges = computed(() => {
return Object.keys(liveProperties.value).some(
(key) =>
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
);
});
const getDifficultyOptions = () => {
const pre113Versions = tags.value.gameVersions
.filter((v) => {
const versionNumbers = v.version.split(".").map(Number);
return versionNumbers[0] === 1 && versionNumbers[1] < 13;
})
.map((v) => v.version);
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
return ["0", "1", "2", "3"];
} else {
return ["peaceful", "easy", "normal", "hard"];
}
};
const overrides: { [key: string]: { type: string; options?: string[]; info?: string } } = {
difficulty: {
type: "dropdown",
options: getDifficultyOptions(),
},
gamemode: {
type: "dropdown",
options: ["survival", "creative", "adventure", "spectator"],
},
};
const fuse = computed(() => {
if (!liveProperties.value) return null;
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
key,
value: String(value),
}));
return new Fuse(propertiesToFuse, {
keys: ["key", "value"],
threshold: 0.2,
});
});
const filteredProperties = computed(() => {
if (!searchInput.value?.trim()) {
return liveProperties.value;
}
const results = fuse.value?.search(searchInput.value) ?? [];
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]));
});
const constructServerProperties = (): string => {
const properties = liveProperties.value;
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
for (const [key, value] of Object.entries(properties)) {
if (typeof value === "object") {
fileContent += `${key}=${JSON.stringify(value)}\n`;
} else if (typeof value === "boolean") {
fileContent += `${key}=${value ? "true" : "false"}\n`;
} else {
fileContent += `${key}=${value}\n`;
}
}
return fileContent;
};
const saveProperties = async () => {
try {
isUpdating.value = true;
await props.server.fs?.updateFile("server.properties", constructServerProperties());
await new Promise((resolve) => setTimeout(resolve, 500));
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value));
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
title: "Server properties updated",
text: "Your server properties were successfully changed.",
});
} catch (error) {
console.error("Error updating server properties:", error);
addNotification({
group: "serverOptions",
type: "error",
title: "Failed to update server properties",
text: "An error occurred while attempting to update your server properties.",
});
} finally {
isUpdating.value = false;
}
};
const resetProperties = async () => {
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value));
await new Promise((resolve) => setTimeout(resolve, 200));
};
const formatPropertyName = (propertyName: string): string => {
return propertyName
.split(/[-.]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
};
const isComplexProperty = (property: any): boolean => {
return (
typeof property === "object" ||
(typeof property === "string" &&
(property.includes(",") ||
property.includes("{") ||
property.includes("}") ||
property.includes("[") ||
property.includes("]") ||
property.length > 30))
);
};
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>
@@ -0,0 +1,176 @@
<template>
<div class="relative h-full w-full">
<div v-if="data" class="flex h-full w-full flex-col gap-4">
<div class="rounded-2xl border-solid border-orange bg-bg-orange p-4 text-contrast">
These settings are for advanced users. Changing them can break your server.
</div>
<div class="gap-2">
<div class="card flex flex-col gap-4">
<div class="flex flex-col justify-between gap-4 sm:flex-row">
<label for="startup-command-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Startup command</span>
<span> The command that runs when your server is started. </span>
</label>
<ButtonStyled>
<button
:disabled="invocation === startupSettings?.original_invocation"
class="!w-full sm:!w-auto"
@click="resetToDefault"
>
<UpdatedIcon class="h-5 w-5" />
Restore default command
</button>
</ButtonStyled>
</div>
<textarea
id="startup-command-field"
v-model="invocation"
class="min-h-[270px] w-full resize-y font-[family-name:var(--mono-font)]"
/>
</div>
<div class="card flex flex-col gap-8">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Java version</span>
<span>
The version of Java that your server will run on. Your server is running Minecraft
{{ data.mc_version }}
</span>
</div>
<UiServersTeleportDropdownMenu
:id="'java-version-field'"
v-model="jdkVersion"
name="java-version"
:options="compatibleJavaVersions"
placeholder="Java Version"
/>
</div>
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Runtime</span>
<span> The Java runtime your server will use. </span>
</div>
<UiServersTeleportDropdownMenu
:id="'runtime-field'"
v-model="jdkBuild"
name="runtime"
:options="['Corretto', 'Temurin', 'GraalVM']"
placeholder="Runtime"
/>
</div>
</div>
</div>
</div>
<UiServersSaveBanner
:is-visible="!!hasUnsavedChanges"
:server="props.server"
:is-updating="isUpdating"
:save="saveStartup"
:reset="resetStartup"
/>
</div>
</template>
<script setup lang="ts">
import { UpdatedIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const data = computed(() => props.server.general);
const startupSettings = computed(() => props.server.startup);
const jdkVersionMap = [
{ value: "lts8", label: "Java 8" },
{ value: "lts11", label: "Java 11" },
{ value: "lts17", label: "Java 17" },
{ value: "lts21", label: "Java 21" },
];
const jdkBuildMap = [
{ value: "corretto", label: "Corretto" },
{ value: "temurin", label: "Temurin" },
{ value: "graal", label: "GraalVM" },
];
const invocation = ref(startupSettings.value?.invocation);
const jdkVersion = ref(
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "",
);
const jdkBuild = ref(
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "",
);
const isUpdating = ref(false);
const compatibleJavaVersions = computed(() => {
const mcVersion = data.value?.mc_version ?? "";
if (!mcVersion) return jdkVersionMap.map((v) => v.label);
const [major, minor] = mcVersion.split(".").map(Number);
if (major >= 1) {
if (minor >= 20) return ["Java 21"];
if (minor >= 18) return ["Java 17", "Java 21"];
if (minor >= 17) return ["Java 16", "Java 17", "Java 21"];
if (minor >= 12) return ["Java 8", "Java 11", "Java 17", "Java 21"];
if (minor >= 6) return ["Java 8", "Java 11"];
}
return ["Java 8"];
});
const hasUnsavedChanges = computed(
() =>
invocation.value !== startupSettings.value?.invocation ||
jdkVersion.value !==
(jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "") ||
jdkBuild.value !==
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build || "")?.label,
);
const saveStartup = async () => {
try {
isUpdating.value = true;
const invocationValue = invocation.value ?? "";
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value;
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value;
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any);
await new Promise((resolve) => setTimeout(resolve, 500));
addNotification({
group: "serverOptions",
type: "success",
title: "Server settings updated",
text: "Your server settings were successfully changed.",
});
await props.server.refresh();
} catch (error) {
console.error(error);
addNotification({
group: "serverOptions",
type: "error",
title: "Failed to update server arguments",
text: "Please try again later.",
});
} finally {
isUpdating.value = false;
}
};
const resetStartup = () => {
invocation.value = startupSettings.value?.invocation;
jdkVersion.value =
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "";
jdkBuild.value =
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "";
};
const resetToDefault = () => {
invocation.value = startupSettings.value?.original_invocation;
};
</script>