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

View File

@@ -1383,7 +1383,7 @@ try {
},
),
useAsyncData(`project/${route.params.id}/dependencies`, () =>
useBaseFetch(`project/${route.params.id}/dependencies`),
useBaseFetch(`project/${route.params.id}/dependencies`, {}),
),
useAsyncData(`project/${route.params.id}/version?featured=true`, () =>
useBaseFetch(`project/${route.params.id}/version?featured=true`),

View File

@@ -13,6 +13,7 @@
>
{{ projectType.display }}s <br />
</strong>
<strong class="main-header-strong">servers <br /></strong>
<strong class="main-header-strong">mods</strong>
</span>
</div>
@@ -724,6 +725,7 @@ async function updateSearchProjects() {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
@@ -1166,7 +1168,7 @@ async function updateSearchProjects() {
> span {
position: absolute;
top: 0;
animation: slide 10s infinite;
animation: slide 12s infinite;
@media (prefers-reduced-motion) {
animation-play-state: paused !important;
@@ -1175,32 +1177,36 @@ async function updateSearchProjects() {
@keyframes slide {
0%,
13% {
10% {
top: 0;
}
17%,
30% {
13%,
23% {
top: -1.2em;
}
33%,
46% {
26%,
36% {
top: -2.4em;
}
50%,
63% {
39%,
49% {
top: -3.6em;
}
66%,
79% {
52%,
62% {
top: -4.8em;
}
83%,
96% {
65%,
75% {
top: -6em;
}
78%,
88% {
top: -7.2em;
}
99.99997%,
99.99998% {
top: -7.2em;
top: -8.4em;
}
99.99999% {
top: 0;

View File

@@ -13,8 +13,38 @@
aria-label="Filters"
>
<AdPlaceholder
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus"
v-if="
(!auth.user || !isPermission(auth.user.badges, 1 << 0) || flags.showAdsWithPlus) &&
!server
"
/>
<section v-if="server" class="card">
<nuxt-link
:to="`/servers/manage/${server.serverId}/content`"
class="mb-2 flex items-center gap-2"
>
<Avatar :src="server.general.image" size="sm" />
<div class="flex flex-col gap-2">
<span class="font-bold">{{ server.general.name }}</span>
<span>{{ server.general.loader }} {{ server.general.mc_version }}</span>
</div>
</nuxt-link>
<Checkbox
v-if="projectType.id !== 'modpack'"
v-model="serverOverrideGameVersions"
label="Override game versions"
/>
<Checkbox
v-if="projectType.id !== 'modpack'"
v-model="serverOverrideLoaders"
label="Override loaders"
/>
<Checkbox
v-if="projectType.id !== 'modpack'"
v-model="serverHideInstalled"
label="Hide already installed"
/>
</section>
<section class="card gap-1" :class="{ 'max-lg:!hidden': !sidebarMenuOpen }">
<div class="flex items-center gap-2">
<div class="iconified-input w-full">
@@ -204,14 +234,6 @@
</button>
</div>
</div>
<pagination
v-if="false"
:page="currentPage"
:count="pageCount"
:link-function="(x) => getSearchUrl(x <= 1 ? 0 : (x - 1) * maxResults)"
class="mb-3 justify-end"
@switch-page="onSearchChangeToTop"
/>
<LogoAnimated v-if="searchLoading && !noLoad" />
<div v-else-if="results && results.hits && results.hits.length === 0" class="no-results">
<p>No results found for your query!</p>
@@ -243,10 +265,33 @@
:server-side="result.server_side"
:categories="result.display_categories"
:search="true"
:show-updated-date="sortType.name !== 'newest'"
:show-updated-date="!server && sortType.name !== 'newest'"
:show-created-date="!server"
:hide-loaders="['resourcepack', 'datapack'].includes(projectType.id)"
:color="result.color"
/>
>
<template v-if="server">
<button
v-if="
result.installed ||
server.mods.data.find((x) => x.project_id === result.project_id) ||
server.general?.project?.id === result.project_id
"
disabled
class="btn btn-outline btn-primary"
>
<CheckIcon />
Installed
</button>
<button v-else-if="result.installing" disabled class="btn btn-outline btn-primary">
Installing...
</button>
<button v-else class="btn btn-outline btn-primary" @click="serverInstall(result)">
<DownloadIcon />
Install
</button>
</template>
</ProjectCard>
</div>
</div>
<div class="pagination-after">
@@ -263,8 +308,8 @@
</template>
<script setup>
import { Multiselect } from "vue-multiselect";
import { Pagination, ScrollablePanel, Checkbox } from "@modrinth/ui";
import { BanIcon, DropdownIcon, CheckIcon, FilterXIcon } from "@modrinth/assets";
import { Pagination, ScrollablePanel, Checkbox, Avatar } from "@modrinth/ui";
import { BanIcon, DropdownIcon, CheckIcon, FilterXIcon, DownloadIcon } from "@modrinth/assets";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import LogoAnimated from "~/components/brand/LogoAnimated.vue";
@@ -379,6 +424,54 @@ if (route.query.o) {
currentPage.value = Math.ceil(route.query.o / maxResults.value) + 1;
}
const server = ref();
const serverHideInstalled = ref(false);
const serverOverrideGameVersions = ref(false);
const serverOverrideLoaders = ref(false);
if (route.query.sid) {
server.value = await usePyroServer(route.query.sid, ["general", "mods"]);
}
if (route.query.shi && projectType.value.id !== "modpack") {
serverHideInstalled.value = route.query.shi === "true";
}
if (route.query.sogv && projectType.value.id !== "modpack") {
serverOverrideGameVersions.value = route.query.sogv === "true";
}
if (route.query.sol && projectType.value.id !== "modpack") {
serverOverrideLoaders.value = route.query.sol === "true";
}
async function serverInstall(project) {
project.installing = true;
try {
const versions = await useBaseFetch(`project/${project.project_id}/version`, {}, false, true);
const version =
versions.find(
(x) =>
x.game_versions.includes(server.value.general.mc_version) &&
x.loaders.includes(server.value.general.loader.toLowerCase()),
) ?? versions[0];
if (projectType.value.id === "modpack") {
await server.value.general?.reinstall(route.query.sid, false, project.project_id, version.id);
project.installed = true;
navigateTo(`/servers/manage/${route.query.sid}/options/loader`);
} else if (projectType.value.id === "mod") {
await server.value.mods.install(version.project_id, version.id);
await server.value.refresh(["mods"]);
project.installed = true;
}
} catch (e) {
console.error(e);
}
project.installing = false;
}
projectType.value = tags.value.projectTypes.find(
(x) => x.id === route.path.substring(1, route.path.length - 1),
);
@@ -392,7 +485,6 @@ const {
() => {
const config = useRuntimeConfig();
const base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl;
const params = [`limit=${maxResults.value}`, `index=${sortType.value.name}`];
if (query.value.length > 0) {
@@ -416,8 +508,20 @@ const {
formattedFacets.push([facet.replace(":", "!=")]);
}
if (server.value && serverHideInstalled.value) {
const installedMods = server.value.mods.data
.filter((x) => x.project_id)
.map((x) => x.project_id);
installedMods.map((x) => [`project_id != ${x}`]).forEach((x) => formattedFacets.push(x));
}
// loaders specifier
if (orFacets.value.length > 0) {
if (server.value && !(serverOverrideLoaders.value || projectType.value.id === "modpack")) {
formattedFacets.push([
`categories:${encodeURIComponent(server.value.general.loader.toLowerCase())}`,
]);
} else if (orFacets.value.length > 0) {
formattedFacets.push(orFacets.value);
} else if (projectType.value.id === "plugin") {
formattedFacets.push(
@@ -435,7 +539,12 @@ const {
);
}
if (selectedVersions.value.length > 0) {
if (
server.value &&
!(serverOverrideGameVersions.value || projectType.value.id === "modpack")
) {
formattedFacets.push([`versions:${encodeURIComponent(server.value.general.mc_version)}`]);
} else if (selectedVersions.value.length > 0) {
const versionFacets = [];
for (const facet of selectedVersions.value) {
versionFacets.push("versions:" + facet);
@@ -574,6 +683,22 @@ function getSearchUrl(offset, useObj) {
queryItems.push(`m=${encodeURIComponent(maxResults.value)}`);
obj.m = maxResults.value;
}
if (server.value) {
queryItems.push(`sid=${encodeURIComponent(server.value.serverId)}`);
obj.sid = server.value.serverId;
}
if (serverHideInstalled.value) {
queryItems.push("shi=true");
obj.shi = true;
}
if (serverOverrideGameVersions.value) {
queryItems.push("sogv=true");
obj.sogv = true;
}
if (serverOverrideLoaders.value) {
queryItems.push("sol=true");
obj.sol = true;
}
let url = `${route.path}`;
@@ -648,7 +773,11 @@ const queryFilter = ref("");
const filters = computed(() => {
const filters = {};
if (projectType.value.id !== "resourcepack" && projectType.value.id !== "datapack") {
if (
projectType.value.id !== "resourcepack" &&
projectType.value.id !== "datapack" &&
(!server || serverOverrideLoaders.value || projectType.value.id === "modpack")
) {
const loaders = tags.value.loaders
.filter((x) => {
if (projectType.value.id === "mod" && !showAllLoaders.value) {
@@ -701,9 +830,11 @@ const filters = computed(() => {
}
}
filters.gameVersion = tags.value.gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => ({ name: x.version, type: "gameVersion" }));
if (!server || serverOverrideGameVersions.value || projectType.value.id === "modpack") {
filters.gameVersion = tags.value.gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === "release"))
.map((x) => ({ name: x.version, type: "gameVersion" }));
}
if (!["resourcepack", "plugin", "shader", "datapack"].includes(projectType.value.id)) {
filters.environment = [

View File

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

View File

@@ -0,0 +1,939 @@
<template>
<div
ref="scrollListener"
data-pyro
class="servers-hero relative isolate -mt-44 h-full min-h-screen pt-8"
>
<PurchaseModal
v-if="showModal && selectedProduct && customer"
:key="selectedProduct.id"
ref="purchaseModal"
:product="selectedProduct"
:country="country"
:custom-server="customServer"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
async (body) =>
await useBaseFetch('billing/payment', { internal: true, method: 'POST', body })
"
:fetch-payment-data="fetchPaymentData"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
@hidden="handleModalHidden"
/>
<section
class="mx-auto mt-32 flex min-h-[calc(80vh-0px)] max-w-7xl flex-col justify-center px-5 sm:mt-20 sm:min-h-[calc(100vh-0px)] sm:pl-10 lg:pl-3"
>
<div class="z-[5] flex w-full flex-col gap-8">
<div class="flex flex-col gap-4">
<div
class="relative h-fit w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Beta Release
</div>
<h1 class="relative m-0 max-w-3xl text-3xl font-bold !leading-[110%] md:text-6xl">
Host your next server with Modrinth Servers
</h1>
</div>
<h2
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
>
Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install
and play your favorite mods and modpacks, all within the Modrinth platform.
</h2>
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
<div
class="flex w-full flex-col items-center gap-5 text-center align-middle sm:w-fit sm:flex-row"
>
<ButtonStyled color="brand" size="large">
<nuxt-link class="w-fit" to="#plan">
<GameIcon aria-hidden="true" />
{{ hasServers ? "Start a new server" : "Start your server" }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-if="hasServers" type="outlined" size="large">
<nuxt-link class="w-fit" to="/servers/manage">
<BoxIcon aria-hidden="true" /> Manage your servers
</nuxt-link>
</ButtonStyled>
<UiServersPoweredByPyro class="mx-0 !mt-0" />
</div>
</div>
</div>
<div
class="absolute left-[55%] top-56 z-[5] hidden h-full max-h-[calc(100vh-10rem)] w-full rotate-1 xl:block"
>
<img
src="https://cdn.modrinth.com/servers/panel-right-dark.webp"
alt=""
aria-hidden="true"
class="pointer-events-none h-full w-fit select-none"
/>
</div>
<div
class="top-26 pointer-events-none absolute left-0 z-[4] flex h-screen w-full flex-row items-end gap-24 sm:-right-1/4 sm:top-14"
>
<div
class="pointer-events-none absolute left-0 right-0 top-8 max-h-[90%] overflow-hidden sm:top-28 sm:mt-0"
style="mask-image: linear-gradient(black, transparent 80%)"
>
<img
src="https://cdn.modrinth.com/servers/bigrinth.webp"
alt=""
aria-hidden="true"
class="pointer-events-none w-full animate-spin select-none p-4 opacity-50"
style="
animation-duration: 172s !important;
animation-timing-function: linear;
animation-iteration-count: infinite;
"
/>
</div>
</div>
</section>
<section
class="relative flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="relative mx-auto flex w-full max-w-7xl flex-col gap-8">
<div
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Why Modrinth Servers?
</div>
<h1 class="relative m-0 max-w-2xl text-4xl leading-[120%] md:text-7xl">
Find a modpack. Now it's a server.
</h1>
<h2
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
Choose from the thousands of modpacks on Modrinth or create your own. Invite your friends
when you're ready to play.
</h2>
<img
src="https://cdn.modrinth.com/servers/excitement.webp"
alt=""
class="absolute right-14 top-0 hidden max-w-[360px] lg:block"
/>
<div class="relative grid w-full grid-cols-1 gap-8 lg:grid-cols-2">
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<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-8 text-brand"
>
<path
d="M8.3 10a.7.7 0 0 1-.626-1.079L11.4 3a.7.7 0 0 1 1.198-.043L16.3 8.9a.7.7 0 0 1-.572 1.1Z"
/>
<rect x="3" y="14" width="7" height="7" rx="1" />
<circle cx="17.5" cy="17.5" r="3.5" />
</svg>
<h2 class="m-0 text-lg font-bold">Play where your mods are</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Modrinth Servers seamlessly integrates the mod and modpack installation process into
your server.
</h3>
</div>
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<LoaderIcon loader="fabric" class="size-8 text-brand" />
<h2 class="m-0 text-lg font-bold">All your favorite mods</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Choose between Vanilla, Fabric, Forge, Quilt and NeoForge. If it's on Modrinth, it can
run on your server.
</h3>
</div>
</div>
<div class="relative">
<img
src="https://cdn.modrinth.com/servers/installation-dark.webp"
alt=""
class="hidden w-full rounded-2xl sm:block"
/>
</div>
<div class="grid w-full grid-cols-1 gap-8 lg:grid-cols-2">
<div class="flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<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-8 text-brand"
>
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="M6 8h.01" />
<path d="M10 8h.01" />
<path d="M14 8h.01" />
</svg>
<h2 class="m-0 text-lg font-bold">Manage it all on Modrinth</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Your server, mods, players, and more are all on Modrinth. No need to switch between
platforms.
</h3>
</div>
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<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-8 text-brand"
>
<polygon points="13 19 22 12 13 5 13 19" />
<polygon points="2 19 11 12 2 5 2 19" />
</svg>
<h2 class="m-0 text-lg font-bold">
Experience modern, reliable hosting powered by Pyro
</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Modrinth Servers are hosted on super-fast servers, with custom-built sofware to ensure
your server runs smoothly.
</h3>
</div>
</div>
</div>
</section>
<section
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="relative mx-auto flex w-full max-w-7xl flex-col gap-8">
<div
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Included with your server
</div>
<h1 class="relative m-0 max-w-2xl text-4xl leading-[120%] md:text-7xl">
Comes with all the features you need.
</h1>
<h2
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
Included with every server is a suite of features designed to provide a hosting experience
that only Modrinth can offer.
</h2>
<img
src="https://cdn.modrinth.com/servers/waving.webp"
alt=""
class="absolute right-8 top-40 hidden max-w-[480px] lg:block"
/>
<div class="grid grid-cols-1 gap-9 lg:grid-cols-2">
<div class="grid w-full grid-cols-1 gap-8">
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<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-8 text-brand"
>
<circle cx="12" cy="12" r="10" />
<path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
<path d="M2 12h20" />
</svg>
<h2 class="m-0 text-lg font-bold">Custom URL</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Share your server with a custom
<span class="text-contrast">modrinth.gg</span> URL.
</h3>
<div
aria-hidden="true"
class="ooh-shiny absolute right-4 top-4 flex items-center justify-center rounded-full bg-bg-raised p-4"
>
<span class="font-bold text-contrast">{{ currentText }}</span
>.modrinth.gg
</div>
</div>
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<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-8 text-brand"
>
<path d="M12 13v8" />
<path d="M4 14.899A7 7 0 1 1 15.71 8h1.79a4.5 4.5 0 0 1 2.5 8.242" />
<path d="m8 17 4-4 4 4" />
</svg>
<h2 class="m-0 text-lg font-bold">Backups included</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Every server comes with 15 backups stored off-site with Backblaze.
</h3>
</div>
</div>
<div
style="
background: radial-gradient(
86.12% 101.64% at 95.97% 94.07%,
rgba(27, 217, 106, 0.23) 0%,
rgba(14, 115, 56, 0.2) 100%
);
border: 1px solid rgba(12, 107, 52, 0.55);
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
"
class="relative flex flex-col gap-4 overflow-hidden rounded-2xl p-6 text-left sm:backdrop-blur-xl md:p-12"
>
<h2 class="m-0 text-lg font-bold">Easy to use file manager</h2>
<h3 class="m-0 text-base font-normal">
Search, manage, and upload files directly to your server with ease.
</h3>
<img
src="https://cdn.modrinth.com/servers/content-dark.webp"
alt=""
class="absolute -bottom-12 -right-[15%] hidden max-w-2xl rounded-2xl bg-brand p-4 lg:block"
/>
<div class="flex flex-row items-center gap-3">
<div
aria-hidden="true"
class="max-w-fit rounded-full bg-brand p-4 text-sm font-bold text-[var(--color-accent-contrast)] lg:absolute lg:bottom-8 lg:right-8 lg:block"
>
8.49 GB used
</div>
<div
aria-hidden="true"
class="flex w-fit items-center gap-2 rounded-full bg-button-bg p-3 lg:hidden"
>
<SortAscendingIcon class="h-6 w-6" />
Sort
</div>
<div
aria-hidden="true"
class="flex w-fit items-center rounded-full bg-button-bg p-3 lg:hidden"
>
<SearchIcon class="h-6 w-6" />
</div>
</div>
</div>
</div>
<div class="grid w-full grid-cols-1 gap-8 lg:grid-cols-2">
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<!-- <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-8 text-brand"
>
<path
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
/>
<path
d="M12 5 9.04 7.96a2.17 2.17 0 0 0 0 3.08c.82.82 2.13.85 3 .07l2.07-1.9a2.82 2.82 0 0 1 3.79 0l2.96 2.66"
/>
<path d="m18 15-2-2" />
<path d="m15 18-2-2" />
</svg> -->
<TerminalSquareIcon class="size-8 text-brand" />
<h2 class="m-0 text-lg font-bold">
An easy console, server properties manager, and more
</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Super powerful features with super simple access.
</h3>
</div>
<div class="relative flex flex-col gap-4 rounded-2xl bg-bg p-6 text-left md:p-12">
<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-8 text-brand"
>
<path
d="M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4.05 3 5.5l7 7Z"
/>
<path
d="M12 5 9.04 7.96a2.17 2.17 0 0 0 0 3.08c.82.82 2.13.85 3 .07l2.07-1.9a2.82 2.82 0 0 1 3.79 0l2.96 2.66"
/>
<path d="m18 15-2-2" />
<path d="m15 18-2-2" />
</svg>
<h2 class="m-0 text-lg font-bold">Help when you need it</h2>
<h3 class="m-0 text-base font-normal text-secondary">
Reach out to the Modrinth team for help with your server at any time.
</h3>
</div>
</div>
</div>
</section>
<section
id="plan"
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
>
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
<div class="mx-auto flex w-full max-w-7xl flex-col items-center gap-8 text-center">
<h1 class="relative m-0 text-4xl leading-[120%] md:text-7xl">
Start your server on Modrinth
</h1>
<h2
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
{{
isAtCapacity && !loggedOut
? "We are currently at capacity. Please try again later."
: "There's a plan for everyone! Choose the one that fits your needs."
}}
<span class="font-bold"> Servers are currently US only. More regions coming soon!</span>
</h2>
<ul class="m-0 flex w-full flex-col gap-8 p-0 lg:flex-row">
<li class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Small</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue"
>
S
</div>
</div>
<p class="m-0">
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">4 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">32 GB Storage</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">2 vCPUs</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$12<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="blue" size="large">
<button
v-if="!isSmallAtCapacity"
class="!bg-highlight-blue !font-medium !text-blue"
@click="selectProduct(pyroPlanProducts[0])"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
</button>
<NuxtLink
v-else
:to="loggedOut ? redirectUrl : 'https://support.modrinth.com'"
:target="loggedOut ? '_self' : '_blank'"
class="!bg-highlight-blue !font-medium !text-blue"
>
<template v-if="loggedOut">
Login
<UserIcon class="!min-h-4 !min-w-4 !text-blue" />
</template>
<template v-else>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
</template>
</NuxtLink>
</ButtonStyled>
</li>
<li
style="
background: radial-gradient(
86.12% 101.64% at 95.97% 94.07%,
rgba(27, 217, 106, 0.23) 0%,
rgba(14, 115, 56, 0.2) 100%
);
border: 1px solid rgba(12, 107, 52, 0.55);
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
"
class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3"
>
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Medium</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand"
>
M
</div>
</div>
<p class="m-0">Great for modded multiplayer and small communities.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">6 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">48 GB Storage</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">3 vCPUs</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$18<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="brand" size="large">
<button
v-if="!isMediumAtCapacity"
class="shadow-xl"
@click="selectProduct(pyroPlanProducts[1])"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4" />
</button>
<NuxtLink
v-else
:to="loggedOut ? redirectUrl : 'https://support.modrinth.com'"
:target="loggedOut ? '_self' : '_blank'"
class="!bg-highlight-green !font-medium !text-green"
>
<template v-if="loggedOut">
Login
<UserIcon class="!min-h-4 !min-w-4 !text-green" />
</template>
<template v-else>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
</template>
</NuxtLink>
</ButtonStyled>
</li>
<li class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3">
<div class="flex flex-row items-center justify-between">
<h1 class="m-0">Large</h1>
<div
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple"
>
L
</div>
</div>
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
<p class="m-0">8 GB RAM</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">64 GB Storage</p>
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
<p class="m-0">4 vCPUs</p>
</div>
<h2 class="m-0 text-3xl text-contrast">
$24<span class="text-sm font-normal text-secondary">/month</span>
</h2>
<ButtonStyled color="purple" size="large">
<button
v-if="!isLargeAtCapacity"
class="!bg-highlight-purple !font-medium !text-purple"
@click="selectProduct(pyroPlanProducts[2])"
>
Get Started
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
</button>
<NuxtLink
v-else
:to="loggedOut ? redirectUrl : 'https://support.modrinth.com'"
:target="loggedOut ? '_self' : '_blank'"
class="!bg-highlight-purple !font-medium !text-purple"
>
<template v-if="loggedOut">
Login
<UserIcon class="!min-h-4 !min-w-4 !text-purple" />
</template>
<template v-else>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
</template>
</NuxtLink>
</ButtonStyled>
</li>
</ul>
<div
class="flex w-full flex-col items-start justify-between gap-4 rounded-2xl bg-bg p-8 text-left md:flex-row md:gap-0"
>
<div class="flex flex-col gap-4">
<h1 class="m-0">Build your own</h1>
<h2 class="m-0 text-base font-normal">
If you're a more technical server administrator, you can pick your own RAM and storage
options.
</h2>
</div>
<div class="flex w-full flex-col-reverse gap-2 md:w-auto md:flex-col md:items-center">
<ButtonStyled color="standard" size="large">
<button
v-if="!isLargeAtCapacity"
class="w-full md:w-fit"
@click="selectProduct(pyroProducts, true)"
>
Build your own
<RightArrowIcon class="!min-h-4 !min-w-4" />
</button>
<NuxtLink
v-else
:to="loggedOut ? redirectUrl : 'https://support.modrinth.com'"
:target="loggedOut ? '_self' : '_blank'"
class="w-full md:w-fit"
>
<template v-if="loggedOut">
Login
<UserIcon class="!min-h-4 !min-w-4" />
</template>
<template v-else>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4" />
</template>
</NuxtLink>
</ButtonStyled>
<p class="m-0 text-sm">Starting at $3/GB RAM</p>
</div>
</div>
</div>
</section>
</div>
</template>
<script setup>
import { ButtonStyled, PurchaseModal } from "@modrinth/ui";
import {
BoxIcon,
GameIcon,
RightArrowIcon,
SearchIcon,
SortAscendingIcon,
ExternalIcon,
TerminalSquareIcon,
UserIcon,
} from "@modrinth/assets";
import { products } from "~/generated/state.json";
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
const pyroPlanProducts = pyroProducts.filter(
(p) => p.metadata.ram === 4096 || p.metadata.ram === 6144 || p.metadata.ram === 8192,
);
pyroPlanProducts.sort((a, b) => a.metadata.ram - b.metadata.ram);
// yep. this is a thing.
if (!pyroProducts.metadata) {
pyroProducts.metadata = {};
}
pyroProducts.metadata.type = "pyro";
const title = "Modrinth Servers";
const description =
"Start your own Minecraft server directly on Modrinth. Play your favorite mods, plugins, and datapacks — without the hassle of setup.";
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
});
useHead({
script: [
{
src: "https://js.stripe.com/v3/",
defer: true,
async: true,
},
],
});
const auth = await useAuth();
const data = useNuxtApp();
const config = useRuntimeConfig();
const purchaseModal = ref(null);
const country = useUserCountry();
const customer = ref(null);
const paymentMethods = ref([]);
const selectedProduct = ref(null);
const customServer = ref(false);
const showModal = ref(false);
const modalKey = ref(0);
const words = ["my-smp", "medieval-masters", "create-server", "mega-smp", "spookypack"];
const currentWordIndex = ref(0);
const currentText = ref("");
const isDeleting = ref(false);
const typingSpeed = 75;
const deletingSpeed = 25;
const pauseTime = 2000;
const loggedOut = computed(() => !auth.value.user);
const redirectUrl = `/auth/sign-in?redirect=${encodeURIComponent("/servers#plan")}`;
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
try {
if (!auth.value.user) return false;
const response = await usePyroFetch("servers");
return response.servers && response.servers.length > 0;
} catch {
return false;
}
});
const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData(
"ServerCapacityAll",
async () => {
try {
const capacityChecks = pyroPlanProducts.map((product) =>
usePyroFetch("capacity", {
method: "POST",
body: {
cpu: product.metadata.cpu,
memory_mb: product.metadata.ram,
swap_mb: product.metadata.swap,
storage_mb: product.metadata.storage,
},
}),
);
const results = await Promise.all(capacityChecks);
return {
small: results[0],
medium: results[1],
large: results[2],
};
} catch (error) {
console.error("Error checking server capacities:", error);
return {
small: { available: 0 },
medium: { available: 0 },
large: { available: 0 },
};
}
},
);
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);
const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.available === 0);
const isLargeAtCapacity = computed(() => capacityStatuses.value?.large?.available === 0);
const startTyping = () => {
const currentWord = words[currentWordIndex.value];
if (isDeleting.value) {
if (currentText.value.length > 0) {
currentText.value = currentText.value.slice(0, -1);
setTimeout(startTyping, deletingSpeed);
} else {
isDeleting.value = false;
currentWordIndex.value = (currentWordIndex.value + 1) % words.length;
setTimeout(startTyping, typingSpeed);
}
} else if (currentText.value.length < currentWord.length) {
currentText.value = currentWord.slice(0, currentText.value.length + 1);
setTimeout(startTyping, typingSpeed);
} else {
isDeleting.value = true;
setTimeout(startTyping, pauseTime);
}
};
const handleError = (err) => {
addNotification({
group: "main",
title: "An error occurred",
type: "error",
text: err.message ?? (err.data ? err.data.description : err),
});
};
const handleModalHidden = () => {
showModal.value = false;
};
watch(selectedProduct, async (newProduct) => {
if (newProduct) {
showModal.value = false;
await nextTick();
showModal.value = true;
modalKey.value++;
await nextTick();
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
}
}
});
async function fetchPaymentData() {
if (!auth.value.user) return;
try {
const [customerData, paymentMethodsData] = await Promise.all([
useBaseFetch("billing/customer", { internal: true }),
useBaseFetch("billing/payment_methods", { internal: true }),
]);
customer.value = customerData;
paymentMethods.value = paymentMethodsData;
} catch (error) {
console.error("Error fetching payment data:", error);
addNotification({
group: "main",
title: "Error fetching payment data",
type: "error",
text: error.message || "An unexpected error occurred",
});
}
}
const route = useRoute();
const isAtCapacity = computed(
() => isSmallAtCapacity.value && isMediumAtCapacity.value && isLargeAtCapacity.value,
);
const selectProduct = async (product, custom) => {
if (isAtCapacity.value) {
addNotification({
group: "main",
title: "Server Capacity Full",
type: "error",
text: "We are currently at capacity. Please try again later.",
});
return;
}
await refreshCapacity();
if (isAtCapacity.value) {
addNotification({
group: "main",
title: "Server Capacity Full",
type: "error",
text: "We are currently at capacity. Please try again later.",
});
return;
}
if (!auth.value.user) {
data.$router.push(redirectUrl);
return;
}
customServer.value = !!custom;
selectedProduct.value = product;
showModal.value = true;
modalKey.value++;
await nextTick();
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
}
};
const openPurchaseModal = () => {
if (isAtCapacity.value) {
addNotification({
group: "main",
title: "Server Capacity Full",
type: "error",
text: "We are currently at capacity. Please try again later.",
});
return;
}
refreshCapacity();
if (isAtCapacity.value) {
addNotification({
group: "main",
title: "Server Capacity Full",
type: "error",
text: "We are currently at capacity. Please try again later.",
});
return;
}
customServer.value = false;
selectedProduct.value = pyroPlanProducts[0];
showModal.value = true;
modalKey.value++;
nextTick(() => {
if (purchaseModal.value && purchaseModal.value.show) {
purchaseModal.value.show();
}
});
};
onMounted(() => {
startTyping();
if (route.query.showModal) {
openPurchaseModal();
}
});
watch(customer, (newCustomer) => {
if (newCustomer) {
if (route.query.showModal) {
openPurchaseModal();
}
}
});
onMounted(() => {
document.body.style.background = "var(--color-accent-contrast)";
document.body.style.overflowX = "hidden !important";
const layoutDiv = document.querySelector(".layout");
if (layoutDiv) {
layoutDiv.style.background = "var(--color-accent-contrast)";
}
fetchPaymentData();
});
onUnmounted(() => {
document.body.style.background = "";
document.body.style.overflowX = "";
const layoutDiv = document.querySelector(".layout");
if (layoutDiv) {
layoutDiv.style.background = "";
}
if (window.Stripe) {
window.Stripe = null;
}
});
</script>
<style scoped>
.servers-hero {
background: radial-gradient(
65% 30% at 50% -10%,
var(--color-brand-highlight) 0%,
var(--color-accent-contrast) 100%
);
}
.faded-brand-line {
background: linear-gradient(to right, var(--color-brand-highlight), transparent);
}
</style>

View File

@@ -0,0 +1,811 @@
<template>
<div class="contents">
<div
v-if="server.error && server.error.message.includes('Forbidden')"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<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">
<TransferIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server not found</h1>
</div>
<p class="text-lg text-secondary">
You don't have permission to view this server or it no longer exists. If you believe
this is an error, please contact Modrinth support.
</p>
</div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="server.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<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">
<TransferIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Connection lost</h1>
<div class="text-center text-secondary">
{{
formattedTime == "00"
? "Reconnecting..."
: `Retrying in ${formattedTime} seconds...`
}}
</div>
</div>
<p class="text-lg text-secondary">
Something went wrong, and we couldn't connect to your server. This is likely due to a
temporary network issue. You'll be reconnected automatically.
</p>
</div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
color="brand"
@click="() => reloadNuxtApp()"
>
<button class="mt-6 !w-full">Reload</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="serverData"
data-pyro-server-manager-root
class="experimental-styles-within mobile-blurred-servericon relative mx-auto box-border flex min-h-screen w-full min-w-0 max-w-[1280px] flex-col gap-6 px-3 transition-all duration-300"
:style="{
'--server-bg-image': serverData.image
? `url(${serverData.image})`
: `linear-gradient(180deg, rgba(153,153,153,1) 0%, rgba(87,87,87,1) 100%)`,
}"
>
<div class="flex w-full min-w-0 select-none flex-col items-center gap-6 pt-4 sm:flex-row">
<UiServersServerIcon :image="serverData.image" class="drop-shadow-lg sm:drop-shadow-none" />
<div
class="flex min-w-0 flex-1 flex-col-reverse items-center gap-2 sm:flex-col sm:items-start"
>
<div class="hidden shrink-0 flex-row items-center gap-1 sm:flex">
<NuxtLink to="/servers/manage" class="breadcrumb goto-link flex w-fit items-center">
<LeftArrowIcon />
All servers
</NuxtLink>
</div>
<div class="flex w-full flex-col items-center gap-4 sm:flex-row">
<h1
class="m-0 w-screen flex-shrink gap-3 truncate px-3 text-center text-4xl font-bold text-contrast sm:w-full sm:p-0 sm:text-left"
>
{{ serverData.name }}
</h1>
<div
v-if="isConnected"
data-pyro-server-action-buttons
class="server-action-buttons-anim flex w-fit flex-shrink-0"
>
<UiServersPanelServerActionButton
class="flex-shrink-0"
:is-online="isServerRunning"
:is-actioning="isActioning"
:is-installing="serverData.status === 'installing'"
:disabled="isActioning || !!error"
:server-name="serverData.name"
:server-data="serverData"
:uptime-seconds="uptimeSeconds"
@action="sendPowerAction"
/>
</div>
</div>
<UiServersServerInfoLabels
:server-data="serverData"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds"
:linked="true"
class="flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
<div
data-pyro-navigation
class="isolate flex w-full select-none flex-col justify-between gap-4 overflow-auto md:flex-row md:items-center"
>
<UiNavTabs :links="navLinks" />
</div>
<div data-pyro-mount class="h-full w-full flex-1">
<div
v-if="error"
class="mx-auto mb-4 flex justify-between gap-2 rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2 leading-[150%]">
<div class="flex items-center gap-3">
<IssuesIcon class="block h-8 w-8 text-red sm:hidden" />
<div class="flex gap-2 text-xl font-bold">{{ errorTitle }}</div>
</div>
<div
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
class="font-normal"
>
<div
v-if="
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
"
>
An invalid loader or Minecraft version was specified and could not be installed.
<ul class="m-0 mt-4 p-0 pl-4">
<li>
If this version of Minecraft was released recently, please check if Modrinth
Servers supports it.
</li>
<li>
If you've installed a modpack, it may have been packaged incorrectly or may
not be compatible with the loader.
</li>
<li>
Your server may need to be reinstalled with a valid mod loader and version.
You can change the loader by clicking the "Change Loader" button.
</li>
<li>
If you're stuck, please contact Modrinth support with the information below:
</li>
</ul>
<ButtonStyled>
<button class="mt-2" @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
</div>
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
An internal error occurred while installing your server. Don't fret try
reinstalling your server, and if the problem persists, please contact Modrinth
support with your server's debug information.
</div>
<div
v-if="errorTitle === 'Installation error'"
class="mt-2 flex flex-col gap-4 sm:flex-row"
>
<ButtonStyled v-if="errorLog">
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyServerDebugInfo">
<CopyIcon v-if="!copied" />
<CheckIcon v-else />
Copy Debug Info
</button>
</ButtonStyled>
<ButtonStyled color="red" type="standard">
<NuxtLink
class="whitespace-pre"
:to="`/servers/manage/${serverId}/options/loader`"
>
<RightArrowIcon />
Change Loader
</NuxtLink>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
<div
v-if="!isConnected && !isReconnecting && !isLoading"
data-pyro-server-ws-error
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
>
<IssuesIcon class="size-5 text-red" />
Something went wrong...
</div>
<div
v-if="isReconnecting"
data-pyro-server-ws-reconnecting
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
>
<UiServersPanelSpinner />
Hang on, we're reconnecting to your server.
</div>
<div
v-if="serverData.status === 'installing'"
data-pyro-server-installing
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
>
<UiServersPanelSpinner />
We're preparing your server, this may take a few minutes.
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
:is-ws-auth-incorrect="isWSAuthIncorrect"
:is-server-running="isServerRunning"
:stats="stats"
:server-power-state="serverPowerState"
:console-output="throttledConsoleOutput"
:socket="socket"
:server="server"
@reinstall="onReinstall"
/>
</div>
<UiServersPoweredByPyro />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
import {
CopyIcon,
IssuesIcon,
LeftArrowIcon,
RightArrowIcon,
CheckIcon,
FileIcon,
TransferIcon,
} from "@modrinth/assets";
import DOMPurify from "dompurify";
import { ButtonStyled } from "@modrinth/ui";
import { refThrottled } from "@vueuse/core";
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
const socket = ref<WebSocket | null>(null);
const isReconnecting = ref(false);
const isLoading = ref(true);
const reconnectInterval = ref<ReturnType<typeof setInterval> | null>(null);
const isMounted = ref(true);
const route = useNativeRoute();
const router = useRouter();
const serverId = route.params.id as string;
const server = await usePyroServer(serverId, [
"general",
"mods",
"backups",
"network",
"startup",
"ws",
"fs",
]);
watch(
() => server.error,
(newError) => {
if (newError && !newError.message.includes("Forbidden")) {
startPolling();
}
},
);
const errorTitle = ref("Error");
const errorMessage = ref("An unexpected error occurred.");
const errorLog = ref("");
const errorLogFile = ref("");
const serverData = computed(() => server.general);
const error = ref<Error | null>(null);
const isConnected = ref(false);
const isWSAuthIncorrect = ref(false);
const maxConsoleOutput = 5000;
const consoleOutput = ref<string[]>([]);
const throttledConsoleOutput = refThrottled(consoleOutput, 200);
const cpuData = ref<number[]>([]);
const ramData = ref<number[]>([]);
const isActioning = ref(false);
const isServerRunning = computed(() => serverPowerState.value === "running");
const serverPowerState = ref<ServerState>("stopped");
const uptimeSeconds = ref(0);
const firstConnect = ref(true);
const copied = ref(false);
const initialConsoleMessage = [
" __________________________________________________",
" / Welcome to your \x1B[32mModrinth Server\x1B[37m! \\",
"| Press the green start button to start your server! |",
" \\____________________________________________________/",
"\x1B[32m _ _ \x1B[37m",
"\x1B[32m (o)--(o) \x1B[37m",
"\x1B[32m /.______.\\\x1B[37m",
"\x1B[32m \\________/ \x1B[37m",
"\x1B[32m ./ \\. \x1B[37m",
"\x1B[32m ( . , )\x1B[37m",
"\x1B[32m \\ \\_\\\\ //_/ /\x1B[37m",
"\x1B[32m ~~ ~~ ~~\x1B[37m",
];
const stats = ref<Stats>({
current: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
past: {
cpu_percent: 0,
ram_usage_bytes: 0,
ram_total_bytes: 1,
storage_usage_bytes: 0,
storage_total_bytes: 0,
},
graph: {
cpu: [],
ram: [],
},
});
const showGameLabel = computed(() => !!serverData.value?.game);
const showLoaderLabel = computed(() => !!serverData.value?.loader);
const navLinks = [
{ label: "Overview", href: `/servers/manage/${serverId}`, subpages: [] },
{
label: "Content",
href: `/servers/manage/${serverId}/content`,
subpages: ["mods", "datapacks"],
},
{ label: "Files", href: `/servers/manage/${serverId}/files`, subpages: [] },
{ label: "Backups", href: `/servers/manage/${serverId}/backups`, subpages: [] },
{
label: "Options",
href: `/servers/manage/${serverId}/options`,
subpages: ["startup", "network", "properties", "info"],
},
];
const connectWebSocket = () => {
if (!isMounted.value) return;
try {
const wsAuth = computed(() => server.ws);
socket.value = new WebSocket(`wss://${wsAuth.value?.url}`);
socket.value.onopen = () => {
if (!isMounted.value) {
socket.value?.close();
return;
}
consoleOutput.value = [];
socket.value?.send(JSON.stringify({ event: "auth", jwt: wsAuth.value?.token }));
isConnected.value = true;
isReconnecting.value = false;
isLoading.value = false;
if (firstConnect.value) {
for (let i = 0; i < initialConsoleMessage.length; i++) {
consoleOutput.value.push(initialConsoleMessage[i]);
}
}
firstConnect.value = false;
if (reconnectInterval.value) {
if (reconnectInterval.value !== null) {
clearInterval(reconnectInterval.value);
}
reconnectInterval.value = null;
}
};
socket.value.onmessage = (event) => {
if (isMounted.value) {
const data: WSEvent = JSON.parse(event.data);
handleWebSocketMessage(data);
}
};
socket.value.onclose = () => {
if (isMounted.value) {
consoleOutput.value.push(
"\nSomething went wrong with the connection, we're reconnecting...",
);
isConnected.value = false;
scheduleReconnect();
}
};
socket.value.onerror = (error) => {
if (isMounted.value) {
console.error("Failed to connect WebSocket:", error);
isConnected.value = false;
scheduleReconnect();
}
};
} catch (error) {
if (isMounted.value) {
console.error("Failed to connect WebSocket:", error);
isConnected.value = false;
scheduleReconnect();
}
}
};
const scheduleReconnect = () => {
if (!isMounted.value) return;
if (!reconnectInterval.value) {
isReconnecting.value = true;
reconnectInterval.value = setInterval(() => {
if (isMounted.value) {
console.log("Attempting to reconnect...");
connectWebSocket();
} else {
reconnectInterval.value = null;
}
}, 5000);
}
};
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null;
const startUptimeUpdates = () => {
uptimeIntervalId = setInterval(() => {
uptimeSeconds.value += 1;
}, 1000);
};
const stopUptimeUpdates = () => {
if (uptimeIntervalId) {
clearInterval(uptimeIntervalId);
intervalId = null;
}
};
const handleWebSocketMessage = (data: WSEvent) => {
switch (data.event) {
case "log":
// eslint-disable-next-line no-case-declarations
const log = data.message.split("\n").filter((l) => l.trim());
if (consoleOutput.value.length > maxConsoleOutput) {
consoleOutput.value.shift();
}
consoleOutput.value.push(...log);
break;
case "stats":
updateStats(data);
break;
case "auth-expiring":
case "auth-incorrect":
reauthenticate();
break;
case "power-state":
updatePowerState(data.state);
break;
case "installation-result":
handleInstallationResult(data);
break;
case "new-mod":
server.refresh(["mods"]);
console.log("New mod:", data);
break;
case "auth-ok":
break;
case "uptime":
stopUptimeUpdates();
uptimeSeconds.value = data.uptime;
startUptimeUpdates();
break;
default:
console.warn("Unhandled WebSocket event:", data);
}
};
const newLoader = ref<string | null>(null);
const newLoaderVersion = ref<string | null>(null);
const newMCVersion = ref<string | null>(null);
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
switch (data.result) {
case "ok":
await server.refresh();
if (!serverData.value) break;
serverData.value.status = "available";
if (server.general) {
if (newLoader.value) server.general.loader = newLoader.value;
if (newLoaderVersion.value) server.general.loader_version = newLoaderVersion.value;
if (newMCVersion.value) server.general.mc_version = newMCVersion.value;
}
error.value = null;
break;
case "err": {
console.log("failed to install");
console.log(data);
errorTitle.value = "Installation error";
errorMessage.value = data.reason ?? "Unknown error";
error.value = new Error(data.reason ?? "Unknown error");
let files = await server.fs?.listDirContents("/", 1, 100);
if (files.total > 1) {
for (let i = 1; i < files.total; i++) {
files = await server.fs?.listDirContents("/", i, 100);
if (files.items?.length === 0) break;
}
}
const fileName = await files.items?.find((file: { name: string }) =>
file.name.startsWith("modrinth-installation"),
)?.name;
errorLogFile.value = fileName;
errorLog.value = await server.fs?.downloadFile(fileName);
break;
}
}
};
const onReinstall = (potentialArgs: any) => {
if (!serverData.value) return;
serverData.value.status = "installing";
// serverData.value.loader = potentialArgs.loader;
// serverData.value.loader_version = potentialArgs.lVersion;
// serverData.value.mc_version = potentialArgs.mVersion;
// if (potentialArgs?.loader) {
// console.log("setting loader to", potentialArgs.loader);
// serverData.value.loader = potentialArgs.loader;
// }
// if (potentialArgs?.lVersion) {
// serverData.value.loader_version = potentialArgs.lVersion;
// }
// if (potentialArgs?.mVersion) {
// serverData.value.mc_version = potentialArgs.mVersion;
// }
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader;
}
if (potentialArgs?.lVersion) {
newLoaderVersion.value = potentialArgs.lVersion;
}
if (potentialArgs?.mVersion) {
newMCVersion.value = potentialArgs.mVersion;
}
server.refresh();
error.value = null;
errorTitle.value = "Error";
errorMessage.value = "An unexpected error occurred.";
console.log(serverData.value);
};
const updateStats = (currentStats: Stats["current"]) => {
isConnected.value = true;
stats.value = {
current: currentStats,
past: { ...stats.value.current },
graph: {
cpu: updateGraphData(cpuData.value, currentStats.cpu_percent),
ram: updateGraphData(
ramData.value,
Math.floor((currentStats.ram_usage_bytes / currentStats.ram_total_bytes) * 100),
),
},
};
};
const updatePowerState = (state: ServerState) => {
console.log("Power state:", state);
serverPowerState.value = state;
if (state === "stopped" || state === "crashed") {
stopUptimeUpdates();
uptimeSeconds.value = 0;
}
};
const updateGraphData = (dataArray: number[], newValue: number): number[] => {
const updated = [...dataArray, newValue];
if (updated.length > 10) updated.shift();
return updated;
};
const reauthenticate = async () => {
try {
await server.refresh();
const wsAuth = computed(() => server.ws);
socket.value?.send(JSON.stringify({ event: "auth", jwt: wsAuth.value?.token }));
} catch (error) {
console.error("Reauthentication failed:", error);
isWSAuthIncorrect.value = true;
}
};
const toAdverb = (word: string) => {
if (word.endsWith("p")) {
return word + "ping";
}
if (word.endsWith("e")) {
return word.slice(0, -1) + "ing";
}
if (word.endsWith("ie")) {
return word.slice(0, -2) + "ying";
}
return word + "ing";
};
const sendPowerAction = async (action: "restart" | "start" | "stop" | "kill") => {
const actionName = action.charAt(0).toUpperCase() + action.slice(1);
try {
isActioning.value = true;
await server.general?.power(actionName);
} catch (error) {
console.error(`Error ${toAdverb(actionName)} server:`, error);
notifyError(
`Error ${toAdverb(actionName)} server`,
"An error occurred while performing this action.",
);
} finally {
isActioning.value = false;
}
};
const notifyError = (title: string, text: string) => {
addNotification({
group: "server",
title,
text,
type: "error",
});
};
let intervalId: ReturnType<typeof setInterval> | null = null;
const countdown = ref(15);
const formattedTime = computed(() => {
const seconds = countdown.value % 60;
return `${seconds.toString().padStart(2, "0")}`;
});
const stopPolling = () => {
if (intervalId) {
clearInterval(intervalId);
intervalId = null;
}
};
const startPolling = () => {
countdown.value = 15;
intervalId = setInterval(() => {
if (countdown.value <= 0) {
reloadNuxtApp();
} else {
countdown.value--;
}
}, 1000);
};
const copyServerDebugInfo = () => {
const debugInfo = `Server ID: ${serverData.value?.server_id}\nError: ${errorMessage.value}\nKind: ${serverData.value?.upstream?.kind}\nProject ID: ${serverData.value?.upstream?.project_id}\nVersion ID: ${serverData.value?.upstream?.version_id}\nLog: ${errorLog.value}`;
navigator.clipboard.writeText(debugInfo);
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 5000);
};
const openInstallLog = () => {
router.replace({
path: `/servers/manage/${serverId}/files`,
query: { ...route.query, editing: errorLogFile.value },
});
};
const cleanup = () => {
isMounted.value = false;
stopPolling();
stopUptimeUpdates();
if (reconnectInterval.value) {
clearInterval(reconnectInterval.value);
reconnectInterval.value = null;
}
if (socket.value) {
socket.value.onopen = null;
socket.value.onmessage = null;
socket.value.onclose = null;
socket.value.onerror = null;
if (
socket.value.readyState === WebSocket.OPEN ||
socket.value.readyState === WebSocket.CONNECTING
) {
socket.value.close();
}
socket.value = null;
}
isConnected.value = false;
isReconnecting.value = false;
isLoading.value = true;
DOMPurify.removeHook("afterSanitizeAttributes");
};
onMounted(() => {
isMounted.value = true;
if (server.error) {
if (!server.error.message.includes("Forbidden")) {
startPolling();
}
} else {
connectWebSocket();
}
DOMPurify.addHook(
"afterSanitizeAttributes",
(node: {
tagName: string;
getAttribute: (arg0: string) => any;
setAttribute: (arg0: string, arg1: string) => void;
}) => {
if (node.tagName === "A" && node.getAttribute("target")) {
node.setAttribute("rel", "noopener noreferrer");
}
},
);
});
onUnmounted(() => {
cleanup();
});
watch(
() => serverData.value?.status,
(newStatus) => {
if (newStatus === "installing") {
startPolling();
} else {
stopPolling();
server.refresh();
}
},
);
definePageMeta({
middleware: "auth",
});
</script>
<style scoped>
@keyframes server-action-buttons-anim {
0% {
opacity: 0;
transform: translateX(1rem);
}
100% {
opacity: 1;
transform: none;
}
}
.server-action-buttons-anim {
animation: server-action-buttons-anim 0.2s ease-out;
}
.mobile-blurred-servericon::before {
@apply absolute left-0 top-0 block h-36 w-full bg-cover bg-center bg-no-repeat blur-2xl sm:hidden;
content: "";
background-image: linear-gradient(
to bottom,
rgba(from var(--color-raised-bg) r g b / 0.2),
rgb(from var(--color-raised-bg) r g b / 0.8)
),
var(--server-bg-image);
}
</style>

View File

@@ -0,0 +1,372 @@
<template>
<div class="contents">
<div v-if="data" class="contents">
<LazyUiServersBackupCreateModal
ref="createBackupModal"
:server="server"
@backup-created="handleBackupCreated"
/>
<LazyUiServersBackupRenameModal
ref="renameBackupModal"
:server="server"
:current-backup-id="currentBackup"
:backup-name="renameBackupName"
@backup-renamed="handleBackupRenamed"
/>
<LazyUiServersBackupRestoreModal
ref="restoreBackupModal"
:server="server"
:backup-id="currentBackup"
:backup-name="currentBackupDetails?.name ?? ''"
:backup-created-at="currentBackupDetails?.created_at ?? ''"
@backup-restored="handleBackupRestored"
/>
<LazyUiServersBackupDeleteModal
ref="deleteBackupModal"
:server="server"
:backup-id="currentBackup"
:backup-name="currentBackupDetails?.name ?? ''"
:backup-created-at="currentBackupDetails?.created_at ?? ''"
@backup-deleted="handleBackupDeleted"
/>
<LazyUiServersBackupSettingsModal ref="backupSettingsModal" :server="server" />
<ul class="m-0 flex list-none flex-col gap-4 p-0">
<div class="relative w-full overflow-hidden rounded-2xl bg-bg-raised p-6 shadow-md">
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row sm:gap-0">
<div class="flex flex-col items-baseline gap-2">
<div class="text-2xl font-bold text-contrast">
{{
data.used_backup_quota === 0
? "No backups"
: `You've created ${data.used_backup_quota} backup${data.used_backup_quota === 1 ? "" : "s"}`
}}
</div>
<div>
{{
data.backup_quota - data.used_backup_quota === 0
? "You have reached your backup limit. Consider removing old backups to create new ones."
: `You can create ${data.backup_quota - data.used_backup_quota} more backups for your server.`
}}
</div>
</div>
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
<ButtonStyled type="standard">
<button @click="showbackupSettingsModal">
<SettingsIcon class="h-5 w-5" />
Auto backups
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button
v-tooltip="
isServerRunning && !userPreferences.backupWhileRunning
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
: ''
"
class="w-full sm:w-fit"
:disabled="isServerRunning && !userPreferences.backupWhileRunning"
@click="showCreateModel"
>
<PlusIcon class="h-5 w-5" />
Create backup
</button>
</ButtonStyled>
</div>
</div>
</div>
<li
v-for="(backup, index) in backups"
:key="backup.id"
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-4 shadow-md"
>
<div class="flex flex-col gap-4">
<div class="flex items-center justify-between">
<div class="flex min-w-0 flex-row items-center gap-4">
<div
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
>
<UiServersIconsLoadingIcon
v-if="backup.ongoing"
v-tooltip="'Backup in progress'"
class="size-6 animate-spin"
/>
<BoxIcon v-else class="size-8" />
</div>
<div class="flex min-w-0 flex-col gap-2">
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
<div class="max-w-full truncate text-xl font-bold text-contrast">
{{ backup.name }}
</div>
<div
v-if="index == 0"
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="size-4" /> Latest
</div>
</div>
<div class="flex items-center gap-2 text-sm font-semibold">
<CalendarIcon class="size-4" />
{{
new Date(backup.created_at).toLocaleString("en-US", {
month: "numeric",
day: "numeric",
year: "2-digit",
hour: "numeric",
minute: "numeric",
hour12: true,
})
}}
</div>
</div>
</div>
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
<UiServersTeleportOverflowMenu
direction="left"
position="bottom"
class="bg-transparent"
:options="[
{
id: 'rename',
action: () => {
renameBackupName = backup.name;
currentBackup = backup.id;
renameBackupModal?.show();
},
},
{
id: 'restore',
action: () => {
currentBackup = backup.id;
restoreBackupModal?.show();
},
},
{ id: 'download', action: () => initiateDownload(backup.id) },
{
id: 'delete',
action: () => {
currentBackup = backup.id;
deleteBackupModal?.show();
},
color: 'red',
},
]"
>
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
<template #rename> <EditIcon /> Rename </template>
<template #restore> <ClipboardCopyIcon /> Restore </template>
<template #download> <DownloadIcon /> Download </template>
<template #delete> <TrashIcon /> Delete </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</li>
</ul>
<div
class="over-the-top-download-animation"
:class="{ 'animation-hidden': !overTheTopDownloadAnimation }"
>
<div>
<div
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
></div>
<div
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
></div>
<div
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
>
<DownloadIcon class="h-20 w-20 text-contrast" />
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useStorage } from "@vueuse/core";
import {
PlusIcon,
CheckIcon,
CalendarIcon,
MoreHorizontalIcon,
EditIcon,
ClipboardCopyIcon,
DownloadIcon,
TrashIcon,
SettingsIcon,
BoxIcon,
} from "@modrinth/assets";
import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
isServerRunning: boolean;
}>();
const route = useNativeRoute();
const serverId = route.params.id;
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
backupWhileRunning: false,
});
defineEmits(["onDownload"]);
const data = computed(() => props.server.general);
const backups = computed(() => {
if (!props.server.backups?.data) return [];
return [...props.server.backups.data].sort((a, b) => {
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
});
useHead({
title: `Backups - ${data.value?.name ?? "Server"} - Modrinth`,
});
const overTheTopDownloadAnimation = ref();
const createBackupModal = ref<typeof NewModal>();
const renameBackupModal = ref<typeof NewModal>();
const restoreBackupModal = ref<typeof NewModal>();
const deleteBackupModal = ref<typeof NewModal>();
const backupSettingsModal = ref<typeof NewModal>();
const renameBackupName = ref("");
const currentBackup = ref("");
const currentBackupDetails = computed(() => {
return backups.value.find((backup) => backup.id === currentBackup.value);
});
const showCreateModel = () => {
createBackupModal.value?.show();
};
const showbackupSettingsModal = () => {
backupSettingsModal.value?.show();
};
const handleBackupCreated = (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupRenamed = (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupRestored = (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupDeleted = (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
} else {
addNotification({ type: "error", text: payload.message });
}
};
function triggerDownloadAnimation() {
overTheTopDownloadAnimation.value = true;
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500);
}
const initiateDownload = async (backupId: string) => {
triggerDownloadAnimation();
try {
const downloadurl: any = await props.server.backups?.download(backupId);
if (!downloadurl || !downloadurl.download_url) {
throw new Error("Invalid download URL.");
}
let finalDownloadUrl = downloadurl.download_url;
if (!/^https?:\/\//i.test(finalDownloadUrl)) {
finalDownloadUrl = `${window.location.origin}${finalDownloadUrl.startsWith("/") ? "" : "/"}${finalDownloadUrl}`;
}
const a = document.createElement("a");
a.href = finalDownloadUrl;
a.setAttribute("download", "");
a.click();
a.remove();
} catch (error) {
console.error("Download failed:", error);
}
};
</script>
<style scoped>
.over-the-top-download-animation {
position: fixed;
z-index: 100;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
scale: 0.5;
transition: all 0.5s ease-out;
opacity: 1;
&.animation-hidden {
scale: 0.8;
opacity: 0;
.animation-ring-1 {
width: 25rem;
height: 25rem;
}
.animation-ring-2 {
width: 50rem;
height: 50rem;
}
.animation-ring-3 {
width: 100rem;
height: 100rem;
}
}
> div {
position: relative;
display: flex;
justify-content: center;
align-items: center;
width: fit-content;
height: fit-content;
> * {
position: absolute;
scale: 1;
transition: all 0.2s ease-out;
width: 20rem;
height: 20rem;
}
}
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<div class="flex h-full w-full flex-col">
<NuxtPage :route="route" :server="props.server" />
</div>
</template>
<script setup lang="ts">
import type { Server } from "~/composables/pyroServers";
const route = useNativeRoute();
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const data = computed(() => props.server.general);
useHead({
title: `Content - ${data.value?.name ?? "Server"} - Modrinth`,
});
</script>

View File

@@ -0,0 +1,507 @@
<template>
<NewModal ref="modModal" header="Edit mod version">
<div>
<div class="mb-4 flex flex-col gap-4">
<div class="inline-flex flex-wrap items-center">
You're changing the version of
<div class="inline-flex flex-wrap items-center gap-1 text-nowrap pl-2">
<UiAvatar
:src="currentMod?.icon_url"
size="24px"
class="inline-block"
alt="Server Icon"
/>
<strong>{{ currentMod?.name + "." }}</strong>
</div>
</div>
<div>
<div v-if="props.server.general?.upstream" class="flex items-center gap-2">
<InfoIcon class="hidden sm:block" />
<span class="text-sm text-secondary">
Your server was created from a modpack. Changing the mod version may cause unexpected
issues.
</span>
</div>
</div>
</div>
<div class="flex items-center gap-4">
<UiServersTeleportDropdownMenu
v-model="currentVersion"
name="Project"
:options="currentVersions"
placeholder="Select project..."
class="!w-full"
:display-name="
(version) => (typeof version === 'object' ? version?.version_number : version)
"
/>
</div>
<div class="mt-4 flex flex-row items-center gap-4">
<ButtonStyled color="brand">
<button :disabled="currentMod.changing" @click="changeModVersion">
<PlusIcon />
Install
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modModal.value.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col">
<div class="sticky top-0 z-20 -mt-4 flex items-center justify-between bg-bg py-4">
<div class="flex w-full flex-col items-center gap-2 sm:flex-row sm:gap-4">
<div class="flex w-full items-center gap-2 sm:gap-4">
<div class="relative flex-1 text-sm">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="!h-9 !min-h-0 w-full border-[1px] border-solid border-button-border pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search mods..."
@input="debouncedSearch"
/>
</div>
<ButtonStyled>
<UiServersTeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Filter mods"
:options="[
{ id: 'all', action: () => (filterMethod = 'all') },
{ id: 'enabled', action: () => (filterMethod = 'enabled') },
{ id: 'disabled', action: () => (filterMethod = 'disabled') },
]"
>
<span class="whitespace-pre text-sm font-medium">
{{ filterMethodLabel }}
</span>
<FilterIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #all> All mods </template>
<template #enabled> Only enabled </template>
<template #disabled> Only disabled </template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-if="hasMods" color="brand" type="outlined">
<nuxt-link
class="w-full text-nowrap sm:w-fit"
:to="`/mods?sid=${props.server.serverId}`"
>
<PlusIcon />
Add content
</nuxt-link>
</ButtonStyled>
</div>
</div>
<div v-if="hasMods" class="flex flex-col gap-2 transition-all">
<div ref="listContainer" class="relative w-full">
<div :style="{ position: 'relative', height: `${totalHeight}px` }">
<div :style="{ position: 'absolute', top: `${visibleTop}px`, width: '100%' }">
<template v-for="mod in visibleItems.items" :key="mod.filename">
<div
class="relative mb-2 flex w-full items-center justify-between rounded-xl bg-bg-raised"
:class="mod.disabled ? 'bg-table-alternateRow text-secondary' : ''"
style="height: 64px"
>
<NuxtLink
:to="
mod.project_id
? `/project/${mod.project_id}/version/${mod.version_id}`
: `files?path=mods`
"
class="group flex min-w-0 items-center rounded-xl p-2"
>
<div class="flex min-w-0 items-center gap-2">
<UiAvatar
:src="mod.icon_url"
size="sm"
alt="Server Icon"
:class="mod.disabled ? 'grayscale' : ''"
/>
<div class="flex min-w-0 flex-col">
<span class="flex min-w-0 items-center gap-2 text-lg font-bold">
<span class="truncate">{{
mod.name || mod.filename.replace(".disabled", "")
}}</span>
<span
v-if="mod.disabled"
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
>Disabled</span
>
</span>
<span class="min-w-0 text-xs text-secondary">{{
mod.version_number || "External mod"
}}</span>
</div>
</div>
</NuxtLink>
<div class="flex items-center gap-2 pr-4 font-semibold text-contrast">
<ButtonStyled v-if="mod.project_id" type="transparent">
<button
v-tooltip="'Edit mod version'"
:disabled="mod.changing"
class="!hidden sm:!block"
@click="beginChangeModVersion(mod)"
>
<template v-if="mod.changing">
<UiServersIconsLoadingIcon />
</template>
<template v-else>
<EditIcon />
</template>
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
v-tooltip="'Delete mod'"
:disabled="mod.changing"
class="!hidden sm:!block"
@click="removeMod(mod)"
>
<TrashIcon />
</button>
</ButtonStyled>
<!-- Dropdown for mobile -->
<div class="mr-2 flex items-center sm:hidden">
<UiServersIconsLoadingIcon
v-if="mod.changing"
class="mr-2 h-5 w-5 animate-spin"
style="color: var(--color-base)"
/>
<ButtonStyled v-else circular type="transparent">
<UiServersTeleportOverflowMenu
:options="[
{
id: 'edit',
action: () => beginChangeModVersion(mod),
shown: !!(mod.project_id && !mod.changing),
},
{
id: 'delete',
action: () => removeMod(mod),
},
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #edit>
<EditIcon class="h-5 w-5" />
<span>Edit</span>
</template>
<template #delete>
<TrashIcon class="h-5 w-5" />
<span>Delete</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</div>
<input
:id="`toggle-${mod.filename}`"
:checked="!mod.disabled"
:disabled="mod.changing"
class="switch stylized-toggle"
type="checkbox"
@change="toggleMod(mod)"
/>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<div v-else class="mt-4 flex h-full flex-col items-center justify-center text-center">
<PackageClosedIcon class="size-24 text-neutral-500" />
<p class="m-0 pb-2 pt-3 text-neutral-200">No mods found!</p>
<p class="m-0 pb-3 text-neutral-400">Add some mods to your server to manage them here.</p>
<ButtonStyled color="brand" class="mt-8">
<nuxt-link :to="`/mods?sid=${props.server.serverId}`">Add content</nuxt-link>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
SearchIcon,
EditIcon,
TrashIcon,
PackageClosedIcon,
FilterIcon,
DropdownIcon,
InfoIcon,
XIcon,
PlusIcon,
MoreVerticalIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
interface Mod {
name?: string;
filename: string;
project_id?: string;
version_id?: string;
version_number?: string;
icon_url?: string;
disabled: boolean;
changing?: boolean;
}
const ITEM_HEIGHT = 72;
const BUFFER_SIZE = 5;
const listContainer = ref<HTMLElement | null>(null);
const windowScrollY = ref(0);
const windowHeight = ref(0);
const localMods = ref<Mod[]>([]);
const searchInput = ref("");
const modSearchInput = ref("");
const filterMethod = ref("all");
const filterMethodLabel = computed(() => {
switch (filterMethod.value) {
case "disabled":
return "Only disabled";
case "enabled":
return "Only enabled";
default:
return "All mods";
}
});
const totalHeight = computed(() => {
const itemsHeight = filteredMods.value.length * ITEM_HEIGHT;
return itemsHeight;
});
const getVisibleRange = () => {
if (!listContainer.value) return { start: 0, end: 0 };
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY;
const scrollTop = Math.max(0, windowScrollY.value - containerTop);
const start = Math.floor(scrollTop / ITEM_HEIGHT);
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT);
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(filteredMods.value.length, start + visibleCount + BUFFER_SIZE * 2),
};
};
const visibleTop = computed(() => {
const range = getVisibleRange();
return range.start * ITEM_HEIGHT;
});
const visibleItems = computed(() => {
const range = getVisibleRange();
const items = filteredMods.value;
return {
items: items.slice(Math.max(0, range.start), Math.min(items.length, range.end)),
};
});
const handleScroll = () => {
windowScrollY.value = window.scrollY;
};
const handleResize = () => {
windowHeight.value = window.innerHeight;
};
onMounted(() => {
windowHeight.value = window.innerHeight;
window.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", handleResize, { passive: true });
handleScroll();
});
onUnmounted(() => {
window.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", handleResize);
});
watch(
() => props.server.mods?.data,
(newMods) => {
if (newMods) {
localMods.value = [...newMods];
}
},
{ immediate: true },
);
const debounce = <T extends (...args: any[]) => void>(
func: T,
wait: number,
): ((...args: Parameters<T>) => void) => {
let timeout: ReturnType<typeof setTimeout>;
return function (...args: Parameters<T>): void {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
};
const pyroContentSentinel = ref<HTMLElement | null>(null);
const debouncedSearch = debounce(() => {
modSearchInput.value = searchInput.value;
if (pyroContentSentinel.value) {
pyroContentSentinel.value.scrollIntoView({
behavior: "smooth",
block: "start",
});
}
}, 300);
async function toggleMod(mod: Mod) {
mod.changing = true;
const originalFilename = mod.filename;
try {
const newFilename = mod.filename.endsWith(".disabled")
? mod.filename.replace(".disabled", "")
: `${mod.filename}.disabled`;
const sourcePath = `/mods/${mod.filename}`;
const destinationPath = `/mods/${newFilename}`;
mod.disabled = newFilename.endsWith(".disabled");
mod.filename = newFilename;
await props.server.fs?.moveFileOrFolder(sourcePath, destinationPath);
await props.server.refresh(["general", "mods"]);
} catch (error) {
mod.filename = originalFilename;
mod.disabled = originalFilename.endsWith(".disabled");
console.error("Error toggling mod:", error);
addNotification({
text: `Something went wrong toggling ${mod.name || mod.filename.replace(".disabled", "")}`,
type: "error",
});
}
mod.changing = false;
}
async function removeMod(mod: Mod) {
mod.changing = true;
try {
await props.server.mods?.remove(`/mods/${mod.filename}`);
await props.server.refresh(["general", "mods"]);
} catch (error) {
console.error("Error removing mod:", error);
addNotification({
text: `couldn't remove ${mod.name || mod.filename}`,
type: "error",
});
}
mod.changing = false;
}
const modModal = ref();
const currentMod = ref();
const currentVersions = ref();
const currentVersion = ref();
async function beginChangeModVersion(mod: Mod) {
currentMod.value = mod;
currentVersions.value = await useBaseFetch(`project/${mod.project_id}/version`, {}, false, true);
currentVersion.value = currentVersions.value.find(
(version: any) => version.id === mod.version_id,
);
modModal.value.show();
}
async function changeModVersion() {
currentMod.value.changing = true;
try {
modModal.value.hide();
await props.server.mods?.remove(`/mods/${currentMod.value.filename}`);
await props.server.mods?.install(currentMod.value.project_id, currentVersion.value.id);
await props.server.refresh(["general", "mods"]);
} catch (error) {
console.error("Error changing mod version:", error);
}
currentMod.value.changing = false;
}
const hasMods = computed(() => {
return filteredMods.value?.length > 0;
});
const filteredMods = computed(() => {
const mods = modSearchInput.value.trim()
? localMods.value.filter(
(mod) =>
mod.name?.toLowerCase().includes(modSearchInput.value.toLowerCase()) ||
mod.filename.toLowerCase().includes(modSearchInput.value.toLowerCase()),
)
: localMods.value;
const statusFilteredMods = (() => {
switch (filterMethod.value) {
case "disabled":
return mods.filter((mod) => mod.disabled);
case "enabled":
return mods.filter((mod) => !mod.disabled);
default:
return mods;
}
})();
return statusFilteredMods.sort((a, b) => {
const aName = a.name || a.filename.replace(".disabled", "");
const bName = b.name || b.filename.replace(".disabled", "");
return aName.localeCompare(bName);
});
});
</script>
<style scoped>
.sentinel {
position: absolute;
top: -1rem;
left: 0;
right: 0;
height: 1px;
visibility: hidden;
}
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -0,0 +1,736 @@
<template>
<div data-pyro-file-manager-root class="contents">
<LazyUiServersFilesCreateItemModal
ref="createItemModal"
:type="newItemType"
@create="handleCreateNewItem"
/>
<LazyUiServersFilesRenameItemModal
ref="renameItemModal"
:item="selectedItem"
@rename="handleRenameItem"
/>
<LazyUiServersFilesMoveItemModal
ref="moveItemModal"
:item="selectedItem"
:current-path="currentPath"
@move="handleMoveItem"
/>
<LazyUiServersFilesDeleteItemModal
ref="deleteItemModal"
:item="selectedItem"
@delete="handleDeleteItem"
/>
<div
class="relative flex w-full flex-col rounded-2xl border border-solid border-bg-raised"
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div ref="mainContent" class="relative isolate flex w-full flex-col">
<UiServersFilesBrowseNavbar
v-if="!isEditing"
:breadcrumb-segments="breadcrumbSegments"
:search-query="searchQuery"
:sort-method="sortMethod"
@navigate="navigateToSegment"
@sort="sortFiles"
@create="showCreateModal"
@upload="initiateFileUpload"
@update:search-query="searchQuery = $event"
/>
<UiServersFilesEditingNavbar
v-else
:file-name="editingFile?.name"
:is-image="isEditingImage"
:file-path="editingFile?.path"
:breadcrumb-segments="breadcrumbSegments"
@cancel="cancelEditing"
@save="() => saveFileContent(true)"
@save-as="saveFileContentAs"
@save-restart="saveFileContentRestart"
@share="requestShareLink"
@navigate="navigateToSegment"
/>
<div v-if="isEditing" class="h-full w-full flex-grow">
<component
:is="VAceEditor"
v-if="!isEditingImage"
v-model:value="fileContent"
lang="json"
theme="one_dark"
:print-margin="false"
style="height: 750px; font-size: 1rem"
class="ace_editor ace_hidpi ace-one-dark ace_dark rounded-b-lg"
@init="onInit"
/>
<UiServersFilesImageViewer v-else :image-blob="imagePreview" />
</div>
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
<UiServersFileVirtualList
:items="filteredItems"
@delete="showDeleteModal"
@rename="showRenameModal"
@download="downloadFile"
@move="showMoveModal"
@edit="editFile"
@contextmenu="showContextMenu"
@load-more="handleLoadMore"
/>
</div>
<div
v-else-if="!isLoading && items.length === 0"
class="flex h-full w-full items-center justify-center p-20"
>
<div class="flex flex-col items-center gap-4 text-center">
<FolderOpenIcon class="h-16 w-16 text-secondary" />
<h3 class="text-2xl font-bold text-contrast">This folder is empty</h3>
<p class="m-0 text-sm text-secondary">There are no files or folders.</p>
</div>
</div>
<LazyUiServersFileManagerError
v-else-if="!isLoading"
title="Unable to list files"
message="Unfortunately, we were unable to list the files in this folder. If this issue persists, contact support."
@refetch="refreshList"
@home="navigateToSegment(-1)"
/>
<LazyUiServersFileManagerError
v-else-if="loadError"
title="Unable to fetch files"
message="This path is invalid or the server is not responding."
@refetch="refreshList"
@home="navigateToSegment(-1)"
/>
</div>
<div
v-if="isDragging"
class="absolute inset-0 flex items-center justify-center rounded-xl bg-black bg-opacity-50 text-white"
>
<div class="text-center">
<UploadIcon class="mx-auto h-16 w-16" />
<p class="mt-2 text-xl">Drop files here to upload</p>
</div>
</div>
</div>
<UiServersFilesContextMenu
ref="contextMenu"
:item="contextMenuInfo.item"
:x="contextMenuInfo.x"
:y="contextMenuInfo.y"
:is-at-bottom="isAtBottom"
@rename="showRenameModal"
@move="showMoveModal"
@download="downloadFile"
@delete="showDeleteModal"
/>
</div>
</template>
<script setup lang="ts">
import { useInfiniteScroll } from "@vueuse/core";
import { UploadIcon, FolderOpenIcon } from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
const route = useRoute();
const router = useRouter();
const VAceEditor = ref();
const mainContent = ref<HTMLElement | null>(null);
const scrollContainer = ref<HTMLElement | null>(null);
const contextMenu = ref();
const searchQuery = ref("");
const sortMethod = ref("default");
const maxResults = 100;
const currentPage = ref(1);
const currentPath = ref(typeof route.query.path === "string" ? route.query.path : "");
const isAtBottom = ref(false);
const contextMenuInfo = ref<any>({ item: null, x: 0, y: 0 });
const createItemModal = ref();
const renameItemModal = ref();
const moveItemModal = ref();
const deleteItemModal = ref();
const newItemType = ref<"file" | "directory">("file");
const selectedItem = ref<any>(null);
const fileContent = ref("");
const isEditing = ref(false);
const editingFile = ref<any>(null);
const closeEditor = ref(false);
const isEditingImage = ref(false);
const imagePreview = ref();
const isDragging = ref(false);
const dragCounter = ref(0);
const data = computed(() => props.server.general);
useHead({
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
});
const fetchDirectoryContents = async (): Promise<{ items: any[]; total: number }> => {
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
try {
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
if (!data || !data.items) {
throw new Error("Invalid data structure received from server.");
}
if (currentPage.value === 1) {
return {
items: applyDefaultSort(data.items),
total: data.total,
};
}
return {
items: [...(directoryData.value?.items || []), ...applyDefaultSort(data.items)],
total: data.total,
};
} catch (error) {
console.error("Error fetching directory contents:", error);
if (error instanceof PyroFetchError && error.statusCode === 400) {
return directoryData.value || { items: [], total: 0 };
}
throw error;
}
};
const {
data: directoryData,
refresh: refreshData,
status,
error: loadError,
} = useLazyAsyncData(() => fetchDirectoryContents(), {
watch: [],
default: () => ({ items: [], total: 0 }),
immediate: true,
});
const isLoading = computed(() => status.value === "pending");
const items = computed(() => directoryData.value?.items || []);
const refreshList = () => {
currentPage.value = 1;
refreshData();
reset();
};
const handleCreateNewItem = async (name: string) => {
try {
const path = `${currentPath.value}/${name}`.replace("//", "/");
await props.server.fs?.createFileOrFolder(path, newItemType.value);
refreshList();
addNotification({
group: "files",
title: "File created",
text: "Your file has been created.",
type: "success",
});
} catch (error) {
handleCreateError(error);
}
};
const handleRenameItem = async (newName: string) => {
try {
const path = `${currentPath.value}/${selectedItem.value.name}`.replace("//", "/");
await props.server.fs?.renameFileOrFolder(path, newName);
refreshList();
if (closeEditor.value) {
await props.server.refresh();
isEditing.value = false;
editingFile.value = null;
closeEditor.value = false;
router.push({ query: { ...route.query, path: currentPath.value } });
}
addNotification({
group: "files",
title: "File renamed",
text: "Your file has been renamed.",
type: "success",
});
} catch (error) {
handleRenameError(error);
}
};
const handleMoveItem = async (destination: string) => {
try {
await props.server.fs?.moveFileOrFolder(
`${currentPath.value}/${selectedItem.value.name}`.replace("//", "/"),
`${destination}/${selectedItem.value.name}`.replace("//", "/"),
);
refreshList();
addNotification({
group: "files",
title: "File moved",
text: "Your file has been moved.",
type: "success",
});
} catch (error) {
console.error("Error moving item:", error);
}
};
const handleDeleteItem = async () => {
try {
const path = `${currentPath.value}/${selectedItem.value.name}`.replace("//", "/");
await props.server.fs?.deleteFileOrFolder(path, selectedItem.value.type === "directory");
refreshList();
addNotification({
group: "files",
title: "File deleted",
text: "Your file has been deleted.",
type: "success",
});
} catch (error) {
console.error("Error deleting item:", error);
}
};
const showCreateModal = (type: "file" | "directory") => {
newItemType.value = type;
createItemModal.value?.show();
};
const showRenameModal = (item: any) => {
selectedItem.value = item;
renameItemModal.value?.show(item);
contextMenuInfo.value.item = null;
};
const showMoveModal = (item: any) => {
selectedItem.value = item;
moveItemModal.value?.show();
contextMenuInfo.value.item = null;
};
const showDeleteModal = (item: any) => {
selectedItem.value = item;
deleteItemModal.value?.show();
contextMenuInfo.value.item = null;
};
const handleCreateError = (error: any) => {
console.error("Error creating item:", error);
if (error instanceof PyroFetchError) {
if (error.statusCode === 400) {
addNotification({
group: "files",
title: "Error creating item",
text: "Invalid file",
type: "error",
});
} else if (error.statusCode === 500) {
addNotification({
group: "files",
title: "Error creating item",
text: "File already exists",
type: "error",
});
}
}
};
const handleRenameError = (error: any) => {
console.error("Error renaming item:", error);
if (error instanceof PyroFetchError) {
if (error.statusCode === 400) {
addNotification({
group: "files",
title: "Could not rename item",
text: "This item already exists or is invalid.",
type: "error",
});
} else if (error.statusCode === 500) {
addNotification({
group: "files",
title: "Could not rename item",
text: "Invalid file",
type: "error",
});
}
}
};
const applyDefaultSort = (items: any[]) => {
return items.sort((a: any, b: any) => {
if (a.type === "directory" && b.type !== "directory") return -1;
if (a.type !== "directory" && b.type === "directory") return 1;
if (a.count > b.count) return -1;
if (a.count < b.count) return 1;
return a.name.localeCompare(b.name);
});
};
const filteredItems = computed(() => {
let result = [...items.value];
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase();
result = result.filter((item) => item.name.toLowerCase().includes(query));
}
switch (sortMethod.value) {
case "modified":
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
break;
case "filesOnly":
result = result.filter((item) => item.type !== "directory");
break;
case "foldersOnly":
result = result.filter((item) => item.type === "directory");
break;
default:
result = applyDefaultSort(result);
}
return result;
});
const { reset } = useInfiniteScroll(
scrollContainer,
async () => {
if (status.value === "pending") return;
try {
const totalPages = directoryData.value?.total || 0;
if (currentPage.value < totalPages) {
currentPage.value++;
const newData = await fetchDirectoryContents();
if (newData && newData.items) {
directoryData.value = {
items: [...directoryData.value.items, ...newData.items],
total: newData.total,
};
}
}
} catch (error) {
console.error("Error during infinite scroll:", error);
}
},
{ distance: 1000 },
);
const handleLoadMore = async () => {
if (status.value === "pending") return;
const totalPages = directoryData.value?.total || 0;
if (currentPage.value < totalPages) {
currentPage.value++;
await refreshData();
}
};
const onInit = (editor: any) => {
editor.commands.addCommand({
name: "saveFile",
bindKey: { win: "Ctrl-S", mac: "Command-S" },
exec: () => saveFileContent(false),
});
};
const showContextMenu = async (item: any, x: number, y: number) => {
contextMenuInfo.value = { item, x, y };
selectedItem.value = item;
await nextTick();
if (!contextMenu.value?.ctxRef) return false;
const screenHeight = window.innerHeight;
const ctxRect = contextMenu.value.ctxRef.getBoundingClientRect();
isAtBottom.value = ctxRect.bottom > screenHeight;
};
const onAnywhereClicked = (e: MouseEvent) => {
if (!(e.target as HTMLElement).closest("#item-context-menu")) {
contextMenuInfo.value.item = null;
}
};
const sortFiles = (method: string) => {
sortMethod.value = method;
};
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"];
const editFile = async (item: { name: string; type: string; path: string }) => {
try {
const path = `${currentPath.value}/${item.name}`.replace("//", "/");
const content = (await props.server.fs?.downloadFile(path, true)) as any;
window.scrollTo(0, 0);
fileContent.value = await content.text();
editingFile.value = item;
isEditing.value = true;
const extension = item.name.split(".").pop();
if (item.type === "file" && extension && imageExtensions.includes(extension)) {
isEditingImage.value = true;
imagePreview.value = content;
}
router.push({ query: { ...route.query, path: currentPath.value, editing: item.path } });
} catch (error) {
console.error("Error fetching file content:", error);
}
};
onMounted(async () => {
await import("ace-builds");
await import("ace-builds/src-noconflict/mode-json");
await import("ace-builds/src-noconflict/theme-one_dark");
VAceEditor.value = markRaw((await import("vue3-ace-editor")).VAceEditor);
document.addEventListener("click", onAnywhereClicked);
window.addEventListener("scroll", onScroll);
});
onUnmounted(() => {
document.removeEventListener("click", onAnywhereClicked);
window.removeEventListener("scroll", onScroll);
});
watch(
() => route.query,
async (newQuery) => {
currentPage.value = 1;
searchQuery.value = "";
sortMethod.value = "default";
currentPath.value = Array.isArray(newQuery.path)
? newQuery.path.join("")
: newQuery.path || "/";
if (newQuery.editing) {
await editFile({
name: newQuery.editing as string,
type: "file",
path: newQuery.editing as string,
});
} else {
isEditing.value = false;
editingFile.value = null;
}
await refreshData();
reset();
},
{ immediate: true, deep: true },
);
const breadcrumbSegments = computed(() => {
if (typeof currentPath.value === "string") {
return currentPath.value.split("/").filter(Boolean);
}
return [];
});
const navigateToSegment = (index: number) => {
const newPath = breadcrumbSegments.value.slice(0, index + 1).join("/");
router.push({ query: { ...route.query, path: newPath } });
if (isEditing.value) {
isEditing.value = false;
editingFile.value = null;
closeEditor.value = false;
const newQuery = { ...route.query };
delete newQuery.editing;
router.replace({ query: newQuery });
}
};
// const navigateToPage = () => {
// router.push({ query: { path: currentPath.value } });
// };
const requestShareLink = async () => {
try {
const response = (await $fetch("https://api.mclo.gs/1/log", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ content: fileContent.value }),
})) as any;
if (response.success) {
await navigator.clipboard.writeText(response.url);
addNotification({
group: "files",
title: "Log URL copied",
text: "Your log file URL has been copied to your clipboard.",
type: "success",
});
} else {
throw new Error(response.error);
}
} catch (error) {
console.error("Error sharing file:", error);
}
};
const handleDragEnter = (event: DragEvent) => {
if (isEditing.value) return;
event.preventDefault();
dragCounter.value++;
isDragging.value = true;
};
const handleDragOver = (event: DragEvent) => {
if (isEditing.value) return;
event.preventDefault();
};
const handleDragLeave = (event: DragEvent) => {
if (isEditing.value) return;
event.preventDefault();
dragCounter.value--;
if (dragCounter.value === 0) {
isDragging.value = false;
}
};
const handleDrop = async (event: DragEvent) => {
if (isEditing.value) return;
event.preventDefault();
isDragging.value = false;
dragCounter.value = 0;
const files = event.dataTransfer?.files;
if (files) {
for (let i = 0; i < files.length; i++) {
await uploadFile(files[i]);
}
}
};
const uploadFile = async (file: File) => {
try {
const filePath = `${currentPath.value}/${file.name}`.replace("//", "/");
await props.server.fs?.uploadFile(filePath, file);
refreshList();
addNotification({
group: "files",
title: "File uploaded",
text: "Your file has been uploaded.",
type: "success",
});
} catch (error) {
console.error("Error uploading file:", error);
}
};
const initiateFileUpload = () => {
const input = document.createElement("input");
input.type = "file";
input.onchange = async () => {
const file = input.files?.[0];
if (file) {
await uploadFile(file);
}
};
input.click();
};
const downloadFile = async (item: any) => {
if (item.type === "file") {
try {
const path = `${currentPath.value}/${item.name}`.replace("//", "/");
const fileData = await props.server.fs?.downloadFile(path);
if (fileData) {
const blob = new Blob([fileData], { type: "application/octet-stream" });
const link = document.createElement("a");
link.href = window.URL.createObjectURL(blob);
link.download = item.name;
link.click();
window.URL.revokeObjectURL(link.href);
} else {
throw new Error("File data is undefined");
}
} catch (error) {
console.error("Error downloading file:", error);
}
contextMenuInfo.value.item = null;
}
};
const saveFileContent = async (exit: boolean = true) => {
if (!editingFile.value) return;
try {
await props.server.fs?.updateFile(editingFile.value.path, fileContent.value);
if (exit) {
await props.server.refresh();
isEditing.value = false;
editingFile.value = null;
router.push({ query: { ...route.query, path: currentPath.value } });
}
addNotification({
group: "files",
title: "File saved",
text: "Your file has been saved.",
type: "success",
});
} catch (error) {
console.error("Error saving file content:", error);
}
};
const saveFileContentRestart = async () => {
await saveFileContent();
await props.server.general?.power("Restart");
};
const saveFileContentAs = async () => {
await saveFileContent(false);
closeEditor.value = true;
showRenameModal(editingFile.value);
};
const cancelEditing = () => {
isEditing.value = false;
editingFile.value = null;
fileContent.value = "";
isEditingImage.value = false;
imagePreview.value = null;
router.push({ query: { ...route.query, path: currentPath.value } });
const newQuery = { ...route.query };
delete newQuery.editing;
router.replace({ query: newQuery });
};
const onScroll = () => {
if (contextMenuInfo.value.item) {
contextMenuInfo.value.y = Math.max(0, contextMenuInfo.value.y - window.scrollY);
}
};
</script>

View File

@@ -0,0 +1,710 @@
<template>
<div
v-if="isConnected && !isWsAuthIncorrect"
class="relative flex select-none flex-col gap-6"
data-pyro-server-manager-root
>
<div
v-if="inspectingError"
data-pyro-servers-inspecting-error
class="flex justify-between rounded-2xl border-2 border-solid border-red bg-bg-red p-4 font-semibold text-contrast"
>
<div class="flex w-full justify-between gap-2">
<div v-if="inspectingError.analysis.problems.length" class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">
{{ serverData?.name }} shut down unexpectedly. We've automatically analyzed the logs
and found the following problems:
</div>
<li
v-for="problem in inspectingError.analysis.problems"
:key="problem.message"
class="list-none"
>
<h4 class="m-0 text-sm font-normal sm:text-lg sm:font-semibold">
{{ problem.message }}
</h4>
<ul class="m-0 ml-6">
<li v-for="solution in problem.solutions" :key="solution.message">
<span class="m-0 text-sm font-normal">{{ solution.message }}</span>
</li>
</ul>
</li>
</div>
</div>
<div v-else class="flex flex-row gap-4">
<IssuesIcon class="hidden h-8 w-8 text-red sm:block" />
<div class="flex flex-col gap-2">
<div class="font-semibold">{{ serverData?.name }} shut down unexpectedly.</div>
<div class="font-normal">
We could not find any specific problems, but you can try restarting the server.
</div>
</div>
</div>
<ButtonStyled color="red" @click="clearError">
<button>
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-col-reverse gap-6 md:flex-col">
<UiServersServerStats :data="stats" />
<div
class="relative flex h-[600px] 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"
>
<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>
<UiServersPanelServerStatus :state="serverPowerState" />
</div>
</div>
<UiServersPanelTerminal :console-output="consoleOutput" :full-screen="fullScreen">
<div class="relative w-full px-4 pt-4">
<ul
v-if="suggestions.length"
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"
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"
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>
</UiServersPanelTerminal>
</div>
</div>
</div>
<UiServersPanelOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
<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 v-else class="flex flex-col">
<h2>Could not connect to the server.</h2>
<p>
An error occurred while attempting to connect to your server. Please try refreshing the page.
(No further information)
</p>
</div>
</template>
<script setup lang="ts">
import { TerminalSquareIcon, XIcon, IssuesIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import type { ServerState, Stats } from "~/types/servers";
import type { Server } from "~/composables/pyroServers";
type ServerProps = {
socket: WebSocket | null;
isConnected: boolean;
isWsAuthIncorrect: boolean;
stats: Stats;
consoleOutput: string[];
serverPowerState: ServerState;
isServerRunning: boolean;
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
};
const props = defineProps<ServerProps>();
interface ErrorData {
id: string;
name: string;
type: string;
version: string;
title: string;
analysis: {
problems: Array<{
message: string;
counter: number;
entry: {
level: number;
time: string | null;
prefix: string;
lines: Array<{ number: number; content: string }>;
};
solutions: Array<{ message: string }>;
}>;
information: Array<{
message: string;
counter: number;
label: string;
value: string;
entry: {
level: number;
time: string | null;
prefix: string;
lines: Array<{ number: number; content: string }>;
};
}>;
};
}
const inspectingError = ref<ErrorData | null>(null);
const mcError = ref<any>(null);
const inspectError = async () => {
const log = await props.server.fs?.downloadFile("logs/latest.log");
const response = (await $fetch("https://api.mclo.gs/1/log", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
content: log,
}),
})) as any;
mcError.value = response;
const analysis = (await $fetch(`https://api.mclo.gs/1/insights/${response.id}`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
content: log,
}),
})) as ErrorData;
inspectingError.value = analysis;
};
const clearError = () => {
inspectingError.value = null;
mcError.value = null;
};
watch(
() => props.serverPowerState,
(newVal) => {
if (newVal === "crashed") {
inspectError();
} else {
clearError();
}
},
);
if (props.serverPowerState === "crashed") {
inspectError();
}
const socket = ref(props.socket);
watch(props, (newAttrs) => {
socket.value = newAttrs.socket;
});
const DYNAMIC_ARG = Symbol("DYNAMIC_ARG");
const commandTree: any = {
advancement: {
grant: {
[DYNAMIC_ARG]: {
everything: null,
only: {
[DYNAMIC_ARG]: null,
},
from: {
[DYNAMIC_ARG]: null,
},
through: {
[DYNAMIC_ARG]: null,
},
until: {
[DYNAMIC_ARG]: null,
},
},
},
revoke: {
[DYNAMIC_ARG]: {
everything: null,
only: {
[DYNAMIC_ARG]: null,
},
from: {
[DYNAMIC_ARG]: null,
},
through: {
[DYNAMIC_ARG]: null,
},
until: {
[DYNAMIC_ARG]: null,
},
},
},
},
ban: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
duration: {
[DYNAMIC_ARG]: null,
},
},
},
ban_ip: null,
banlist: {
ips: null,
players: null,
all: null,
},
bossbar: {
add: null,
get: null,
list: null,
remove: null,
set: null,
},
clear: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
reason: null,
},
},
},
clone: null,
data: {
get: null,
merge: null,
modify: null,
remove: null,
},
datapack: {
disable: null,
enable: null,
list: null,
reload: null,
},
debug: {
start: null,
stop: null,
function: null,
memory: null,
},
defaultgamemode: {
survival: null,
creative: null,
adventure: null,
spectator: null,
},
deop: null,
difficulty: {
peaceful: null,
easy: null,
normal: null,
hard: null,
},
effect: {
give: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
true: null,
false: null,
},
},
},
},
},
clear: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
enchant: null,
execute: null,
experience: {
add: null,
set: null,
query: null,
},
fill: null,
forceload: {
add: null,
remove: null,
query: null,
},
function: null,
gamemode: {
survival: {
[DYNAMIC_ARG]: null,
},
creative: {
[DYNAMIC_ARG]: null,
},
adventure: {
[DYNAMIC_ARG]: null,
},
spectator: {
[DYNAMIC_ARG]: null,
},
},
gamerule: null,
give: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
help: null,
kick: null,
kill: {
[DYNAMIC_ARG]: null,
},
list: null,
locate: {
biome: null,
poi: null,
structure: null,
},
loot: {
give: null,
insert: null,
replace: null,
spawn: null,
},
me: null,
msg: null,
op: null,
pardon: null,
pardon_ip: null,
particle: null,
playsound: null,
recipe: {
give: null,
take: null,
},
reload: null,
say: null,
schedule: {
function: null,
clear: null,
},
scoreboard: {
objectives: {
add: null,
remove: null,
setdisplay: null,
list: null,
modify: null,
},
players: {
add: null,
remove: null,
set: null,
get: null,
list: null,
enable: null,
operation: null,
reset: null,
},
},
seed: null,
setblock: null,
setidletimeout: null,
setworldspawn: null,
spawnpoint: null,
spectate: null,
spreadplayers: null,
stop: null,
stopsound: null,
summon: null,
tag: {
add: null,
list: null,
remove: null,
},
team: {
add: null,
empty: null,
join: null,
leave: null,
list: null,
modify: null,
remove: null,
},
teleport: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
},
tp: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: {
[DYNAMIC_ARG]: null,
},
},
},
trigger: null,
weather: {
clear: {
[DYNAMIC_ARG]: null,
},
rain: {
[DYNAMIC_ARG]: null,
},
thunder: {
[DYNAMIC_ARG]: null,
},
},
whitelist: {
add: null,
list: null,
off: null,
on: null,
reload: null,
remove: null,
},
worldborder: {
add: null,
center: null,
damage: {
amount: null,
buffer: null,
},
get: null,
set: null,
warning: {
distance: null,
time: null,
},
},
xp: null,
};
const fullScreen = ref(false);
const commandInput = ref("");
const suggestions = ref<string[]>([]);
const selectedSuggestionIndex = ref(0);
const serverData = computed(() => props.server.general);
// const serverIP = computed(() => serverData.value?.net.ip ?? "");
// const serverPort = computed(() => serverData.value?.net.port ?? 0);
// const serverDomain = computed(() => serverData.value?.net.domain ?? "");
const suggestionsList = ref<HTMLUListElement | null>(null);
useHead({
title: `Overview - ${serverData.value?.name ?? "Server"} - Modrinth`,
});
const bestSuggestion = computed(() => {
if (!suggestions.value.length) return "";
const inputTokens = commandInput.value.trim().split(/\s+/);
let lastInputToken = inputTokens[inputTokens.length - 1] || "";
if (inputTokens.length - 1 === 0 && lastInputToken.startsWith("/")) {
lastInputToken = lastInputToken.slice(1);
}
const selectedSuggestion = suggestions.value[selectedSuggestionIndex.value];
const suggestionTokens = selectedSuggestion.split(/\s+/);
const lastSuggestionToken = suggestionTokens[suggestionTokens.length - 1] || "";
if (lastSuggestionToken.toLowerCase().startsWith(lastInputToken.toLowerCase())) {
return lastSuggestionToken.slice(lastInputToken.length);
}
return "";
});
const getSuggestions = (input: string): string[] => {
const trimmedInput = input.trim();
const inputWithoutSlash = trimmedInput.startsWith("/") ? trimmedInput.slice(1) : trimmedInput;
const tokens = inputWithoutSlash.split(/\s+/);
let currentLevel: any = commandTree;
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i].toLowerCase();
if (currentLevel?.[token]) {
currentLevel = currentLevel[token] as any;
} else if (currentLevel?.[DYNAMIC_ARG]) {
currentLevel = currentLevel[DYNAMIC_ARG] as any;
} else {
if (i === tokens.length - 1) {
break;
}
currentLevel = null;
break;
}
}
if (currentLevel) {
const lastToken = tokens[tokens.length - 1]?.toLowerCase() || "";
const possibleKeys = Object.keys(currentLevel);
if (currentLevel[DYNAMIC_ARG]) {
possibleKeys.push("<arg>");
}
return possibleKeys
.filter((key) => key === "<arg>" || key.toLowerCase().startsWith(lastToken))
.filter((k) => k !== lastToken.trim())
.map((key) => {
if (key === "<arg>") {
return [...tokens.slice(0, -1), "<arg>"].join(" ");
}
const newTokens = [...tokens.slice(0, -1), key];
return newTokens.join(" ");
});
}
return [];
};
const sendCommand = () => {
const cmd = commandInput.value.trim();
if (!socket || !cmd) return;
try {
sendConsoleCommand(cmd);
commandInput.value = "";
suggestions.value = [];
selectedSuggestionIndex.value = 0;
} catch (error) {
console.error("Error sending command:", error);
}
};
const sendConsoleCommand = (cmd: string) => {
try {
socket.value?.send(JSON.stringify({ event: "command", cmd }));
} catch (error) {
console.error("Error sending command:", error);
}
};
watch(
() => selectedSuggestionIndex.value,
(newVal) => {
if (suggestionsList.value) {
const selectedSuggestion = suggestionsList.value.querySelector(`#suggestion-${newVal}`);
if (selectedSuggestion) {
selectedSuggestion.scrollIntoView({ block: "nearest" });
}
}
},
);
watch(
() => commandInput.value,
(newVal) => {
const trimmed = newVal.trim();
if (!trimmed) {
suggestions.value = [];
return;
}
suggestions.value = getSuggestions(newVal);
selectedSuggestionIndex.value = 0;
},
);
const selectNextSuggestion = () => {
if (suggestions.value.length === 0) return;
selectedSuggestionIndex.value = (selectedSuggestionIndex.value + 1) % suggestions.value.length;
};
const selectPrevSuggestion = () => {
if (suggestions.value.length === 0) return;
selectedSuggestionIndex.value =
(selectedSuggestionIndex.value - 1 + suggestions.value.length) % suggestions.value.length;
};
const acceptSuggestion = () => {
if (suggestions.value.filter((s) => s !== "<arg>").length === 0) return;
const selected = suggestions.value[selectedSuggestionIndex.value];
const currentTokens = commandInput.value.trim().split(" ");
const suggestionTokens = selected.split(/\s+/).filter(Boolean);
// check if last current token is in command tree if so just add to the end
if (currentTokens[currentTokens.length - 1].toLowerCase() === suggestionTokens[0].toLowerCase()) {
/* empty */
} else {
const offset =
currentTokens.length - 1 === 0 && currentTokens[0].trim().startsWith("/") ? 1 : 0;
commandInput.value =
commandInput.value +
suggestionTokens[suggestionTokens.length - 1].substring(
currentTokens[currentTokens.length - 1].length - offset,
) +
" ";
suggestions.value = getSuggestions(commandInput.value);
selectedSuggestionIndex.value = 0;
}
};
const selectSuggestion = (index: number) => {
selectedSuggestionIndex.value = index;
acceptSuggestion();
};
</script>

