You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -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`),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
11
apps/frontend/src/pages/servers/admin/index.vue
Normal file
11
apps/frontend/src/pages/servers/admin/index.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 border-0 border-b border-solid border-bg-raised p-3"
|
||||
>
|
||||
<h2 class="m-0 text-2xl font-bold text-contrast">Admin</h2>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
939
apps/frontend/src/pages/servers/index.vue
Normal file
939
apps/frontend/src/pages/servers/index.vue
Normal 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>
|
||||
811
apps/frontend/src/pages/servers/manage/[id].vue
Normal file
811
apps/frontend/src/pages/servers/manage/[id].vue
Normal 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>
|
||||
372
apps/frontend/src/pages/servers/manage/[id]/backups.vue
Normal file
372
apps/frontend/src/pages/servers/manage/[id]/backups.vue
Normal 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>
|
||||
21
apps/frontend/src/pages/servers/manage/[id]/content.vue
Normal file
21
apps/frontend/src/pages/servers/manage/[id]/content.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<NuxtPage :route="route" :server="props.server" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { 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>
|
||||
507
apps/frontend/src/pages/servers/manage/[id]/content/index.vue
Normal file
507
apps/frontend/src/pages/servers/manage/[id]/content/index.vue
Normal 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>
|
||||
736
apps/frontend/src/pages/servers/manage/[id]/files.vue
Normal file
736
apps/frontend/src/pages/servers/manage/[id]/files.vue
Normal 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>
|
||||
710
apps/frontend/src/pages/servers/manage/[id]/index.vue
Normal file
710
apps/frontend/src/pages/servers/manage/[id]/index.vue
Normal 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>
|
||||
48
apps/frontend/src/pages/servers/manage/[id]/options.vue
Normal file
48
apps/frontend/src/pages/servers/manage/[id]/options.vue
Normal 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>
|
||||
@@ -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>
|
||||
322
apps/frontend/src/pages/servers/manage/[id]/options/index.vue
Normal file
322
apps/frontend/src/pages/servers/manage/[id]/options/index.vue
Normal 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>
|
||||
156
apps/frontend/src/pages/servers/manage/[id]/options/info.vue
Normal file
156
apps/frontend/src/pages/servers/manage/[id]/options/info.vue
Normal 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>
|
||||
607
apps/frontend/src/pages/servers/manage/[id]/options/loader.vue
Normal file
607
apps/frontend/src/pages/servers/manage/[id]/options/loader.vue
Normal 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>
|
||||
468
apps/frontend/src/pages/servers/manage/[id]/options/network.vue
Normal file
468
apps/frontend/src/pages/servers/manage/[id]/options/network.vue
Normal 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>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<div class="h-full w-full gap-2 overflow-y-auto">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<h1 class="m-0 text-lg font-bold text-contrast">Server preferences</h1>
|
||||
<p class="m-0">Preferences apply per server and changes are only saved in your browser.</p>
|
||||
<div
|
||||
v-for="(prefConfig, key) in preferences"
|
||||
:key="key"
|
||||
class="flex items-center justify-between gap-2"
|
||||
>
|
||||
<label :for="`pref-${key}`" class="flex flex-col gap-2">
|
||||
<div class="flex flex-row gap-2">
|
||||
<span class="text-lg font-bold text-contrast">{{ prefConfig.displayName }}</span>
|
||||
<div
|
||||
v-if="prefConfig.implemented === false"
|
||||
class="hidden items-center gap-1 rounded-full bg-table-alternateRow p-1 px-1.5 text-xs font-semibold sm:flex"
|
||||
>
|
||||
Coming Soon
|
||||
</div>
|
||||
</div>
|
||||
<span>{{ prefConfig.description }}</span>
|
||||
</label>
|
||||
<input
|
||||
:id="`pref-${key}`"
|
||||
v-model="newUserPreferences[key]"
|
||||
class="switch stylized-toggle flex-none"
|
||||
type="checkbox"
|
||||
:disabled="prefConfig.implemented === false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiServersSaveBanner
|
||||
:is-visible="hasUnsavedChanges"
|
||||
:server="props.server"
|
||||
:is-updating="false"
|
||||
:save="savePreferences"
|
||||
:reset="resetPreferences"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id as string;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const preferences = {
|
||||
ramAsNumber: {
|
||||
displayName: "RAM as bytes",
|
||||
description:
|
||||
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
|
||||
implemented: true,
|
||||
},
|
||||
autoRestart: {
|
||||
displayName: "Auto restart",
|
||||
description: "When enabled, your server will automatically restart if it crashes.",
|
||||
implemented: false,
|
||||
},
|
||||
powerDontAskAgain: {
|
||||
displayName: "Power actions confirmation",
|
||||
description: "When enabled, you will be prompted before stopping and restarting your server.",
|
||||
implemented: true,
|
||||
},
|
||||
backupWhileRunning: {
|
||||
displayName: "Create backups while running",
|
||||
description: "When enabled, backups will be created even if the server is running.",
|
||||
implemented: true,
|
||||
},
|
||||
} as const;
|
||||
|
||||
type PreferenceKeys = keyof typeof preferences;
|
||||
|
||||
type UserPreferences = {
|
||||
[K in PreferenceKeys]: boolean;
|
||||
};
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
ramAsNumber: false,
|
||||
autoRestart: false,
|
||||
powerDontAskAgain: false,
|
||||
backupWhileRunning: false,
|
||||
};
|
||||
|
||||
const userPreferences = useStorage<UserPreferences>(
|
||||
`pyro-server-${serverId}-preferences`,
|
||||
defaultPreferences,
|
||||
);
|
||||
|
||||
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)));
|
||||
|
||||
const hasUnsavedChanges = computed(() => {
|
||||
return JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value);
|
||||
});
|
||||
|
||||
const savePreferences = () => {
|
||||
userPreferences.value = { ...newUserPreferences.value };
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Preferences saved",
|
||||
text: "Your preferences have been saved.",
|
||||
});
|
||||
};
|
||||
|
||||
const resetPreferences = () => {
|
||||
newUserPreferences.value = { ...userPreferences.value };
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full select-none overflow-y-auto">
|
||||
<div
|
||||
v-if="propsData && status === 'success'"
|
||||
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
|
||||
>
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Server properties</h2>
|
||||
<div class="m-0">
|
||||
Edit the Minecraft server properties file. If you're unsure about a specific property,
|
||||
the
|
||||
<NuxtLink
|
||||
class="goto-link !inline-block"
|
||||
to="https://minecraft.wiki/w/Server.properties"
|
||||
external
|
||||
>
|
||||
Minecraft Wiki
|
||||
</NuxtLink>
|
||||
has more detailed information about each property.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="relative w-full text-sm">
|
||||
<label for="search-server-properties" class="sr-only">Search server properties</label>
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id="search-server-properties"
|
||||
v-model="searchInput"
|
||||
class="w-full pl-9"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
placeholder="Search server properties..."
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-for="(property, index) in filteredProperties"
|
||||
:key="index"
|
||||
class="flex flex-row flex-wrap items-center justify-between py-2"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<span :id="`property-label-${index}`">{{ formatPropertyName(index) }}</span>
|
||||
<span v-if="overrides[index] && overrides[index].info" class="ml-2">
|
||||
<EyeIcon v-tooltip="overrides[index].info" />
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="overrides[index] && overrides[index].type === 'dropdown'"
|
||||
class="mt-2 flex w-full sm:w-[320px] sm:justify-end"
|
||||
>
|
||||
<UiServersTeleportDropdownMenu
|
||||
:id="`server-property-${index}`"
|
||||
v-model="liveProperties[index]"
|
||||
:name="formatPropertyName(index)"
|
||||
:options="overrides[index].options || []"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
placeholder="Select..."
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
|
||||
<input
|
||||
:id="`server-property-${index}`"
|
||||
v-model="liveProperties[index]"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="typeof property === 'number'" class="mt-2 w-full sm:w-[320px]">
|
||||
<input
|
||||
:id="`server-property-${index}`"
|
||||
v-model.number="liveProperties[index]"
|
||||
type="number"
|
||||
class="w-full border p-2"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
/>
|
||||
</div>
|
||||
<div v-else-if="isComplexProperty(property)" class="mt-2 w-full sm:w-[320px]">
|
||||
<textarea
|
||||
:id="`server-property-${index}`"
|
||||
v-model="liveProperties[index]"
|
||||
class="w-full resize-y rounded-xl border p-2"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
></textarea>
|
||||
</div>
|
||||
<div v-else class="mt-2 flex w-full justify-end sm:w-[320px]">
|
||||
<input
|
||||
:id="`server-property-${index}`"
|
||||
v-model="liveProperties[index]"
|
||||
type="text"
|
||||
class="w-full rounded-xl border p-2"
|
||||
:aria-labelledby="`property-label-${index}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card flex h-full w-full items-center justify-center">
|
||||
<p class="text-contrast">
|
||||
The server properties file has not been generated yet. Start up your server to generate it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UiServersSaveBanner
|
||||
:is-visible="hasUnsavedChanges"
|
||||
:server="props.server"
|
||||
:is-updating="isUpdating"
|
||||
restart
|
||||
:save="saveProperties"
|
||||
:reset="resetProperties"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { EyeIcon, SearchIcon } from "@modrinth/assets";
|
||||
import Fuse from "fuse.js";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const tags = useTags();
|
||||
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const searchInput = ref("");
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
const { data: propsData, status } = await useAsyncData(
|
||||
"ServerProperties",
|
||||
async () => await props.server.general?.fetchConfigFile("ServerProperties"),
|
||||
);
|
||||
|
||||
const liveProperties = ref<Record<string, any>>({});
|
||||
const originalProperties = ref<Record<string, any>>({});
|
||||
|
||||
watch(
|
||||
propsData,
|
||||
(newPropsData) => {
|
||||
if (newPropsData) {
|
||||
liveProperties.value = JSON.parse(JSON.stringify(newPropsData));
|
||||
originalProperties.value = JSON.parse(JSON.stringify(newPropsData));
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const hasUnsavedChanges = computed(() => {
|
||||
return Object.keys(liveProperties.value).some(
|
||||
(key) =>
|
||||
JSON.stringify(liveProperties.value[key]) !== JSON.stringify(originalProperties.value[key]),
|
||||
);
|
||||
});
|
||||
|
||||
const getDifficultyOptions = () => {
|
||||
const pre113Versions = tags.value.gameVersions
|
||||
.filter((v) => {
|
||||
const versionNumbers = v.version.split(".").map(Number);
|
||||
return versionNumbers[0] === 1 && versionNumbers[1] < 13;
|
||||
})
|
||||
.map((v) => v.version);
|
||||
if (data.value?.mc_version && pre113Versions.includes(data.value.mc_version)) {
|
||||
return ["0", "1", "2", "3"];
|
||||
} else {
|
||||
return ["peaceful", "easy", "normal", "hard"];
|
||||
}
|
||||
};
|
||||
|
||||
const overrides: { [key: string]: { type: string; options?: string[]; info?: string } } = {
|
||||
difficulty: {
|
||||
type: "dropdown",
|
||||
options: getDifficultyOptions(),
|
||||
},
|
||||
gamemode: {
|
||||
type: "dropdown",
|
||||
options: ["survival", "creative", "adventure", "spectator"],
|
||||
},
|
||||
};
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (!liveProperties.value) return null;
|
||||
|
||||
const propertiesToFuse = Object.entries(liveProperties.value).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}));
|
||||
|
||||
return new Fuse(propertiesToFuse, {
|
||||
keys: ["key", "value"],
|
||||
threshold: 0.2,
|
||||
});
|
||||
});
|
||||
|
||||
const filteredProperties = computed(() => {
|
||||
if (!searchInput.value?.trim()) {
|
||||
return liveProperties.value;
|
||||
}
|
||||
|
||||
const results = fuse.value?.search(searchInput.value) ?? [];
|
||||
|
||||
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]));
|
||||
});
|
||||
|
||||
const constructServerProperties = (): string => {
|
||||
const properties = liveProperties.value;
|
||||
|
||||
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
|
||||
|
||||
for (const [key, value] of Object.entries(properties)) {
|
||||
if (typeof value === "object") {
|
||||
fileContent += `${key}=${JSON.stringify(value)}\n`;
|
||||
} else if (typeof value === "boolean") {
|
||||
fileContent += `${key}=${value ? "true" : "false"}\n`;
|
||||
} else {
|
||||
fileContent += `${key}=${value}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
return fileContent;
|
||||
};
|
||||
|
||||
const saveProperties = async () => {
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
await props.server.fs?.updateFile("server.properties", constructServerProperties());
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value));
|
||||
await props.server.refresh();
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server properties updated",
|
||||
text: "Your server properties were successfully changed.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating server properties:", error);
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "Failed to update server properties",
|
||||
text: "An error occurred while attempting to update your server properties.",
|
||||
});
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetProperties = async () => {
|
||||
liveProperties.value = JSON.parse(JSON.stringify(originalProperties.value));
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
};
|
||||
|
||||
const formatPropertyName = (propertyName: string): string => {
|
||||
return propertyName
|
||||
.split(/[-.]/)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
const isComplexProperty = (property: any): boolean => {
|
||||
return (
|
||||
typeof property === "object" ||
|
||||
(typeof property === "string" &&
|
||||
(property.includes(",") ||
|
||||
property.includes("{") ||
|
||||
property.includes("}") ||
|
||||
property.includes("[") ||
|
||||
property.includes("]") ||
|
||||
property.length > 30))
|
||||
);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
</style>
|
||||
176
apps/frontend/src/pages/servers/manage/[id]/options/startup.vue
Normal file
176
apps/frontend/src/pages/servers/manage/[id]/options/startup.vue
Normal 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>
|
||||
154
apps/frontend/src/pages/servers/manage/index.vue
Normal file
154
apps/frontend/src/pages/servers/manage/index.vue
Normal 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>
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => [
|
||||
|
||||
Reference in New Issue
Block a user