View File

@@ -0,0 +1,48 @@
<template>
<UiServersServerSidebar :route="route" :nav-links="navLinks" :server="props.server" />
</template>
<script setup lang="ts">
import {
InfoIcon,
ListIcon,
SettingsIcon,
TextQuoteIcon,
VersionIcon,
CardIcon,
UserIcon,
WrenchIcon,
} from "@modrinth/assets";
import type { Server } from "~/composables/pyroServers";
const route = useRoute();
const serverId = route.params.id as string;
const props = defineProps<{
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
}>();
useHead({
title: `Options - ${props.server.general?.name ?? "Server"} - Modrinth`,
});
const navLinks = [
{ icon: SettingsIcon, label: "General", href: `/servers/manage/${serverId}/options` },
{ icon: WrenchIcon, label: "Platform", href: `/servers/manage/${serverId}/options/loader` },
{ icon: TextQuoteIcon, label: "Startup", href: `/servers/manage/${serverId}/options/startup` },
{ icon: VersionIcon, label: "Network", href: `/servers/manage/${serverId}/options/network` },
{ icon: ListIcon, label: "Properties", href: `/servers/manage/${serverId}/options/properties` },
{
icon: UserIcon,
label: "Preferences",
href: `/servers/manage/${serverId}/options/preferences`,
},
{
icon: CardIcon,
label: "Billing",
href: `/settings/billing#server-${serverId}`,
external: true,
},
{ icon: InfoIcon, label: "Info", href: `/servers/manage/${serverId}/options/info` },
];
</script>

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,154 @@
<template>
<div
data-pyro-server-list-root
class="experimental-styles-within relative mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-3"
>
<div
v-if="serverList.length > 0 || isPollingForNewServers"
class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row"
>
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
<div class="relative w-full text-sm md:w-72">
<label class="sr-only" for="search">Search</label>
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search"
v-model="searchInput"
class="w-full border-[1px] border-solid border-button-border pl-9"
type="search"
name="search"
autocomplete="off"
placeholder="Search servers..."
/>
</div>
<ButtonStyled type="standard">
<NuxtLink
class="!h-10 whitespace-pre !border-[1px] !border-solid !border-button-border text-sm !font-medium"
:to="{ path: '/servers', hash: '#plan' }"
>
<PlusIcon class="size-4" />
New server
</NuxtLink>
</ButtonStyled>
</div>
</div>
<LazyUiServersServerManageEmptyState
v-if="serverList.length === 0 && !isPollingForNewServers"
/>
<template v-else>
<ul v-if="filteredData.length > 0" class="m-0 flex flex-col gap-4 p-0">
<UiServersServerListing
v-for="server in filteredData"
:key="server.server_id"
:server_id="server.server_id"
:name="server.name"
:status="server.status"
:game="server.game"
:loader="server.loader"
:loader_version="server.loader_version"
:mc_version="server.mc_version"
:upstream="server.upstream"
:net="server.net"
/>
<LazyUiServersServerListingSkeleton v-if="isPollingForNewServers" />
</ul>
<div v-else class="flex h-full items-center justify-center">
<p class="text-contrast">No servers found.</p>
</div>
</template>
<UiServersPoweredByPyro />
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import Fuse from "fuse.js";
import { PlusIcon, SearchIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import type { Server } from "~/types/servers";
definePageMeta({
middleware: "auth",
});
useHead({
title: "Servers - Modrinth",
});
interface ServerResponse {
servers: Server[];
}
const route = useRoute();
const isPollingForNewServers = ref(false);
const { data: serverResponse, refresh } = await useAsyncData<ServerResponse>(
"ServerList",
async () => {
try {
const response = await usePyroFetch<{ servers: Server[] }>("servers");
return response;
} catch {
throw new PyroFetchError("Unable to load servers");
}
},
);
const serverList = computed(() => serverResponse.value?.servers || []);
const searchInput = ref("");
const fuse = computed(() => {
if (serverList.value.length === 0) return null;
return new Fuse(serverList.value, {
keys: ["name", "loader", "mc_version", "game", "state"],
includeScore: true,
threshold: 0.4,
});
});
const filteredData = computed(() => {
if (!searchInput.value.trim()) {
return serverList.value;
}
return fuse.value ? fuse.value.search(searchInput.value).map((result) => result.item) : [];
});
const previousServerList = ref<Server[]>([]);
const refreshCount = ref(0);
const checkForNewServers = async () => {
await refresh();
refreshCount.value += 1;
if (JSON.stringify(previousServerList.value) !== JSON.stringify(serverList.value)) {
isPollingForNewServers.value = false;
clearInterval(intervalId);
} else if (refreshCount.value >= 5) {
isPollingForNewServers.value = false;
clearInterval(intervalId);
}
};
let intervalId: ReturnType<typeof setInterval> | undefined;
onMounted(() => {
if (route.query.redirect_status === "succeeded") {
isPollingForNewServers.value = true;
previousServerList.value = [...serverList.value];
intervalId = setInterval(checkForNewServers, 5000);
}
});
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId);
}
});
</script>

View File

@@ -16,6 +16,9 @@
<div class="flex items-center gap-1">
<span class="font-bold text-primary">
<template v-if="charge.product.metadata.type === 'midas'"> Modrinth Plus </template>
<template v-else-if="charge.product.metadata.type === 'pyro'">
Modrinth Servers
</template>
<template v-else> Unknown product </template>
<template v-if="charge.subscription_interval">
{{ charge.subscription_interval }}

View File

@@ -4,7 +4,7 @@
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
<div class="universal-card recessed">
<ConfirmModal
ref="modal_cancel"
ref="modalCancel"
:title="formatMessage(cancelModalMessages.title)"
:description="formatMessage(cancelModalMessages.description)"
:proceed-label="formatMessage(cancelModalMessages.action)"
@@ -108,7 +108,7 @@
id: 'cancel',
action: () => {
cancelSubscriptionId = midasSubscription.id;
$refs.modal_cancel.show();
$refs.modalCancel.show();
},
},
]"
@@ -123,7 +123,7 @@
@click="
() => {
cancelSubscriptionId = midasSubscription.id;
$refs.modal_cancel.show();
$refs.modalCancel.show();
}
"
>
@@ -152,7 +152,163 @@
</div>
</div>
</div>
<div v-if="pyroSubscriptions.length > 0">
<div
v-for="(subscription, index) in pyroSubscriptions"
:key="index"
class="universal-card recessed mt-4"
>
<div class="flex flex-col justify-between gap-4">
<div class="flex flex-col gap-4">
<LazyUiServersModrinthServersIcon class="flex h-8 w-fit" />
<div class="flex flex-col gap-2">
<UiServersServerListing
v-if="subscription.serverInfo"
:server_id="subscription.serverInfo.server_id"
:name="subscription.serverInfo.name"
:status="subscription.serverInfo.status"
:game="subscription.serverInfo.game"
:loader="subscription.serverInfo.loader"
:loader_version="subscription.serverInfo.loader_version"
:mc_version="subscription.serverInfo.mc_version"
:upstream="subscription.serverInfo.upstream"
:net="subscription.serverInfo.net"
/>
<div v-else class="w-fit">
<p>
A linked server couldn't be found with this subscription. It may have been deleted
or suspended. Please contact Modrinth support with the following information:
</p>
<div class="flex w-full flex-col gap-2">
<CopyCode
class="whitespace-nowrap"
:text="'Server ID: ' + subscription.metadata.id"
/>
<CopyCode class="whitespace-nowrap" :text="'Stripe ID: ' + subscription.id" />
</div>
</div>
<h3 class="m-0 mt-4 text-xl font-semibold leading-none text-contrast">
{{ getProductSize(getPyroProduct(subscription)) }} Plan
</h3>
<div class="flex flex-row justify-between">
<div class="mt-2 flex flex-col gap-2">
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<span> {{ getPyroProduct(subscription)?.metadata?.cpu }} vCores (CPU) </span>
</div>
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<span>
{{
getPyroProduct(subscription)?.metadata?.ram
? getPyroProduct(subscription).metadata.ram / 1024 + " GB RAM"
: ""
}}
</span>
</div>
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<span>
{{
getPyroProduct(subscription)?.metadata?.swap
? getPyroProduct(subscription).metadata.swap / 1024 + " GB Swap"
: ""
}}
</span>
</div>
<div class="flex items-center gap-2">
<CheckCircleIcon class="h-5 w-5 text-brand" />
<span>
{{
getPyroProduct(subscription)?.metadata?.storage
? getPyroProduct(subscription).metadata.storage / 1024 + " GB SSD"
: ""
}}
</span>
</div>
</div>
<div class="flex flex-col items-end justify-between">
<div class="flex flex-col items-end gap-2">
<div class="flex text-2xl font-bold text-contrast">
<span class="text-contrast">
{{
formatPrice(
vintl.locale,
getProductPrice(getPyroProduct(subscription), subscription.interval)
.prices.intervals[subscription.interval],
getProductPrice(getPyroProduct(subscription), subscription.interval)
.currency_code,
)
}}
</span>
<span>/{{ subscription.interval.replace("ly", "") }}</span>
</div>
<div v-if="getPyroCharge(subscription)" class="mb-4 flex flex-col items-end">
<span class="text-sm text-secondary">
Since {{ $dayjs(subscription.created).format("MMMM D, YYYY") }}
</span>
<span
v-if="getPyroCharge(subscription).status === 'open'"
class="text-sm text-secondary"
>
Renews {{ $dayjs(getPyroCharge(subscription).due).format("MMMM D, YYYY") }}
</span>
<span
v-else-if="getPyroCharge(subscription).status === 'processing'"
class="text-sm text-orange"
>
Your payment is being processed. Perks will activate once payment is
complete.
</span>
<span
v-else-if="getPyroCharge(subscription).status === 'cancelled'"
class="text-sm text-secondary"
>
Expires {{ $dayjs(getPyroCharge(subscription).due).format("MMMM D, YYYY") }}
</span>
<span
v-else-if="getPyroCharge(subscription).status === 'failed'"
class="text-sm text-red"
>
Your subscription payment failed. Please update your payment method.
</span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled
v-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status !== 'cancelled'
"
type="standard"
@click="showPyroCancelModal(subscription.id)"
>
<button class="text-contrast">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status === 'cancelled'
"
type="standard"
color="green"
@click="resubscribePyro(subscription.id)"
>
<button class="text-contrast">Resubscribe</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section class="universal-card">
<ConfirmModal
ref="modal_confirm"
@@ -320,8 +476,17 @@
</div>
</section>
</template>
<script setup>
import { ConfirmModal, NewModal, OverflowMenu, AnimatedLogo, PurchaseModal } from "@modrinth/ui";
import {
ConfirmModal,
NewModal,
OverflowMenu,
AnimatedLogo,
PurchaseModal,
ButtonStyled,
CopyCode,
} from "@modrinth/ui";
import {
PlusIcon,
XIcon,
@@ -339,7 +504,7 @@ import {
HistoryIcon,
} from "@modrinth/assets";
import { calculateSavings, formatPrice, createStripeElements, getCurrency } from "@modrinth/utils";
import { ref } from "vue";
import { ref, computed } from "vue";
import { products } from "~/generated/state.json";
definePageMeta({
@@ -434,6 +599,14 @@ const messages = defineMessages({
id: "settings.billing.payment_method.card_expiry",
defaultMessage: "Expires {month}/{year}",
},
pyroSubscriptionTitle: {
id: "settings.billing.pyro_subscription.title",
defaultMessage: "Modrinth Server Subscriptions",
},
pyroSubscriptionDescription: {
id: "settings.billing.pyro_subscription.description",
defaultMessage: "Manage your Modrinth Server subscriptions.",
},
});
const paymentMethodTypes = defineMessages({
@@ -471,7 +644,9 @@ function loadStripe() {
if (!stripe) {
stripe = Stripe(config.public.stripePublishableKey);
}
} catch {}
} catch (error) {
console.error("Error loading Stripe:", error);
}
}
const [
@@ -479,6 +654,8 @@ const [
{ data: charges, refresh: refreshCharges },
{ data: customer, refresh: refreshCustomer },
{ data: subscriptions, refresh: refreshSubscriptions },
{ data: productsData, refresh: refreshProducts },
{ data: serversData, refresh: refreshServers },
] = await Promise.all([
useAsyncData("billing/payment_methods", () =>
useBaseFetch("billing/payment_methods", { internal: true }),
@@ -488,81 +665,51 @@ const [
useAsyncData("billing/subscriptions", () =>
useBaseFetch("billing/subscriptions", { internal: true }),
),
useAsyncData("billing/products", () => useBaseFetch("billing/products", { internal: true })),
useAsyncData("servers", () => usePyroFetch("servers")),
]);
async function refresh() {
await Promise.all([
refreshPaymentMethods(),
refreshCharges(),
refreshCustomer(),
refreshSubscriptions(),
]);
}
const midasProduct = ref(products.find((x) => x.metadata.type === "midas"));
const midasProduct = ref(products.find((x) => x.metadata?.type === "midas"));
const midasSubscription = computed(() =>
subscriptions.value.find(
(x) => x.status === "provisioned" && midasProduct.value.prices.find((y) => y.id === x.price_id),
subscriptions.value?.find(
(x) =>
x.status === "provisioned" && midasProduct.value?.prices?.find((y) => y.id === x.price_id),
),
);
const midasSubscriptionPrice = computed(() =>
midasSubscription.value
? midasProduct.value.prices.find((x) => x.id === midasSubscription.value.price_id)
? midasProduct.value?.prices?.find((x) => x.id === midasSubscription.value.price_id)
: null,
);
const midasCharge = computed(() =>
midasSubscription.value
? charges.value.find((x) => x.subscription_id === midasSubscription.value.id)
? charges.value?.find((x) => x.subscription_id === midasSubscription.value.id)
: null,
);
const pyroSubscriptions = computed(() => {
const pyroSubs = subscriptions.value?.filter((s) => s?.metadata?.type === "pyro") || [];
const servers = serversData.value?.servers || [];
return pyroSubs.map((subscription) => {
const server = servers.find((s) => s.server_id === subscription.metadata.id);
return {
...subscription,
serverInfo: server,
};
});
});
const purchaseModal = ref();
const country = useUserCountry();
const price = computed(() =>
midasProduct.value.prices.find((x) => x.currency_code === getCurrency(country.value)),
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
);
// Initialize subscription with fake data if redirected from checkout
const route = useRoute();
const router = useRouter();
if (route.query.priceId && route.query.plan && route.query.redirect_status) {
let status;
if (route.query.redirect_status === "succeeded") {
status = "active";
} else if (route.query.redirect_status === "processing") {
status = "payment-processing";
} else {
status = "payment-failed";
}
subscriptions.value.push({
id: "temp",
price_id: route.query.priceId,
interval: route.query.plan,
created: Date.now(),
status,
});
charges.value.push({
id: "temp",
price_id: route.query.priceId,
subscription_id: "temp",
status: "open",
due: Date.now() + (route.query.plan === "yearly" ? 31536000000 : 2629746000),
subscription_interval: route.query.plan,
});
await router.replace({ query: {} });
}
const primaryPaymentMethodId = computed(() => {
if (
customer.value &&
customer.value.invoice_settings &&
customer.value.invoice_settings.default_payment_method
) {
if (customer.value?.invoice_settings?.default_payment_method) {
return customer.value.invoice_settings.default_payment_method;
} else if (paymentMethods.value && paymentMethods.value[0] && paymentMethods.value[0].id) {
} else if (paymentMethods.value?.[0]?.id) {
return paymentMethods.value[0].id;
} else {
return null;
@@ -678,7 +825,7 @@ async function removePaymentMethod(index) {
stopLoading();
}
const cancelSubscriptionId = ref();
const cancelSubscriptionId = ref(null);
async function cancelSubscription(id, cancelled) {
startLoading();
try {
@@ -700,4 +847,79 @@ async function cancelSubscription(id, cancelled) {
}
stopLoading();
}
const getPyroProduct = (subscription) => {
if (!subscription || !productsData.value) return null;
return productsData.value.find((p) => p.prices?.some((x) => x.id === subscription.price_id));
};
const getPyroCharge = (subscription) => {
if (!subscription || !charges.value) return null;
return charges.value.find(
(charge) => charge.subscription_id === subscription.id && charge.status !== "succeeded",
);
};
const getProductSize = (product) => {
if (!product || !product.metadata) return "Unknown";
const ramSize = product.metadata.ram;
if (ramSize === 4096) return "Small";
if (ramSize === 6144) return "Medium";
if (ramSize === 8192) return "Large";
return "Custom";
};
const getProductPrice = (product, interval) => {
if (!product || !product.prices) return null;
const countryValue = country.value;
return (
product.prices.find(
(p) => p.currency_code === getCurrency(countryValue) && p.prices?.intervals?.[interval],
) ??
product.prices.find((p) => p.currency_code === "USD" && p.prices?.intervals?.[interval]) ??
product.prices[0]
);
};
const modalCancel = ref(null);
const showPyroCancelModal = (subscriptionId) => {
cancelSubscriptionId.value = subscriptionId;
if (modalCancel.value) {
modalCancel.value.show();
} else {
console.error("modalCancel ref is undefined");
}
};
const resubscribePyro = async (subscriptionId) => {
try {
await useBaseFetch(`billing/subscription/${subscriptionId}`, {
internal: true,
method: "PATCH",
body: {
cancelled: false,
},
});
await refresh();
} catch {
data.$notify({
group: "main",
title: "Error resubscribing",
text: "An error occurred while resubscribing to your Modrinth server.",
type: "error",
});
}
};
const refresh = async () => {
await Promise.all([
refreshPaymentMethods(),
refreshCharges(),
refreshCustomer(),
refreshSubscriptions(),
refreshProducts(),
refreshServers(),
]);
};
</script>

View File

@@ -498,7 +498,7 @@ const badges = computed(() => {
});
async function copyId() {
await navigator.clipboard.writeText(project.value.id);
await navigator.clipboard.writeText(user.value.id);
}
const navLinks = computed(() => [