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:
parent
f165665a35
commit
185dd47668
4
.gitignore
vendored
4
.gitignore
vendored
@ -56,4 +56,8 @@ generated
|
||||
# app testing dir
|
||||
app-playground-data/*
|
||||
|
||||
# soley because i need the PORT to be 3002 due to WSL stuff
|
||||
.env
|
||||
apps/frontend/.env
|
||||
|
||||
.astro
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
"identifier": "plugins",
|
||||
"description": "",
|
||||
"local": true,
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-confirm",
|
||||
|
||||
3314
apps/app/gen/schemas/windows-schema.json
Normal file
3314
apps/app/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,7 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ["../../packages/eslint-config-custom/nuxt.js"],
|
||||
rules: {
|
||||
"import/no-unresolved": "off",
|
||||
},
|
||||
};
|
||||
|
||||
@ -176,7 +176,6 @@ export default defineNuxtConfig({
|
||||
$fetch(`${API_URL}projects_random?count=60`, headers),
|
||||
$fetch(`${API_URL}search?limit=3&query=leave&index=relevance`, headers),
|
||||
$fetch(`${API_URL}search?limit=3&query=&index=updated`, headers),
|
||||
// TODO: dehardcode
|
||||
$fetch(`${API_URL.replace("/v2/", "/_internal/")}billing/products`, headers),
|
||||
]);
|
||||
|
||||
@ -321,8 +320,10 @@ export default defineNuxtConfig({
|
||||
apiBaseUrl: process.env.BASE_URL ?? globalThis.BASE_URL ?? getApiUrl(),
|
||||
// @ts-ignore
|
||||
rateLimitKey: process.env.RATE_LIMIT_IGNORE_KEY ?? globalThis.RATE_LIMIT_IGNORE_KEY,
|
||||
pyroBaseUrl: process.env.PYRO_BASE_URL,
|
||||
public: {
|
||||
apiBaseUrl: getApiUrl(),
|
||||
pyroBaseUrl: process.env.PYRO_BASE_URL,
|
||||
siteUrl: getDomain(),
|
||||
production: isProduction(),
|
||||
featureFlagOverrides: getFeatureFlagOverrides(),
|
||||
@ -361,7 +362,7 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: ["@vintl/nuxt", "@nuxtjs/turnstile"],
|
||||
modules: ["@vintl/nuxt", "@nuxtjs/turnstile", "@pinia/nuxt"],
|
||||
vintl: {
|
||||
defaultLocale: "en-US",
|
||||
locales: [
|
||||
@ -462,6 +463,7 @@ function getDomain() {
|
||||
return "https://modrinth.com";
|
||||
}
|
||||
} else {
|
||||
return "http://localhost:3000";
|
||||
const port = process.env.PORT || 3000;
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@nuxt/devtools": "^1.3.3",
|
||||
"@nuxtjs/turnstile": "^0.8.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^20.1.0",
|
||||
"@vintl/compact-number": "^2.0.5",
|
||||
"@vintl/how-ago": "^3.0.1",
|
||||
@ -38,8 +39,13 @@
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
"ace-builds": "^1.36.2",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
"dayjs": "^1.11.7",
|
||||
"dompurify": "^3.1.7",
|
||||
"floating-vue": "2.0.0-beta.20",
|
||||
"fuse.js": "^6.6.2",
|
||||
"highlight.js": "^11.7.0",
|
||||
@ -48,9 +54,12 @@
|
||||
"jszip": "^3.10.1",
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"vue-multiselect": "3.0.0",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
"vue3-apexcharts": "^1.5.2",
|
||||
"xss": "^1.0.14"
|
||||
}
|
||||
|
||||
BIN
apps/frontend/src/assets/images/games/rinth.png
Normal file
BIN
apps/frontend/src/assets/images/games/rinth.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/frontend/src/assets/images/servers/this-is-fine.gif
Normal file
BIN
apps/frontend/src/assets/images/servers/this-is-fine.gif
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 MiB |
@ -119,7 +119,7 @@ export default {
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--color-brand-inverted);
|
||||
color: var(--color-accent-contrast, var(--color-brand-inverted));
|
||||
stroke-width: 0.2rem;
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
|
||||
@ -45,7 +45,7 @@ export default {
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-code-bg);
|
||||
width: min-content;
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
@ -55,7 +55,6 @@ export default {
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
span {
|
||||
max-width: 10rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<nav
|
||||
class="experimental-styles-within relative flex w-fit overflow-clip rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
ref="scrollContainer"
|
||||
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<NuxtLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
@ -18,7 +19,9 @@
|
||||
<span class="text-nowrap">{{ link.label }}</span>
|
||||
</NuxtLink>
|
||||
<div
|
||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'}`"
|
||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-brand-highlight'
|
||||
}`"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
@ -32,6 +35,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
|
||||
const route = useNativeRoute();
|
||||
|
||||
interface Tab {
|
||||
@ -47,12 +52,13 @@ const props = defineProps<{
|
||||
query?: string;
|
||||
}>();
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
const sliderLeft = ref(4);
|
||||
const sliderTop = ref(4);
|
||||
const sliderRight = ref(4);
|
||||
const sliderBottom = ref(4);
|
||||
const activeIndex = ref(-1);
|
||||
const oldIndex = ref(-1);
|
||||
const subpageSelected = ref(false);
|
||||
|
||||
const filteredLinks = computed(() =>
|
||||
@ -63,6 +69,8 @@ const sliderTopPx = computed(() => `${sliderTop.value}px`);
|
||||
const sliderRightPx = computed(() => `${sliderRight.value}px`);
|
||||
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
|
||||
|
||||
const tabLinkElements = ref();
|
||||
|
||||
function pickLink() {
|
||||
let index = -1;
|
||||
subpageSelected.value = false;
|
||||
@ -86,16 +94,13 @@ function pickLink() {
|
||||
if (activeIndex.value !== -1) {
|
||||
startAnimation();
|
||||
} else {
|
||||
oldIndex.value = -1;
|
||||
sliderLeft.value = 0;
|
||||
sliderRight.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const tabLinkElements = ref();
|
||||
|
||||
function startAnimation() {
|
||||
const el = tabLinkElements.value[activeIndex.value].$el;
|
||||
const el = tabLinkElements.value[activeIndex.value]?.$el;
|
||||
|
||||
if (!el || !el.offsetParent) return;
|
||||
|
||||
@ -141,21 +146,19 @@ function startAnimation() {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", pickLink);
|
||||
pickLink();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", pickLink);
|
||||
});
|
||||
|
||||
watch(route, () => pickLink());
|
||||
watch(
|
||||
() => route.path,
|
||||
() => pickLink(),
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
/* Delay on opacity is to hide any jankiness as the page loads */
|
||||
transition:
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1) 0s,
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Creating backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. Before 1.21"
|
||||
maxlength="64"
|
||||
/>
|
||||
<div class="flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
If left empty, the backup name will default to
|
||||
<span class="font-semibold"> Backup #{{ newBackupAmount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isCreating" @click="createBackup">
|
||||
<PlusIcon />
|
||||
Create backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hideModal">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, InfoIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupCreated"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const isCreating = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
const backupName = ref("");
|
||||
const newBackupAmount = computed(() =>
|
||||
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
|
||||
);
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
input.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
backupName.value = "";
|
||||
};
|
||||
|
||||
const createBackup = async () => {
|
||||
if (!backupName.value.trim()) {
|
||||
backupName.value = `Backup #${newBackupAmount.value}`;
|
||||
}
|
||||
|
||||
isCreating.value = true;
|
||||
try {
|
||||
await props.server.backups?.create(backupName.value);
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupCreated", { success: true, message: "Backup created successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupCreated", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isCreating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<NewModal ref="modal" danger header="Deleting backup">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-[#0e0e0ea4] p-6">
|
||||
<div class="text-2xl font-extrabold text-contrast">
|
||||
{{ backupName }}
|
||||
</div>
|
||||
<div class="flex gap-2 font-semibold text-contrast">
|
||||
<CalendarIcon />
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-4">
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isDeleting" @click="deleteBackup">
|
||||
<TrashIcon />
|
||||
Delete backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="hideModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { TrashIcon, CalendarIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupId: string;
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupDeleted"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const isDeleting = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
const deleteBackup = async () => {
|
||||
if (!props.backupId) {
|
||||
emit("backupDeleted", { success: false, message: "No backup selected" });
|
||||
return;
|
||||
}
|
||||
|
||||
isDeleting.value = true;
|
||||
try {
|
||||
await props.server.backups?.delete(props.backupId);
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupDeleted", { success: true, message: "Backup deleted successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupDeleted", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isDeleting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Renaming backup" @show="focusInput">
|
||||
<div class="flex flex-col gap-2 md:w-[600px]">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="input"
|
||||
v-model="backupName"
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. Before 1.21"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRenaming" @click="renameBackup">
|
||||
<SaveIcon />
|
||||
Rename backup
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="hideModal">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { SaveIcon, XIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
currentBackupId: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupRenamed"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const input = ref<HTMLInputElement>();
|
||||
const backupName = ref("");
|
||||
const isRenaming = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
|
||||
const focusInput = () => {
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
input.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hideModal = () => {
|
||||
backupName.value = "";
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
const renameBackup = async () => {
|
||||
if (!backupName.value.trim() || !props.currentBackupId) {
|
||||
emit("backupRenamed", { success: false, message: "Backup name cannot be empty" });
|
||||
return;
|
||||
}
|
||||
|
||||
isRenaming.value = true;
|
||||
try {
|
||||
await props.server.backups?.rename(props.currentBackupId, backupName.value);
|
||||
await props.server.refresh();
|
||||
hideModal();
|
||||
emit("backupRenamed", { success: true, message: "Backup renamed successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupRenamed", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isRenaming.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Restoring backup">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="relative flex w-full flex-col gap-2 rounded-2xl bg-bg p-6">
|
||||
<div class="text-2xl font-extrabold text-contrast">
|
||||
{{ backupName }}
|
||||
</div>
|
||||
<div class="flex gap-2 font-semibold text-contrast">
|
||||
<CalendarIcon />
|
||||
{{ formattedDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isRestoring" @click="restoreBackup">Restore backup</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="hideModal">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { CalendarIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
backupId: string;
|
||||
backupName: string;
|
||||
backupCreatedAt: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(["backupRestored"]);
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
const isRestoring = ref(false);
|
||||
const backupError = ref<string | null>(null);
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
return new Date(props.backupCreatedAt).toLocaleString("en-US", {
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "2-digit",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
});
|
||||
});
|
||||
|
||||
const hideModal = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (!props.backupId) {
|
||||
emit("backupRestored", { success: false, message: "No backup selected" });
|
||||
return;
|
||||
}
|
||||
|
||||
isRestoring.value = true;
|
||||
try {
|
||||
await props.server.backups?.restore(props.backupId);
|
||||
hideModal();
|
||||
emit("backupRestored", { success: true, message: "Backup restored successfully" });
|
||||
} catch (error) {
|
||||
backupError.value = error instanceof Error ? error.message : String(error);
|
||||
emit("backupRestored", { success: false, message: backupError.value });
|
||||
} finally {
|
||||
isRestoring.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
show: () => modal.value?.show(),
|
||||
hide: hideModal,
|
||||
});
|
||||
</script>
|
||||
201
apps/frontend/src/components/ui/servers/BackupSettingsModal.vue
Normal file
201
apps/frontend/src/components/ui/servers/BackupSettingsModal.vue
Normal file
@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Editing auto backup settings">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Auto backup</div>
|
||||
<p class="m-0">
|
||||
Automatically create a backup of your server every
|
||||
<strong>{{ autoBackupInterval == 1 ? "hour" : `${autoBackupInterval} hours` }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingSettings" class="py-2 text-sm text-secondary">Loading settings...</div>
|
||||
<template v-else>
|
||||
<input
|
||||
id="auto-backup-toggle"
|
||||
v-model="autoBackupEnabled"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Interval</div>
|
||||
<p class="m-0">
|
||||
The amount of hours between each backup. This will only backup your server if it has
|
||||
been modified since the last backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-contrast">
|
||||
<div
|
||||
class="flex w-fit items-center rounded-xl border border-solid border-button-border bg-table-alternateRow"
|
||||
>
|
||||
<button
|
||||
class="rounded-l-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
@click="autoBackupInterval = Math.max(autoBackupInterval - 1, 1)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="-2 0 18 2">
|
||||
<path
|
||||
d="M18,12H6"
|
||||
transform="translate(-5 -11)"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
id="auto-backup-interval"
|
||||
v-model="autoBackupInterval"
|
||||
class="w-16 !appearance-none text-center [&&]:bg-transparent [&&]:focus:shadow-none"
|
||||
type="number"
|
||||
style="-moz-appearance: textfield; appearance: none"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="rounded-r-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
@click="autoBackupInterval = Math.min(autoBackupInterval + 1, 24)"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</div>
|
||||
{{ autoBackupInterval == 1 ? "hour" : "hours" }}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!hasChanges || isSaving" @click="saveSettings">
|
||||
<SaveIcon class="h-5 w-5" />
|
||||
{{ isSaving ? "Saving..." : "Save changes" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isSaving" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["backups"]>;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
|
||||
const autoBackupEnabled = ref(false);
|
||||
const autoBackupInterval = ref(1);
|
||||
const isLoadingSettings = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
const validatedBackupInterval = computed(() => {
|
||||
const roundedValue = Math.round(autoBackupInterval.value);
|
||||
|
||||
if (roundedValue < 1) {
|
||||
return 1;
|
||||
} else if (roundedValue > 24) {
|
||||
return 24;
|
||||
}
|
||||
return roundedValue;
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!initialSettings.value) return false;
|
||||
|
||||
return (
|
||||
autoBackupEnabled.value !== initialSettings.value.enabled ||
|
||||
autoBackupInterval.value !== initialSettings.value.interval
|
||||
);
|
||||
});
|
||||
|
||||
const fetchSettings = async () => {
|
||||
isLoadingSettings.value = true;
|
||||
try {
|
||||
const settings = await props.server.backups?.getAutoBackup();
|
||||
initialSettings.value = settings as { interval: number; enabled: boolean };
|
||||
autoBackupEnabled.value = settings?.enabled ?? false;
|
||||
autoBackupInterval.value = settings?.interval || 1;
|
||||
} catch (error) {
|
||||
console.error("Error fetching backup settings:", error);
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Error",
|
||||
text: "Failed to load backup settings",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
isLoadingSettings.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
await props.server.backups?.updateAutoBackup(
|
||||
autoBackupEnabled.value ? "enable" : "disable",
|
||||
autoBackupInterval.value,
|
||||
);
|
||||
|
||||
initialSettings.value = {
|
||||
enabled: autoBackupEnabled.value,
|
||||
interval: autoBackupInterval.value,
|
||||
};
|
||||
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Success",
|
||||
text: "Backup settings updated successfully",
|
||||
type: "success",
|
||||
});
|
||||
|
||||
modal.value?.hide();
|
||||
} catch (error) {
|
||||
console.error("Error saving backup settings:", error);
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Error",
|
||||
text: "Failed to save backup settings",
|
||||
type: "error",
|
||||
});
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(autoBackupInterval, () => {
|
||||
autoBackupInterval.value = validatedBackupInterval.value;
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
show: async () => {
|
||||
await fetchSettings();
|
||||
modal.value?.show();
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
</style>
|
||||
229
apps/frontend/src/components/ui/servers/FileItem.vue
Normal file
229
apps/frontend/src/components/ui/servers/FileItem.vue
Normal file
@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<li
|
||||
role="button"
|
||||
data-pyro-file
|
||||
:class="containerClasses"
|
||||
tabindex="0"
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
>
|
||||
<div data-pyro-file-metadata class="flex w-full items-center gap-4 truncate">
|
||||
<div
|
||||
class="flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
||||
>
|
||||
<component :is="iconComponent" class="size-6" />
|
||||
</div>
|
||||
<div class="flex w-full flex-col truncate">
|
||||
<span
|
||||
class="w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
||||
>{{ name }}</span
|
||||
>
|
||||
<span class="text-xs text-secondary group-hover:text-primary">
|
||||
{{ subText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-pyro-file-actions class="flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
||||
<span class="w-[160px] text-nowrap text-right font-mono text-sm text-secondary">{{
|
||||
formattedDate
|
||||
}}</span>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu :options="menuOptions" direction="left" position="bottom">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #rename> <EditIcon /> Rename </template>
|
||||
<template #move> <RightArrowIcon /> Move </template>
|
||||
<template #download> <DownloadIcon /> Download </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import {
|
||||
MoreHorizontalIcon,
|
||||
EditIcon,
|
||||
DownloadIcon,
|
||||
TrashIcon,
|
||||
FolderOpenIcon,
|
||||
FileIcon,
|
||||
RightArrowIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed, shallowRef, ref } from "vue";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import {
|
||||
UiServersIconsCogFolderIcon,
|
||||
UiServersIconsEarthIcon,
|
||||
UiServersIconsCodeFileIcon,
|
||||
UiServersIconsTextFileIcon,
|
||||
UiServersIconsImageFileIcon,
|
||||
} from "#components";
|
||||
import PaletteIcon from "~/assets/icons/palette.svg?component";
|
||||
|
||||
interface FileItemProps {
|
||||
name: string;
|
||||
type: "directory" | "file";
|
||||
size?: number;
|
||||
count?: number;
|
||||
modified: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const props = defineProps<FileItemProps>();
|
||||
|
||||
const emit = defineEmits(["rename", "download", "delete", "move", "edit", "contextmenu"]);
|
||||
|
||||
const codeExtensions = Object.freeze([
|
||||
"json",
|
||||
"json5",
|
||||
"jsonc",
|
||||
"java",
|
||||
"kt",
|
||||
"kts",
|
||||
"sh",
|
||||
"bat",
|
||||
"ps1",
|
||||
"yml",
|
||||
"yaml",
|
||||
"toml",
|
||||
"js",
|
||||
"ts",
|
||||
"py",
|
||||
"rb",
|
||||
"php",
|
||||
"html",
|
||||
"css",
|
||||
"cpp",
|
||||
"c",
|
||||
"h",
|
||||
"rs",
|
||||
"go",
|
||||
]);
|
||||
|
||||
const textExtensions = Object.freeze(["txt", "md", "log", "cfg", "conf", "properties", "ini"]);
|
||||
const imageExtensions = Object.freeze(["png", "jpg", "jpeg", "gif", "svg", "webp"]);
|
||||
const units = Object.freeze(["B", "KB", "MB", "GB", "TB", "PB", "EB"]);
|
||||
|
||||
const route = shallowRef(useRoute());
|
||||
const router = useRouter();
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
"group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised",
|
||||
isEditableFile.value ? "cursor-pointer" : props.type === "directory" ? "cursor-pointer" : "",
|
||||
]);
|
||||
|
||||
const fileExtension = computed(() => props.name.split(".").pop()?.toLowerCase() || "");
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
id: "rename",
|
||||
action: () => emit("rename", { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
id: "move",
|
||||
action: () => emit("move", { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
id: "download",
|
||||
action: () => emit("download", { name: props.name, type: props.type, path: props.path }),
|
||||
shown: props.type !== "directory",
|
||||
},
|
||||
{
|
||||
id: "delete",
|
||||
action: () => emit("delete", { name: props.name, type: props.type, path: props.path }),
|
||||
color: "red" as const,
|
||||
},
|
||||
]);
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (props.type === "directory") {
|
||||
if (props.name === "config") return UiServersIconsCogFolderIcon;
|
||||
if (props.name === "world") return UiServersIconsEarthIcon;
|
||||
if (props.name === "resourcepacks") return PaletteIcon;
|
||||
return FolderOpenIcon;
|
||||
}
|
||||
|
||||
const ext = fileExtension.value;
|
||||
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon;
|
||||
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon;
|
||||
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon;
|
||||
return FileIcon;
|
||||
});
|
||||
|
||||
const subText = computed(() => {
|
||||
if (props.type === "directory") {
|
||||
return `${props.count} ${props.count === 1 ? "item" : "items"}`;
|
||||
}
|
||||
return formattedSize.value;
|
||||
});
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000);
|
||||
return `${date.toLocaleDateString("en-US", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
year: "2-digit",
|
||||
})}, ${date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
hour12: true,
|
||||
})}`;
|
||||
});
|
||||
|
||||
const isEditableFile = computed(() => {
|
||||
if (props.type === "file") {
|
||||
const ext = fileExtension.value;
|
||||
return (
|
||||
!props.name.includes(".") ||
|
||||
textExtensions.includes(ext) ||
|
||||
codeExtensions.includes(ext) ||
|
||||
imageExtensions.includes(ext)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
if (props.size === undefined) return "";
|
||||
const bytes = props.size;
|
||||
if (bytes === 0) return "0 B";
|
||||
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const size = (bytes / Math.pow(1024, exponent)).toFixed(2);
|
||||
return `${size} ${units[exponent]}`;
|
||||
});
|
||||
|
||||
const openContextMenu = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
emit("contextmenu", event.clientX, event.clientY);
|
||||
};
|
||||
|
||||
const navigateToFolder = () => {
|
||||
const currentPath = route.value.query.path?.toString() || "";
|
||||
const newPath = currentPath.endsWith("/")
|
||||
? `${currentPath}${props.name}`
|
||||
: `${currentPath}/${props.name}`;
|
||||
router.push({ query: { path: newPath, page: 1 } });
|
||||
};
|
||||
|
||||
const isNavigating = ref(false);
|
||||
|
||||
const selectItem = () => {
|
||||
if (isNavigating.value) return;
|
||||
isNavigating.value = true;
|
||||
|
||||
if (props.type === "directory") {
|
||||
navigateToFolder();
|
||||
} else if (props.type === "file" && isEditableFile.value) {
|
||||
emit("edit", { name: props.name, type: props.type, path: props.path });
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigating.value = false;
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
40
apps/frontend/src/components/ui/servers/FileManagerError.vue
Normal file
40
apps/frontend/src/components/ui/servers/FileManagerError.vue
Normal file
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
|
||||
<FileIcon class="size-28" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="text-red-500 m-0 text-2xl font-bold">{{ title }}</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('refetch')">
|
||||
<UiServersIconsLoadingIcon class="h-5 w-5" />
|
||||
Try again
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('home')">
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
Go to home folder
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, HomeIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
message: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "refetch"): void;
|
||||
(e: "home"): void;
|
||||
}>();
|
||||
</script>
|
||||
120
apps/frontend/src/components/ui/servers/FileVirtualList.vue
Normal file
120
apps/frontend/src/components/ui/servers/FileVirtualList.vue
Normal file
@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<div ref="listContainer" data-pyro-files-virtual-list-root class="relative w-full">
|
||||
<div
|
||||
:style="{
|
||||
position: 'relative',
|
||||
minHeight: `${totalHeight}px`,
|
||||
}"
|
||||
data-pyro-files-virtual-height-watcher
|
||||
>
|
||||
<ul
|
||||
class="list-none"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: `${visibleTop}px`,
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}"
|
||||
data-pyro-files-virtual-list
|
||||
>
|
||||
<UiServersFileItem
|
||||
v-for="item in visibleItems"
|
||||
:key="item.path"
|
||||
:count="item.count"
|
||||
:created="item.created"
|
||||
:modified="item.modified"
|
||||
:name="item.name"
|
||||
:path="item.path"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
@delete="$emit('delete', item)"
|
||||
@rename="$emit('rename', item)"
|
||||
@download="$emit('download', item)"
|
||||
@move="$emit('move', item)"
|
||||
@edit="$emit('edit', item)"
|
||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
items: any[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", item: any): void;
|
||||
(e: "rename", item: any): void;
|
||||
(e: "download", item: any): void;
|
||||
(e: "move", item: any): void;
|
||||
(e: "edit", item: any): void;
|
||||
(e: "contextmenu", item: any, x: number, y: number): void;
|
||||
(e: "loadMore"): void;
|
||||
}>();
|
||||
|
||||
const ITEM_HEIGHT = 61;
|
||||
const BUFFER_SIZE = 5;
|
||||
|
||||
const listContainer = ref<HTMLElement | null>(null);
|
||||
const windowScrollY = ref(0);
|
||||
const windowHeight = ref(0);
|
||||
|
||||
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT);
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!listContainer.value) return { start: 0, end: 0 };
|
||||
|
||||
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY;
|
||||
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop);
|
||||
|
||||
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT);
|
||||
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT);
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - BUFFER_SIZE),
|
||||
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
|
||||
};
|
||||
});
|
||||
|
||||
const visibleTop = computed(() => {
|
||||
return visibleRange.value.start * ITEM_HEIGHT;
|
||||
});
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
return props.items.slice(visibleRange.value.start, visibleRange.value.end);
|
||||
});
|
||||
|
||||
const handleScroll = () => {
|
||||
windowScrollY.value = window.scrollY;
|
||||
|
||||
if (!listContainer.value) return;
|
||||
|
||||
const containerBottom = listContainer.value.getBoundingClientRect().bottom;
|
||||
const remainingScroll = containerBottom - window.innerHeight;
|
||||
|
||||
if (remainingScroll < windowHeight.value * 0.2) {
|
||||
emit("loadMore");
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
231
apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
Normal file
231
apps/frontend/src/components/ui/servers/FilesBrowseNavbar.vue
Normal file
@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
|
||||
<header
|
||||
:class="[
|
||||
'duration-20 h-26 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
|
||||
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
|
||||
]"
|
||||
data-pyro-files-state="browsing"
|
||||
aria-label="File navigation"
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="-ml-1 flex-shrink-0">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="$emit('navigate', -1)"
|
||||
>
|
||||
<span
|
||||
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Home</span>
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
|
||||
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
|
||||
<TransitionGroup
|
||||
name="breadcrumb"
|
||||
tag="span"
|
||||
class="relative flex min-w-0 flex-shrink items-center"
|
||||
>
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbSegments"
|
||||
:key="`${segment || index}-group`"
|
||||
class="relative flex min-w-0 flex-shrink items-center text-sm"
|
||||
>
|
||||
<div class="flex min-w-0 flex-shrink items-center">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer truncate focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
:aria-current="
|
||||
index === breadcrumbSegments.length - 1 ? 'location' : undefined
|
||||
"
|
||||
:class="{
|
||||
'!text-contrast': index === breadcrumbSegments.length - 1,
|
||||
}"
|
||||
@click="$emit('navigate', index)"
|
||||
>
|
||||
{{ segment || "" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbSegments.length - 1"
|
||||
class="size-4 flex-shrink-0 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-1">
|
||||
<div class="flex w-full flex-row-reverse sm:flex-row">
|
||||
<ButtonStyled type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Sort files"
|
||||
:options="[
|
||||
{ id: 'normal', action: () => $emit('sort', 'default') },
|
||||
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
||||
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
||||
]"
|
||||
>
|
||||
<span class="hidden whitespace-pre text-sm font-medium sm:block">
|
||||
{{ sortMethodLabel }}
|
||||
</span>
|
||||
<SortAscendingIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #normal> Alphabetical </template>
|
||||
<template #modified> Date modified </template>
|
||||
<template #filesOnly> Files only </template>
|
||||
<template #foldersOnly> Folders only </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 w-full text-sm sm:w-40">
|
||||
<label for="search-folder" class="sr-only">Search folder</label>
|
||||
<div class="relative">
|
||||
<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-folder"
|
||||
:value="searchQuery"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-button-bg bg-transparent py-2 pl-9"
|
||||
placeholder="Search..."
|
||||
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Create new..."
|
||||
:options="[
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||
{ id: 'upload', action: () => $emit('upload') },
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
PlusIcon,
|
||||
UploadIcon,
|
||||
DropdownIcon,
|
||||
FolderOpenIcon,
|
||||
SearchIcon,
|
||||
SortAscendingIcon,
|
||||
HomeIcon,
|
||||
ChevronRightIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed } from "vue";
|
||||
import { useIntersectionObserver } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbSegments: string[];
|
||||
searchQuery: string;
|
||||
sortMethod: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "navigate", index: number): void;
|
||||
(e: "sort", method: string): void;
|
||||
(e: "create", type: "file" | "directory"): void;
|
||||
(e: "upload"): void;
|
||||
(e: "update:searchQuery", value: string): void;
|
||||
}>();
|
||||
|
||||
const pyroFilesSentinel = ref<HTMLElement | null>(null);
|
||||
const isStuck = ref(false);
|
||||
|
||||
useIntersectionObserver(
|
||||
pyroFilesSentinel,
|
||||
([{ isIntersecting }]) => {
|
||||
isStuck.value = !isIntersecting;
|
||||
},
|
||||
{ threshold: [0, 1] },
|
||||
);
|
||||
|
||||
const sortMethodLabel = computed(() => {
|
||||
switch (props.sortMethod) {
|
||||
case "modified":
|
||||
return "Date modified";
|
||||
case "filesOnly":
|
||||
return "Files only";
|
||||
case "foldersOnly":
|
||||
return "Folders only";
|
||||
default:
|
||||
return "Alphabetical";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sentinel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.breadcrumb-move,
|
||||
.breadcrumb-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.9);
|
||||
}
|
||||
|
||||
.breadcrumb-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.8);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.breadcrumb-leave-active {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.breadcrumb-move {
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
106
apps/frontend/src/components/ui/servers/FilesContextMenu.vue
Normal file
106
apps/frontend/src/components/ui/servers/FilesContextMenu.vue
Normal file
@ -0,0 +1,106 @@
|
||||
<template>
|
||||
<div
|
||||
class="fixed"
|
||||
:style="{
|
||||
transform: `translateY(${isAtBottom ? '-100%' : '0'})`,
|
||||
top: `${y}px`,
|
||||
left: `${x}px`,
|
||||
}"
|
||||
>
|
||||
<Transition>
|
||||
<div
|
||||
v-if="item"
|
||||
id="item-context-menu"
|
||||
ref="ctxRef"
|
||||
:style="{
|
||||
border: '1px solid var(--color-button-bg)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
backgroundColor: 'var(--color-raised-bg)',
|
||||
padding: 'var(--gap-sm)',
|
||||
boxShadow: 'var(--shadow-floating)',
|
||||
gap: 'var(--gap-xs)',
|
||||
width: 'max-content',
|
||||
}"
|
||||
class="flex h-fit w-fit select-none flex-col"
|
||||
>
|
||||
<button
|
||||
class="btn btn-transparent flex !w-full items-center"
|
||||
@click="$emit('rename', item)"
|
||||
>
|
||||
<EditIcon class="h-5 w-5" />
|
||||
Rename
|
||||
</button>
|
||||
<button class="btn btn-transparent flex !w-full items-center" @click="$emit('move', item)">
|
||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||
Move
|
||||
</button>
|
||||
<button
|
||||
v-if="item.type !== 'directory'"
|
||||
class="btn btn-transparent flex !w-full items-center"
|
||||
@click="$emit('download', item)"
|
||||
>
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-transparent btn-red flex !w-full items-center"
|
||||
@click="$emit('delete', item)"
|
||||
>
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, ArrowBigUpDashIcon, DownloadIcon, TrashIcon } from "@modrinth/assets";
|
||||
|
||||
interface FileItem {
|
||||
type: string;
|
||||
name: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
item: FileItem | null;
|
||||
x: number;
|
||||
y: number;
|
||||
isAtBottom: boolean;
|
||||
}>();
|
||||
|
||||
const ctxRef = ref<HTMLElement | null>(null);
|
||||
|
||||
defineEmits<{
|
||||
(e: "rename", item: FileItem): void;
|
||||
(e: "move", item: FileItem): void;
|
||||
(e: "download", item: FileItem): void;
|
||||
(e: "delete", item: FileItem): void;
|
||||
}>();
|
||||
|
||||
defineExpose({
|
||||
ctxRef,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#item-context-menu {
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
opacity 0.1s ease;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
#item-context-menu.v-enter-active,
|
||||
#item-context-menu.v-leave-active {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#item-context-menu.v-enter-from,
|
||||
#item-context-menu.v-leave-to {
|
||||
transform: scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Creating a ${type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="createInput"
|
||||
v-model="itemName"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
|
||||
required
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
type: "file" | "directory";
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "create", name: string): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const createInput = ref<HTMLInputElement | null>(null);
|
||||
const itemName = ref("");
|
||||
const submitted = ref(false);
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return "Name is required.";
|
||||
}
|
||||
if (props.type === "file") {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true;
|
||||
if (!error.value) {
|
||||
emit("create", itemName.value);
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
itemName.value = "";
|
||||
submitted.value = false;
|
||||
modal.value?.show();
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
createInput.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div
|
||||
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-[#cb224436] bg-[#f57b7b0e] p-6 shadow-md dark:border-0 dark:bg-[#0e0e0ea4]"
|
||||
>
|
||||
<div
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#3f1818a4] p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
|
||||
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
|
||||
<span
|
||||
v-if="item?.type === 'directory'"
|
||||
class="text-xs text-secondary group-hover:text-primary"
|
||||
>
|
||||
{{ item?.count }} items
|
||||
</span>
|
||||
<span v-else class="text-xs text-secondary group-hover:text-primary">
|
||||
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="red">
|
||||
<button type="submit">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
Delete {{ item?.type }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from "@modrinth/assets";
|
||||
|
||||
defineProps<{
|
||||
item: {
|
||||
name: string;
|
||||
type: string;
|
||||
count?: number;
|
||||
size?: number;
|
||||
} | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("delete");
|
||||
hide();
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
modal.value?.show();
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
140
apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue
Normal file
140
apps/frontend/src/components/ui/servers/FilesEditingNavbar.vue
Normal file
@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<header
|
||||
data-pyro-files-state="editing"
|
||||
class="flex h-12 select-none items-center justify-between rounded-t-2xl bg-table-alternateRow p-3"
|
||||
aria-label="File editor navigation"
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
class="m-0 flex list-none items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex list-none items-center p-0">
|
||||
<li class="-ml-1">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="goHome"
|
||||
>
|
||||
<span
|
||||
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Home</span>
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 p-0">
|
||||
<ol class="m-0 flex items-center p-0">
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbSegments"
|
||||
:key="index"
|
||||
class="flex items-center text-sm"
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
:class="{ '!text-contrast': index === breadcrumbSegments.length - 1 }"
|
||||
@click="$emit('navigate', index)"
|
||||
>
|
||||
{{ segment || "" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbSegments.length"
|
||||
class="size-4 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</li>
|
||||
<li class="flex items-center px-3 text-sm">
|
||||
<span class="font-semibold !text-contrast" aria-current="location">{{
|
||||
fileName
|
||||
}}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div v-if="!isImage" class="flex gap-2">
|
||||
<Button
|
||||
v-if="isLogFile"
|
||||
v-tooltip="'Share to mclo.gs'"
|
||||
icon-only
|
||||
transparent
|
||||
aria-label="Share to mclo.gs"
|
||||
@click="$emit('share')"
|
||||
>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
<ButtonStyled type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Save file"
|
||||
:options="[
|
||||
{ id: 'save', action: () => $emit('save') },
|
||||
{ id: 'save-as', action: () => $emit('save-as') },
|
||||
{ id: 'save&restart', action: () => $emit('save-restart') },
|
||||
]"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
|
||||
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
|
||||
<template #save&restart>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
Save & restart
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, SaveIcon, ShareIcon, HomeIcon, ChevronRightIcon } from "@modrinth/assets";
|
||||
import { Button, ButtonStyled } from "@modrinth/ui";
|
||||
import { computed } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbSegments: string[];
|
||||
fileName?: string;
|
||||
isImage: boolean;
|
||||
filePath?: string;
|
||||
}>();
|
||||
|
||||
const isLogFile = computed(() => {
|
||||
return props.filePath?.startsWith("logs") || props.filePath?.endsWith(".log");
|
||||
});
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "cancel"): void;
|
||||
(e: "save"): void;
|
||||
(e: "save-as"): void;
|
||||
(e: "save-restart"): void;
|
||||
(e: "share"): void;
|
||||
(e: "navigate", index: number): void;
|
||||
}>();
|
||||
|
||||
const goHome = () => {
|
||||
emit("cancel");
|
||||
router.push({ path: "/servers/manage/" + route.params.id + "/files" });
|
||||
};
|
||||
</script>
|
||||
159
apps/frontend/src/components/ui/servers/FilesImageViewer.vue
Normal file
159
apps/frontend/src/components/ui/servers/FilesImageViewer.vue
Normal file
@ -0,0 +1,159 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center bg-bg-raised">
|
||||
<div
|
||||
ref="container"
|
||||
class="relative w-full flex-grow overflow-hidden bg-bg-raised"
|
||||
@mousedown="startPan"
|
||||
@mousemove="pan"
|
||||
@mouseup="endPan"
|
||||
@mouseleave="endPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<UiServersPyroLoading v-if="loading" />
|
||||
<div v-if="error" class="flex h-full w-full flex-col items-center justify-center gap-8">
|
||||
<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-12"
|
||||
>
|
||||
<path d="M4 13c3.5-2 8-2 10 2a5.5 5.5 0 0 1 8 5" />
|
||||
<path
|
||||
d="M5.15 17.89c5.52-1.52 8.65-6.89 7-12C11.55 4 11.5 2 13 2c3.22 0 5 5.5 5 8 0 6.5-4.2 12-10.49 12C5.11 22 2 22 2 20c0-1.5 1.14-1.55 3.15-2.11Z"
|
||||
/>
|
||||
</svg>
|
||||
<p class="m-0">Invalid or empty image file.</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="!loading && !error"
|
||||
ref="image"
|
||||
:src="imageUrl"
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
||||
:style="{
|
||||
transform: `translate(-50%, -50%) scale(${scale}) translate(${translateX}px, ${translateY}px)`,
|
||||
transition: isPanning ? 'none' : 'transform 0.3s ease-out',
|
||||
}"
|
||||
alt="Viewed image"
|
||||
@load="onImageLoad"
|
||||
@error="onImageError"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!error"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center space-x-4 rounded-xl bg-bg p-2"
|
||||
>
|
||||
<Button icon-only transparent @click="zoomIn">
|
||||
<ZoomInIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="resetZoom">
|
||||
<HomeIcon />
|
||||
</Button>
|
||||
<Button icon-only transparent @click="zoomOut">
|
||||
<ZoomOutIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { HomeIcon, ZoomInIcon, ZoomOutIcon } from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
imageBlob: {
|
||||
type: Blob,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const container = ref(null);
|
||||
const image = ref(null);
|
||||
const scale = ref(1);
|
||||
const translateX = ref(0);
|
||||
const translateY = ref(0);
|
||||
const isPanning = ref(false);
|
||||
const startX = ref(0);
|
||||
const startY = ref(0);
|
||||
const imageUrl = ref("");
|
||||
const loading = ref(true);
|
||||
const error = ref(false);
|
||||
|
||||
const createImageUrl = (blob) => {
|
||||
if (imageUrl.value) {
|
||||
URL.revokeObjectURL(imageUrl.value);
|
||||
}
|
||||
imageUrl.value = URL.createObjectURL(blob);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (newBlob) {
|
||||
loading.value = true;
|
||||
error.value = false;
|
||||
createImageUrl(newBlob);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) {
|
||||
createImageUrl(props.imageBlob);
|
||||
}
|
||||
});
|
||||
|
||||
const onImageLoad = () => {
|
||||
loading.value = false;
|
||||
resetZoom();
|
||||
};
|
||||
|
||||
const onImageError = () => {
|
||||
loading.value = false;
|
||||
error.value = true;
|
||||
};
|
||||
|
||||
const zoomIn = () => {
|
||||
scale.value = Math.min(scale.value * 1.2, 5);
|
||||
};
|
||||
|
||||
const zoomOut = () => {
|
||||
scale.value = Math.max(scale.value / 1.2, 0.1);
|
||||
};
|
||||
|
||||
const resetZoom = () => {
|
||||
scale.value = 0.5;
|
||||
translateX.value = 0;
|
||||
translateY.value = 0;
|
||||
};
|
||||
|
||||
const startPan = (e) => {
|
||||
isPanning.value = true;
|
||||
startX.value = e.clientX - translateX.value;
|
||||
startY.value = e.clientY - translateY.value;
|
||||
};
|
||||
|
||||
const pan = (e) => {
|
||||
if (isPanning.value) {
|
||||
translateX.value = e.clientX - startX.value;
|
||||
translateY.value = e.clientY - startY.value;
|
||||
}
|
||||
};
|
||||
|
||||
const endPan = () => {
|
||||
isPanning.value = false;
|
||||
};
|
||||
|
||||
const handleWheel = (e) => {
|
||||
const delta = (e.deltaY * -0.01) / 10;
|
||||
const newScale = Math.max(0.1, Math.min(scale.value + delta, 5));
|
||||
|
||||
scale.value = newScale;
|
||||
};
|
||||
</script>
|
||||
@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Moving ${item?.name}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
ref="destinationInput"
|
||||
v-model="destination"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. mods/modname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-nowrap">
|
||||
New location:
|
||||
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
|
||||
<span class="text-secondary">/root</span>{{ newpath }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit">
|
||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||
Move
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowBigUpDashIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, nextTick } from "vue";
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const props = defineProps<{
|
||||
item: { name: string } | null;
|
||||
currentPath: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "move", destination: string): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const destination = ref("");
|
||||
const newpath = computed(() => {
|
||||
return destination.value.replace("//", "/");
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit("move", destination.value);
|
||||
hide();
|
||||
};
|
||||
|
||||
const show = () => {
|
||||
destination.value = props.currentPath;
|
||||
modal.value?.show();
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
destinationInput.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="renameInput"
|
||||
v-model="itemName"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
required
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<EditIcon class="h-5 w-5" />
|
||||
Rename
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
item: { name: string; type: string } | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "rename", newName: string): void;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
const renameInput = ref<HTMLInputElement | null>(null);
|
||||
const itemName = ref("");
|
||||
const submitted = ref(false);
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return "Name is required.";
|
||||
}
|
||||
if (props.item?.type === "file") {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/;
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.";
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/;
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return "Name must contain only alphanumeric characters, dashes, underscores, or spaces.";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
});
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true;
|
||||
if (!error.value) {
|
||||
emit("rename", itemName.value);
|
||||
hide();
|
||||
}
|
||||
};
|
||||
|
||||
const show = (item: { name: string; type: string }) => {
|
||||
itemName.value = item.name;
|
||||
submitted.value = false;
|
||||
modal.value?.show();
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
renameInput.value?.focus();
|
||||
}, 100);
|
||||
});
|
||||
};
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide();
|
||||
};
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
76
apps/frontend/src/components/ui/servers/LoaderSelector.vue
Normal file
76
apps/frontend/src/components/ui/servers/LoaderSelector.vue
Normal file
@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div
|
||||
v-for="loader in loaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
:class="isCurrentLoader(loader.name) ? '[&&]:bg-bg-green' : ''"
|
||||
>
|
||||
<UiServersIconsLoaderIcon
|
||||
:loader="loader.name"
|
||||
class="[&&]:size-6"
|
||||
:class="isCurrentLoader(loader.name) ? 'text-brand' : ''"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
||||
{{ loader.displayName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isCurrentLoader(loader.name)"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="isCurrentLoader(loader.name)" class="m-0 text-xs text-secondary">
|
||||
{{ data.loader_version }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button @click="selectLoader(loader.name)">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader(loader.name) ? "Reinstall" : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
loader: string | null;
|
||||
loader_version: string | null;
|
||||
};
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "selectLoader", loader: string): void;
|
||||
}>();
|
||||
|
||||
const loaders = [
|
||||
{ name: "Vanilla" as const, displayName: "Vanilla" },
|
||||
{ name: "Fabric" as const, displayName: "Fabric" },
|
||||
{ name: "Quilt" as const, displayName: "Quilt" },
|
||||
{ name: "Forge" as const, displayName: "Forge" },
|
||||
{ name: "NeoForge" as const, displayName: "NeoForge" },
|
||||
];
|
||||
|
||||
const isCurrentLoader = (loaderName: string) => {
|
||||
return props.data.loader?.toLowerCase() === loaderName.toLowerCase();
|
||||
};
|
||||
|
||||
const selectLoader = (loader: string) => {
|
||||
emit("selectLoader", loader);
|
||||
};
|
||||
</script>
|
||||
107
apps/frontend/src/components/ui/servers/LogParser.vue
Normal file
107
apps/frontend/src/components/ui/servers/LogParser.vue
Normal file
@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="parsed-log group relative w-full overflow-hidden px-6 py-1">
|
||||
<div
|
||||
ref="logContent"
|
||||
class="log-content selectable whitespace-pre-wrap selection:bg-black selection:text-white dark:selection:bg-white dark:selection:text-black"
|
||||
v-html="sanitizedLog"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import Convert from "ansi-to-html";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
const props = defineProps<{
|
||||
log: string;
|
||||
index: number;
|
||||
}>();
|
||||
|
||||
const logContent = ref<HTMLElement | null>(null);
|
||||
|
||||
const colors = {
|
||||
30: "#101010",
|
||||
31: "#EFA6A2",
|
||||
32: "#80C990",
|
||||
33: "#A69460",
|
||||
34: "#A3B8EF",
|
||||
35: "#E6A3DC",
|
||||
36: "#50CACD",
|
||||
37: "#808080",
|
||||
90: "#454545",
|
||||
91: "#E0AF85",
|
||||
92: "#5ACCAF",
|
||||
93: "#C8C874",
|
||||
94: "#CCACED",
|
||||
95: "#F2A1C2",
|
||||
96: "#74C3E4",
|
||||
97: "#C0C0C0",
|
||||
};
|
||||
|
||||
const convert = new Convert({
|
||||
fg: "#FFF",
|
||||
bg: "#000",
|
||||
newline: false,
|
||||
escapeXML: true,
|
||||
stream: false,
|
||||
colors,
|
||||
});
|
||||
|
||||
const urlRegex = /https?:\/\/[^\s]+/g;
|
||||
const usernameRegex = /<([^&]+)>/g;
|
||||
|
||||
const sanitizedLog = computed(() => {
|
||||
let html = convert.toHtml(props.log);
|
||||
html = html.replace(
|
||||
urlRegex,
|
||||
(url) =>
|
||||
`<a style="color:var(--color-link);text-decoration:underline;" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`,
|
||||
);
|
||||
html = html.replace(
|
||||
usernameRegex,
|
||||
(_, username) => `<span class="minecraft-username"><${username}></span>`,
|
||||
);
|
||||
return DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ["span", "a"],
|
||||
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
|
||||
ADD_ATTR: ["target"],
|
||||
RETURN_TRUSTED_TYPE: true,
|
||||
USE_PROFILES: { html: true },
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parsed-log:hover:not(.selected) {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
html.light-mode .parsed-log:hover:not(.selected) {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
html.dark-mode .parsed-log:hover:not(.selected) {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
html.oled-mode .parsed-log:hover:not(.selected) {
|
||||
background-color: #222;
|
||||
}
|
||||
|
||||
.minecraft-username {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
::v-deep(.log-content) {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
::v-deep(.log-content.selectable) {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
::v-deep(.log-content *) {
|
||||
user-select: text;
|
||||
}
|
||||
</style>
|
||||
660
apps/frontend/src/components/ui/servers/MOTDEditor.vue
Normal file
660
apps/frontend/src/components/ui/servers/MOTDEditor.vue
Normal file
@ -0,0 +1,660 @@
|
||||
<template>
|
||||
<div class="flex items-center justify-center">
|
||||
<div class="w-full overflow-hidden">
|
||||
<div class="mb-4">
|
||||
<div
|
||||
v-for="(line, lineIndex) in motd"
|
||||
:key="lineIndex"
|
||||
class="relative mb-2 rounded bg-button-bg p-2"
|
||||
>
|
||||
<div
|
||||
class="font-minecraft text-white"
|
||||
:contenteditable="true"
|
||||
spellcheck="false"
|
||||
@input="handleInput($event, lineIndex)"
|
||||
@keydown.enter.prevent
|
||||
@paste.prevent="handlePaste($event, lineIndex)"
|
||||
@mouseup="handleSelection(lineIndex)"
|
||||
v-html="renderLine(line)"
|
||||
></div>
|
||||
<div class="text-sm text-gray-400">
|
||||
{{ motd[lineIndex].reduce((sum, segment) => sum + segment.text.length, 0) }}/45
|
||||
characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<transition name="fade">
|
||||
<div
|
||||
v-if="showPopup"
|
||||
:style="{ top: `${popupY}px`, left: `${popupX}px` }"
|
||||
class="fixed z-10 flex flex-col items-end gap-2 transition-all duration-300 ease-in-out"
|
||||
>
|
||||
<div class="rounded-xl border bg-table-alternateRow p-2 shadow-lg">
|
||||
<div class="flex space-x-2">
|
||||
<Button
|
||||
v-for="style in styles"
|
||||
:key="style.name"
|
||||
icon-only
|
||||
transparent
|
||||
@click="applyStyle({ [style.name]: !currentStyle[style.name] })"
|
||||
>
|
||||
<component :is="style.icon" class="h-4 w-4" />
|
||||
</Button>
|
||||
<div class="relative overflow-y-scroll">
|
||||
<Button icon-only transparent :class="colorPicker ?? 'hidden'" @click="pickColor">
|
||||
<PaintBrushIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="colorPicker"
|
||||
icon-only
|
||||
class="w-fit overflow-y-auto rounded-xl p-2 [&&]:bg-table-alternateRow"
|
||||
>
|
||||
<div :class="colorPicker ? `grid grid-flow-col grid-rows-4 gap-2` : '[&&]:hidden'">
|
||||
<button
|
||||
v-for="format in sortedFormatCodes()"
|
||||
:key="format.code"
|
||||
class="rounded-full p-3"
|
||||
:style="{ backgroundColor: format.color }"
|
||||
:title="format.description"
|
||||
@click="applyStyle({ color: format.color })"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
ItalicIcon,
|
||||
BoldIcon,
|
||||
StrikethroughIcon,
|
||||
UnderlineIcon,
|
||||
PaintBrushIcon,
|
||||
ChevronLeftIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Button } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
server: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const formatCodes = [
|
||||
{ code: "§f", color: "white", description: "White" },
|
||||
{ code: "§7", color: "#AAAAAA", description: "Gray" },
|
||||
{ code: "§8", color: "#555555", description: "Dark Gray" },
|
||||
{ code: "§0", color: "#000000", description: "Black" },
|
||||
{ code: "§9", color: "#5555FF", description: "Blue" },
|
||||
{ code: "§1", color: "#0000AA", description: "Dark Blue" },
|
||||
{ code: "§b", color: "#55FFFF", description: "Aqua" },
|
||||
{ code: "§3", color: "#00AAAA", description: "Dark Aqua" },
|
||||
{ code: "§a", color: "#55FF55", description: "Green" },
|
||||
{ code: "§2", color: "#00AA00", description: "Dark Green" },
|
||||
{ code: "§e", color: "#FFFF55", description: "Yellow" },
|
||||
{ code: "§6", color: "#FFAA00", description: "Gold" },
|
||||
{ code: "§c", color: "#FF5555", description: "Red" },
|
||||
{ code: "§4", color: "#AA0000", description: "Dark Red" },
|
||||
{ code: "§d", color: "#FF55FF", description: "Light Purple" },
|
||||
{ code: "§5", color: "#AA00AA", description: "Dark Purple" },
|
||||
];
|
||||
|
||||
const sortedFormatCodes = () => {
|
||||
const colors = formatCodes;
|
||||
if (colors[0].description === "White") {
|
||||
colors.reverse();
|
||||
}
|
||||
return colors;
|
||||
};
|
||||
|
||||
const minecraftEmojis = [
|
||||
{ char: "☺", name: "SMILING FACE" },
|
||||
{ char: "☹", name: "FROWNING FACE" },
|
||||
{ char: "☠", name: "SKULL AND CROSSBONES" },
|
||||
{ char: "❣", name: "HEART EXCLAMATION" },
|
||||
{ char: "❤", name: "RED HEART" },
|
||||
{ char: "✌", name: "VICTORY HAND" },
|
||||
{ char: "☝", name: "INDEX POINTING UP" },
|
||||
{ char: "✍", name: "WRITING HAND" },
|
||||
{ char: "♨", name: "HOT SPRINGS" },
|
||||
{ char: "✈", name: "AIRPLANE" },
|
||||
{ char: "⌛", name: "HOURGLASS DONE" },
|
||||
{ char: "⌚", name: "WATCH" },
|
||||
{ char: "☀", name: "SUN" },
|
||||
{ char: "☁", name: "CLOUD" },
|
||||
{ char: "☂", name: "UMBRELLA" },
|
||||
{ char: "❄", name: "SNOWFLAKE" },
|
||||
{ char: "☃", name: "SNOWMAN" },
|
||||
{ char: "☄", name: "COMET" },
|
||||
{ char: "♠", name: "SPADE SUIT" },
|
||||
{ char: "♥", name: "HEART SUIT" },
|
||||
{ char: "♦", name: "DIAMOND SUIT" },
|
||||
{ char: "♣", name: "CLUB SUIT" },
|
||||
{ char: "♟", name: "CHESS PAWN" },
|
||||
{ char: "☎", name: "TELEPHONE" },
|
||||
{ char: "⌨", name: "KEYBOARD" },
|
||||
{ char: "✉", name: "ENVELOPE" },
|
||||
{ char: "✏", name: "PENCIL" },
|
||||
{ char: "✒", name: "BLACK PEN" },
|
||||
{ char: "✂", name: "SCISSORS" },
|
||||
{ char: "☢", name: "RADIOACTIVE" },
|
||||
{ char: "☣", name: "BIOHAZARD" },
|
||||
{ char: "⬆", name: "UP ARROW" },
|
||||
{ char: "⬇", name: "DOWN ARROW" },
|
||||
{ char: "➡", name: "RIGHT ARROW" },
|
||||
{ char: "⬅", name: "LEFT ARROW" },
|
||||
{ char: "↗", name: "UP-RIGHT ARROW" },
|
||||
{ char: "↘", name: "DOWN-RIGHT ARROW" },
|
||||
{ char: "↙", name: "DOWN-LEFT ARROW" },
|
||||
{ char: "↖", name: "UP-LEFT ARROW" },
|
||||
{ char: "↕", name: "UP-DOWN ARROW" },
|
||||
{ char: "↔", name: "LEFT-RIGHT ARROW" },
|
||||
{ char: "↩", name: "RIGHT ARROW CURVING LEFT" },
|
||||
{ char: "↪", name: "LEFT ARROW CURVING RIGHT" },
|
||||
{ char: "✡", name: "STAR OF DAVID" },
|
||||
{ char: "☸", name: "WHEEL OF DHARMA" },
|
||||
{ char: "☯", name: "YIN YANG" },
|
||||
{ char: "✝", name: "LATIN CROSS" },
|
||||
{ char: "☦", name: "ORTHODOX CROSS" },
|
||||
{ char: "☪", name: "STAR AND CRESCENT" },
|
||||
{ char: "☮", name: "PEACE SYMBOL" },
|
||||
{ char: "♈", name: "ARIES" },
|
||||
{ char: "♉", name: "TAURUS" },
|
||||
{ char: "♊", name: "GEMINI" },
|
||||
{ char: "♋", name: "CANCER" },
|
||||
{ char: "♌", name: "LEO" },
|
||||
{ char: "♍", name: "VIRGO" },
|
||||
{ char: "♎", name: "LIBRA" },
|
||||
{ char: "♏", name: "SCORPIO" },
|
||||
{ char: "♐", name: "SAGITTARIUS" },
|
||||
{ char: "♑", name: "CAPRICORN" },
|
||||
{ char: "♒", name: "AQUARIUS" },
|
||||
{ char: "♓", name: "PISCES" },
|
||||
{ char: "▶", name: "PLAY BUTTON" },
|
||||
{ char: "◀", name: "REVERSE BUTTON" },
|
||||
{ char: "♀", name: "FEMALE SIGN" },
|
||||
{ char: "♂", name: "MALE SIGN" },
|
||||
{ char: "✖", name: "MULTIPLY" },
|
||||
{ char: "‼", name: "DOUBLE EXCLAMATION MARK" },
|
||||
{ char: "〰", name: "WAVY DASH" },
|
||||
{ char: "☑", name: "CHECK BOX WITH CHECK" },
|
||||
{ char: "✔", name: "CHECK MARK" },
|
||||
{ char: "✳", name: "EIGHT-SPOKED ASTERISK" },
|
||||
{ char: "✴", name: "EIGHT-POINTED STAR" },
|
||||
{ char: "❇", name: "SPARKLE" },
|
||||
{ char: "©", name: "COPYRIGHT" },
|
||||
{ char: "®", name: "REGISTERED" },
|
||||
{ char: "™", name: "TRADE MARK" },
|
||||
{ char: "Ⓜ", name: "CIRCLED M" },
|
||||
{ char: "㊗", name: 'JAPANESE "CONGRATULATIONS" BUTTON' },
|
||||
{ char: "㊙", name: 'JAPANESE "SECRET" BUTTON' },
|
||||
{ char: "▪", name: "BLACK SMALL SQUARE" },
|
||||
{ char: "▫", name: "WHITE SMALL SQUARE" },
|
||||
{ char: "☷", name: "TRIGRAM FOR EARTH" },
|
||||
{ char: "☵", name: "TRIGRAM FOR WATER" },
|
||||
{ char: "☶", name: "TRIGRAM FOR MOUNTAIN" },
|
||||
{ char: "☋", name: "DESCENDING NODE" },
|
||||
{ char: "☌", name: "CONJUNCTION" },
|
||||
{ char: "♜", name: "BLACK CHESS ROOK" },
|
||||
{ char: "♕", name: "WHITE CHESS QUEEN" },
|
||||
{ char: "♡", name: "WHITE HEART SUIT" },
|
||||
{ char: "♬", name: "BEAMED SIXTEENTH NOTES" },
|
||||
{ char: "☚", name: "BLACK LEFT POINTING INDEX" },
|
||||
{ char: "♮", name: "MUSIC NATURAL SIGN" },
|
||||
{ char: "♝", name: "BLACK CHESS BISHOP" },
|
||||
{ char: "♯", name: "SHARP" },
|
||||
{ char: "☴", name: "TRIGRAM FOR WIND" },
|
||||
{ char: "♭", name: "FLAT" },
|
||||
{ char: "☓", name: "SALTIRE" },
|
||||
{ char: "☛", name: "BLACK RIGHT POINTING INDEX" },
|
||||
{ char: "☭", name: "HAMMER AND SICKLE" },
|
||||
{ char: "♢", name: "WHITE DIAMOND SUIT" },
|
||||
{ char: "✐", name: "UPPER RIGHT PENCIL" },
|
||||
{ char: "♖", name: "WHITE CHESS ROOK" },
|
||||
{ char: "☈", name: "THUNDERSTORM" },
|
||||
{ char: "☒", name: "BALLOT BOX WITH X" },
|
||||
{ char: "★", name: "BLACK STAR" },
|
||||
{ char: "♚", name: "BLACK CHESS KING" },
|
||||
{ char: "♛", name: "BLACK CHESS QUEEN" },
|
||||
{ char: "✎", name: "LOWER RIGHT PENCIL" },
|
||||
{ char: "♪", name: "EIGHTH NOTE" },
|
||||
{ char: "☰", name: "TRIGRAM FOR HEAVEN" },
|
||||
{ char: "☽", name: "FIRST QUARTER MOON" },
|
||||
{ char: "☡", name: "CAUTION SIGN" },
|
||||
{ char: "☼", name: "WHITE SUN WITH RAYS" },
|
||||
{ char: "♅", name: "URANUS" },
|
||||
{ char: "☐", name: "BALLOT BOX" },
|
||||
{ char: "☟", name: "WHITE DOWN POINTING INDEX" },
|
||||
{ char: "❦", name: "FLORAL HEART" },
|
||||
{ char: "☊", name: "ASCENDING NODE" },
|
||||
{ char: "☍", name: "OPPOSITION" },
|
||||
{ char: "☬", name: "ADI SHAKTI" },
|
||||
{ char: "♧", name: "WHITE CLUB SUIT" },
|
||||
{ char: "☫", name: "FARSI SYMBOL" },
|
||||
{ char: "☱", name: "TRIGRAM FOR LAKE" },
|
||||
{ char: "☾", name: "LAST QUARTER MOON" },
|
||||
{ char: "☤", name: "CADUCEUS" },
|
||||
{ char: "❧", name: "ROTATED FLORAL HEART BULLET" },
|
||||
{ char: "♄", name: "SATURN" },
|
||||
{ char: "♁", name: "EARTH" },
|
||||
{ char: "♔", name: "WHITE CHESS KING" },
|
||||
{ char: "❥", name: "ROTATED HEAVY BLACK HEART BULLET" },
|
||||
{ char: "☥", name: "ANKH" },
|
||||
{ char: "☻", name: "BLACK SMILING FACE" },
|
||||
{ char: "♤", name: "WHITE SPADE SUIT" },
|
||||
{ char: "♞", name: "BLACK CHESS KNIGHT" },
|
||||
{ char: "♆", name: "NEPTUNE" },
|
||||
{ char: "#", name: "HASH SIGN" },
|
||||
{ char: "♃", name: "JUPITER" },
|
||||
{ char: "♩", name: "QUARTER NOTE" },
|
||||
{ char: "☇", name: "LIGHTNING" },
|
||||
{ char: "☞", name: "WHITE RIGHT POINTING INDEX" },
|
||||
{ char: "♫", name: "BEAMED EIGHTH NOTES" },
|
||||
{ char: "☏", name: "WHITE TELEPHONE" },
|
||||
{ char: "♘", name: "WHITE CHESS KNIGHT" },
|
||||
{ char: "☧", name: "CHI RHO" },
|
||||
{ char: "☉", name: "SUN" },
|
||||
{ char: "♇", name: "PLUTO" },
|
||||
{ char: "☩", name: "CROSS OF JERUSALEM" },
|
||||
{ char: "♙", name: "WHITE CHESS PAWN" },
|
||||
{ char: "☜", name: "WHITE LEFT POINTING INDEX" },
|
||||
{ char: "☲", name: "TRIGRAM FOR FIRE" },
|
||||
{ char: "☨", name: "CROSS OF LORRAINE" },
|
||||
{ char: "♗", name: "WHITE CHESS BISHOP" },
|
||||
{ char: "☳", name: "TRIGRAM FOR THUNDER" },
|
||||
{ char: "⚔", name: "CROSSED SWORDS" },
|
||||
{ char: "⚀", name: "DICE ONE" },
|
||||
];
|
||||
|
||||
const rawMotd = ref(props.server.general?.motd ?? "");
|
||||
|
||||
const motd = computed(() => {
|
||||
const lines = rawMotd.value.split("\n");
|
||||
return lines.map((line) => {
|
||||
const segments = [];
|
||||
let currentSegment = { text: "", color: "White" };
|
||||
let i = 0;
|
||||
while (i < line.length) {
|
||||
if (line[i] === "§") {
|
||||
if (currentSegment.text) {
|
||||
segments.push({ ...currentSegment });
|
||||
currentSegment = { text: "", color: "White" };
|
||||
}
|
||||
const formatCode = line.substr(i, 2);
|
||||
const format = formatCodes.find((f) => f.code === formatCode);
|
||||
console.log(format);
|
||||
console.log(formatCode);
|
||||
if (format) {
|
||||
currentSegment.color = format.color;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§l") {
|
||||
currentSegment.bold = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§o") {
|
||||
currentSegment.italic = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§n") {
|
||||
currentSegment.underline = true;
|
||||
i += 2;
|
||||
continue;
|
||||
} else if (formatCode === "§m") {
|
||||
currentSegment.strikethrough = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
currentSegment.text += line[i];
|
||||
i++;
|
||||
}
|
||||
if (currentSegment.text) {
|
||||
segments.push(currentSegment);
|
||||
}
|
||||
return segments;
|
||||
});
|
||||
});
|
||||
|
||||
const styles = [
|
||||
{
|
||||
name: "bold",
|
||||
icon: BoldIcon,
|
||||
},
|
||||
{
|
||||
name: "italic",
|
||||
icon: ItalicIcon,
|
||||
},
|
||||
{
|
||||
name: "underline",
|
||||
icon: UnderlineIcon,
|
||||
},
|
||||
{
|
||||
name: "strikethrough",
|
||||
icon: StrikethroughIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const showPopup = ref(false);
|
||||
const popupX = ref(0);
|
||||
const popupY = ref(0);
|
||||
const currentLineIndex = ref(0);
|
||||
const selectionStart = ref(0);
|
||||
const selectionEnd = ref(0);
|
||||
const colorPicker = ref(false);
|
||||
|
||||
const pickColor = () => {
|
||||
colorPicker.value = !colorPicker.value;
|
||||
};
|
||||
|
||||
const totalCharacters = computed(() => {
|
||||
return motd.value.reduce((sum, line) => {
|
||||
return Math.max(
|
||||
sum,
|
||||
line.reduce((lineSum, segment) => lineSum + segment.text.length, 0),
|
||||
);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
const minecraftFormat = computed(() => {
|
||||
return motd.value
|
||||
.map((line) => {
|
||||
return line
|
||||
.map((segment) => {
|
||||
let format = getColorCode(segment.color);
|
||||
if (segment.bold) format += "§l";
|
||||
if (segment.italic) format += "§o";
|
||||
if (segment.underline) format += "§n";
|
||||
if (segment.strikethrough) format += "§m";
|
||||
return format + segment.text;
|
||||
})
|
||||
.join("");
|
||||
})
|
||||
.join("\n");
|
||||
});
|
||||
|
||||
const currentStyle = computed(() => {
|
||||
const line = motd.value[currentLineIndex.value];
|
||||
if (!line) return {};
|
||||
|
||||
let start = 0;
|
||||
for (const segment of line) {
|
||||
if (start + segment.text.length > selectionStart.value) {
|
||||
return {
|
||||
color: segment.color || "White",
|
||||
bold: segment.bold || false,
|
||||
italic: segment.italic || false,
|
||||
underline: segment.underline || false,
|
||||
strikethrough: segment.strikethrough || false,
|
||||
};
|
||||
}
|
||||
start += segment.text.length;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
function getColorCode(color) {
|
||||
const format = formatCodes.find((f) => f.description === color);
|
||||
return format ? format.code : "§f";
|
||||
}
|
||||
|
||||
function renderLine(line) {
|
||||
return line
|
||||
.map((segment) => {
|
||||
let style = `color: ${segment.color};`;
|
||||
if (segment.bold) style += "font-weight: 900;";
|
||||
if (segment.italic) style += "font-style: italic;";
|
||||
if (segment.underline) style += "text-decoration: underline;";
|
||||
if (segment.strikethrough) style += "text-decoration: line-through;";
|
||||
return `<span style="${style}">${segment.text}</span>`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function handleSelection(lineIndex) {
|
||||
const selection = window.getSelection();
|
||||
if (selection.toString().length > 0) {
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
popupX.value = rect.left;
|
||||
popupY.value = rect.bottom;
|
||||
showPopup.value = true;
|
||||
currentLineIndex.value = lineIndex;
|
||||
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
|
||||
const rangeClone = range.cloneRange();
|
||||
rangeClone.selectNodeContents(lineElement);
|
||||
rangeClone.setEnd(range.startContainer, range.startOffset);
|
||||
selectionStart.value = rangeClone.toString().length;
|
||||
selectionEnd.value = selectionStart.value + range.toString().length;
|
||||
} else {
|
||||
showPopup.value = false;
|
||||
colorPicker.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyStyle(newStyle) {
|
||||
const line = motd.value[currentLineIndex.value];
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
|
||||
for (const segment of line) {
|
||||
if (currentPos + segment.text.length <= selectionStart.value) {
|
||||
newLine.push(segment);
|
||||
} else if (currentPos >= selectionEnd.value) {
|
||||
newLine.push(segment);
|
||||
} else {
|
||||
const beforeSelection = segment.text.slice(0, Math.max(0, selectionStart.value - currentPos));
|
||||
const inSelection = segment.text.slice(
|
||||
Math.max(0, selectionStart.value - currentPos),
|
||||
Math.min(segment.text.length, selectionEnd.value - currentPos),
|
||||
);
|
||||
const afterSelection = segment.text.slice(
|
||||
Math.min(segment.text.length, selectionEnd.value - currentPos),
|
||||
);
|
||||
console.log(beforeSelection);
|
||||
console.log(inSelection);
|
||||
console.log(afterSelection);
|
||||
|
||||
if (beforeSelection) newLine.push({ ...segment, text: beforeSelection });
|
||||
if (inSelection) {
|
||||
const mergedStyle = { ...segment, ...newStyle };
|
||||
for (const key in newStyle) {
|
||||
if (newStyle[key] === false) {
|
||||
delete mergedStyle[key];
|
||||
}
|
||||
}
|
||||
newLine.push({ ...mergedStyle, text: inSelection });
|
||||
}
|
||||
if (afterSelection) newLine.push({ ...segment, text: afterSelection });
|
||||
}
|
||||
currentPos += segment.text.length;
|
||||
}
|
||||
|
||||
motd.value[currentLineIndex.value] = newLine;
|
||||
showPopup.value = false;
|
||||
colorPicker.value = false;
|
||||
|
||||
// Rerender the line to reflect the changes
|
||||
nextTick(() => {
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[currentLineIndex.value];
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
});
|
||||
}
|
||||
|
||||
function insertEmoji() {
|
||||
const emoji = "☺";
|
||||
if (totalCharacters.value + emoji.length <= 90) {
|
||||
applyStyle({ text: emoji });
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event, lineIndex) {
|
||||
const newText = event.target.textContent;
|
||||
const oldText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
|
||||
const diff = newText.length - oldText.length;
|
||||
|
||||
if (newText.length <= 45) {
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
const cursorOffset = getCursorOffset(event.target, range);
|
||||
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
for (const segment of motd.value[lineIndex]) {
|
||||
const segmentEnd = currentPos + segment.text.length;
|
||||
const newSegmentText = newText.slice(currentPos, Math.min(segmentEnd, newText.length));
|
||||
if (newSegmentText) {
|
||||
newLine.push({ ...segment, text: newSegmentText });
|
||||
}
|
||||
currentPos = segmentEnd;
|
||||
if (currentPos >= newText.length) break;
|
||||
}
|
||||
if (currentPos < newText.length) {
|
||||
newLine.push({ text: newText.slice(currentPos), color: "White" });
|
||||
}
|
||||
motd.value[lineIndex] = newLine;
|
||||
|
||||
nextTick(() => {
|
||||
const lineElement = event.target;
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
|
||||
const newRange = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
const { node, offset } = getCursorNodeAndOffset(lineElement, cursorOffset);
|
||||
|
||||
if (node) {
|
||||
newRange.setStart(node, offset);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
event.target.innerHTML = renderLine(motd.value[lineIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get cursor offset considering styled spans
|
||||
function getCursorOffset(element, range) {
|
||||
let offset = 0;
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node === range.startContainer) {
|
||||
return offset + range.startOffset;
|
||||
}
|
||||
offset += node.length;
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
// Helper function to find the node and offset for cursor placement
|
||||
function getCursorNodeAndOffset(element, targetOffset) {
|
||||
let currentOffset = 0;
|
||||
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null, false);
|
||||
let node;
|
||||
|
||||
while ((node = walker.nextNode())) {
|
||||
if (currentOffset + node.length >= targetOffset) {
|
||||
return { node, offset: targetOffset - currentOffset };
|
||||
}
|
||||
currentOffset += node.length;
|
||||
}
|
||||
|
||||
// If we've gone past the end, return the last possible position
|
||||
const lastTextNode = element.lastChild?.lastChild;
|
||||
return { node: lastTextNode, offset: lastTextNode?.length || 0 };
|
||||
}
|
||||
|
||||
function handlePaste(event, lineIndex) {
|
||||
event.preventDefault();
|
||||
const pastedText = (event.clipboardData || window.clipboardData).getData("text");
|
||||
const selection = window.getSelection();
|
||||
const range = selection.getRangeAt(0);
|
||||
const startOffset = range.startOffset;
|
||||
|
||||
const currentText = motd.value[lineIndex].reduce((acc, segment) => acc + segment.text, "");
|
||||
const newText = currentText.slice(0, startOffset) + pastedText + currentText.slice(startOffset);
|
||||
|
||||
if (newText.length <= 45) {
|
||||
// Preserve existing styles by matching new text with old segments
|
||||
const newLine = [];
|
||||
let currentPos = 0;
|
||||
for (const segment of motd.value[lineIndex]) {
|
||||
if (currentPos < startOffset) {
|
||||
const segmentEnd = Math.min(currentPos + segment.text.length, startOffset);
|
||||
newLine.push({ ...segment, text: newText.slice(currentPos, segmentEnd) });
|
||||
currentPos = segmentEnd;
|
||||
} else if (currentPos >= startOffset + pastedText.length) {
|
||||
newLine.push({ ...segment, text: newText.slice(currentPos) });
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Insert pasted text as a new segment
|
||||
if (currentPos < startOffset + pastedText.length) {
|
||||
newLine.push({
|
||||
text: newText.slice(currentPos, startOffset + pastedText.length),
|
||||
color: "White",
|
||||
});
|
||||
}
|
||||
motd.value[lineIndex] = newLine;
|
||||
|
||||
nextTick(() => {
|
||||
const lineElement = document.querySelectorAll("[contenteditable]")[lineIndex];
|
||||
lineElement.innerHTML = renderLine(newLine);
|
||||
const newRange = document.createRange();
|
||||
const sel = window.getSelection();
|
||||
newRange.setStart(lineElement.childNodes[0], startOffset + pastedText.length);
|
||||
newRange.collapse(true);
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(newRange);
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.minecraft-font {
|
||||
font-family: "Minecraft", monospace;
|
||||
font-size: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
[contenteditable] {
|
||||
outline: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
@font-face {
|
||||
font-family: "Monocraft";
|
||||
src: url("/Monocraft.ttf") format("truetype");
|
||||
}
|
||||
|
||||
.font-minecraft {
|
||||
font-family: "Monocraft", monospace;
|
||||
}
|
||||
|
||||
.mcbg {
|
||||
background: url("@/assets/images/servers/minecraft-background-dark.png") repeat center center;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
.fade-enter, .fade-leave-to /* .fade-leave-active in <2.1.8 */ {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:serif="http://www.serif.com/"
|
||||
version="1.1"
|
||||
viewBox="0 0 1793 199"
|
||||
>
|
||||
<g>
|
||||
<g id="Layer_1">
|
||||
<g id="green" fill="var(--color-brand)">
|
||||
<path
|
||||
d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
|
||||
/>
|
||||
<path
|
||||
d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
|
||||
/>
|
||||
<path
|
||||
d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
|
||||
/>
|
||||
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z" />
|
||||
<path
|
||||
d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"
|
||||
/>
|
||||
<path
|
||||
d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"
|
||||
/>
|
||||
<path
|
||||
d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"
|
||||
/>
|
||||
<g>
|
||||
<path
|
||||
d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="black" fill="currentColor">
|
||||
<path
|
||||
d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
31
apps/frontend/src/components/ui/servers/PanelCopyIP.vue
Normal file
31
apps/frontend/src/components/ui/servers/PanelCopyIP.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<ButtonStyled type="standard">
|
||||
<button aria-label="Copy server IP" @click="copyText">
|
||||
<CopyIcon />
|
||||
Copy IP
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CopyIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps<{
|
||||
ip: string;
|
||||
port: number;
|
||||
subdomain?: string | null;
|
||||
}>();
|
||||
|
||||
const copyText = () => {
|
||||
const text = props.subdomain ? `${props.subdomain}.modrinth.gg` : `${props.ip}:${props.port}`;
|
||||
navigator.clipboard.writeText(text);
|
||||
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: `Copied IP`,
|
||||
text: `Your server's IP has been copied to your clipboard`,
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
</script>
|
||||
@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style="font-variant-numeric: tabular-nums"
|
||||
class="pointer-events-none h-full w-full select-none"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex flex-row items-center gap-6">
|
||||
<div
|
||||
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
|
||||
</div>
|
||||
<h3 class="relative z-10 text-base font-normal text-secondary">CPU usage</h3>
|
||||
</div>
|
||||
<CPUIcon class="absolute right-10 top-10" />
|
||||
</div>
|
||||
<div
|
||||
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
|
||||
</div>
|
||||
<h3 class="relative z-10 text-base font-normal text-secondary">Memory usage</h3>
|
||||
</div>
|
||||
<DBIcon class="absolute right-10 top-10" />
|
||||
</div>
|
||||
<div
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0 Bytes</h2>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 0 Bytes</h3>
|
||||
</div>
|
||||
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative flex h-full w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div class="experimental-styles-within flex flex-row items-center">
|
||||
<div class="flex flex-row items-center gap-4">
|
||||
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="console relative h-full min-h-[488px] w-full overflow-hidden rounded-xl bg-bg text-sm"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CPUIcon, DBIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
html.light-mode .console {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
html.dark-mode .console {
|
||||
background: black;
|
||||
}
|
||||
|
||||
html.oled-mode .console {
|
||||
background: black;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal">
|
||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p>
|
||||
|
||||
<UiCheckbox
|
||||
v-model="powerDontAskAgainCheckbox"
|
||||
label="Don't ask me again"
|
||||
class="text-sm"
|
||||
:disabled="!currentPendingAction"
|
||||
/>
|
||||
<div class="flex flex-row gap-4">
|
||||
<ButtonStyled type="standard" color="brand" @click="confirmAction">
|
||||
<button>
|
||||
<CheckIcon class="h-5 w-5" />
|
||||
{{ currentPendingActionFriendly }} server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled @click="closePowerModal">
|
||||
<button>
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="detailsModal"
|
||||
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`"
|
||||
@close="closeDetailsModal"
|
||||
>
|
||||
<UiServersServerInfoLabels
|
||||
:server-data="serverData"
|
||||
:show-game-label="true"
|
||||
:show-loader-label="true"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:column="true"
|
||||
class="mb-6 flex flex-col gap-2"
|
||||
/>
|
||||
<ButtonStyled type="standard" color="brand" @click="closeDetailsModal">
|
||||
<button class="w-full">Close</button>
|
||||
</ButtonStyled>
|
||||
</NewModal>
|
||||
|
||||
<div class="flex flex-row items-center gap-2 rounded-lg">
|
||||
<ButtonStyled v-if="isInstalling" type="standard" color="brand">
|
||||
<button disabled class="flex-shrink-0">
|
||||
<UiServersPanelSpinner class="size-5" /> Installing...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-else class="contents">
|
||||
<ButtonStyled v-if="showStopButton" type="transparent">
|
||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer">
|
||||
<div class="flex gap-1">
|
||||
<StopCircleIcon class="h-5 w-5" />
|
||||
<span>{{ stopButtonText }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction">
|
||||
<div v-if="isStartingOrRestarting" class="grid place-content-center">
|
||||
<UiServersIconsLoadingIcon />
|
||||
</div>
|
||||
<div v-else class="contents">
|
||||
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" />
|
||||
</div>
|
||||
<span>
|
||||
{{ actionButtonText }}
|
||||
</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown options -->
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
:options="[
|
||||
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
|
||||
{ id: 'allServers', action: () => router.push('/servers/manage') },
|
||||
{ id: 'details', action: () => showDetailsModal() },
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #kill>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
<span>Kill server</span>
|
||||
</template>
|
||||
<template #allServers>
|
||||
<ServerIcon class="h-5 w-5" />
|
||||
<span>All servers</span>
|
||||
</template>
|
||||
<template #details>
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import {
|
||||
PlayIcon,
|
||||
UpdatedIcon,
|
||||
StopCircleIcon,
|
||||
SlashIcon,
|
||||
MoreVerticalIcon,
|
||||
XIcon,
|
||||
CheckIcon,
|
||||
ServerIcon,
|
||||
InfoIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
isOnline: boolean;
|
||||
isActioning: boolean;
|
||||
isInstalling: boolean;
|
||||
disabled: boolean;
|
||||
serverName?: string;
|
||||
serverData: object;
|
||||
uptimeSeconds: number;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const serverId = router.currentRoute.value.params.id;
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
powerDontAskAgain: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: "start" | "restart" | "stop" | "kill"): void;
|
||||
}>();
|
||||
|
||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
|
||||
const ServerState = {
|
||||
Stopped: "Stopped",
|
||||
Starting: "Starting",
|
||||
Running: "Running",
|
||||
Stopping: "Stopping",
|
||||
Restarting: "Restarting",
|
||||
} as const;
|
||||
|
||||
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
|
||||
|
||||
const currentPendingAction = ref<string | null>(null);
|
||||
const currentPendingState = ref<ServerStateType | null>(null);
|
||||
const powerDontAskAgainCheckbox = ref(false);
|
||||
|
||||
const currentState = ref<ServerStateType>(
|
||||
props.isOnline ? ServerState.Running : ServerState.Stopped,
|
||||
);
|
||||
|
||||
const isStartingDelay = ref(false);
|
||||
const showStopButton = computed(
|
||||
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
|
||||
);
|
||||
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
|
||||
const canTakeAction = computed(
|
||||
() =>
|
||||
!props.isActioning &&
|
||||
!isStartingDelay.value &&
|
||||
currentState.value !== ServerState.Starting &&
|
||||
currentState.value !== ServerState.Stopping,
|
||||
);
|
||||
|
||||
const isStartingOrRestarting = computed(
|
||||
() =>
|
||||
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
|
||||
);
|
||||
|
||||
const isStopping = computed(() => currentState.value === ServerState.Stopping);
|
||||
|
||||
const actionButtonText = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case ServerState.Starting:
|
||||
return "Starting...";
|
||||
case ServerState.Restarting:
|
||||
return "Restarting...";
|
||||
case ServerState.Running:
|
||||
return "Restart";
|
||||
case ServerState.Stopping:
|
||||
return "Stopping...";
|
||||
default:
|
||||
return "Start";
|
||||
}
|
||||
});
|
||||
|
||||
const currentPendingActionFriendly = computed(() => {
|
||||
switch (currentPendingAction.value) {
|
||||
case "start":
|
||||
return "Start";
|
||||
case "restart":
|
||||
return "Restart";
|
||||
case "stop":
|
||||
return "Stop";
|
||||
case "kill":
|
||||
return "Kill";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const stopButtonText = computed(() =>
|
||||
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop",
|
||||
);
|
||||
|
||||
const createPendingAction = () => {
|
||||
if (!canTakeAction.value) return;
|
||||
if (currentState.value === ServerState.Running) {
|
||||
currentPendingAction.value = "restart";
|
||||
currentPendingState.value = ServerState.Restarting;
|
||||
showPowerModal();
|
||||
} else {
|
||||
runAction("start", ServerState.Starting);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
createPendingAction();
|
||||
};
|
||||
|
||||
const showPowerModal = () => {
|
||||
if (userPreferences.value.powerDontAskAgain) {
|
||||
runAction(
|
||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
||||
currentPendingState.value!,
|
||||
);
|
||||
} else {
|
||||
confirmActionModal.value?.show();
|
||||
}
|
||||
};
|
||||
|
||||
const confirmAction = () => {
|
||||
if (powerDontAskAgainCheckbox.value) {
|
||||
userPreferences.value.powerDontAskAgain = true;
|
||||
}
|
||||
runAction(
|
||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
||||
currentPendingState.value!,
|
||||
);
|
||||
closePowerModal();
|
||||
};
|
||||
|
||||
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
|
||||
emit("action", action);
|
||||
currentState.value = serverState;
|
||||
|
||||
if (action === "start") {
|
||||
isStartingDelay.value = true;
|
||||
setTimeout(() => {
|
||||
isStartingDelay.value = false;
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const stopServer = () => {
|
||||
if (!canTakeAction.value) return;
|
||||
currentPendingAction.value = "stop";
|
||||
currentPendingState.value = ServerState.Stopping;
|
||||
showPowerModal();
|
||||
};
|
||||
|
||||
const killServer = () => {
|
||||
currentPendingAction.value = "kill";
|
||||
currentPendingState.value = ServerState.Stopping;
|
||||
showPowerModal();
|
||||
};
|
||||
|
||||
const closePowerModal = () => {
|
||||
confirmActionModal.value?.hide();
|
||||
currentPendingAction.value = null;
|
||||
powerDontAskAgainCheckbox.value = false;
|
||||
};
|
||||
|
||||
const closeDetailsModal = () => {
|
||||
detailsModal.value?.hide();
|
||||
};
|
||||
|
||||
const showDetailsModal = () => {
|
||||
detailsModal.value?.show();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.isOnline,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
currentState.value = ServerState.Running;
|
||||
} else {
|
||||
currentState.value = ServerState.Stopped;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
closeDetailsModal();
|
||||
},
|
||||
);
|
||||
</script>
|
||||
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-label="`Server is ${getStatusText}`"
|
||||
class="relative inline-flex select-none items-center"
|
||||
@mouseenter="isExpanded = true"
|
||||
@mouseleave="isExpanded = false"
|
||||
>
|
||||
<div
|
||||
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`"
|
||||
>
|
||||
<div
|
||||
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${
|
||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0'
|
||||
}`"
|
||||
>
|
||||
<div class="h-3 w-3 rounded-full"></div>
|
||||
<span
|
||||
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out"
|
||||
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`"
|
||||
>
|
||||
{{ getStatusText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import type { ServerState } from "~/types/servers";
|
||||
|
||||
const props = defineProps<{
|
||||
state: ServerState;
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const getStatusClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case "running":
|
||||
return { main: "bg-brand", bg: "bg-bg-green" };
|
||||
case "stopped":
|
||||
return { main: "", bg: "" };
|
||||
case "crashed":
|
||||
return { main: "bg-brand-red", bg: "bg-bg-red" };
|
||||
default:
|
||||
return { main: "", bg: "" };
|
||||
}
|
||||
});
|
||||
|
||||
const getStatusText = computed(() => {
|
||||
switch (props.state) {
|
||||
case "running":
|
||||
return "Running";
|
||||
case "stopped":
|
||||
return "";
|
||||
case "crashed":
|
||||
return "Crashed";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
22
apps/frontend/src/components/ui/servers/PanelSpinner.vue
Normal file
22
apps/frontend/src/components/ui/servers/PanelSpinner.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
625
apps/frontend/src/components/ui/servers/PanelTerminal.vue
Normal file
625
apps/frontend/src/components/ui/servers/PanelTerminal.vue
Normal file
@ -0,0 +1,625 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-terminal
|
||||
:class="[
|
||||
'terminal-font console relative z-[1] flex h-full w-full flex-col items-center justify-between overflow-hidden rounded-t-xl px-1 text-sm transition-transform duration-300',
|
||||
{ 'scale-fullscreen screen-fixed inset-0 z-50 !rounded-none': isFullScreen },
|
||||
]"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
v-if="cosmetics.advancedRendering"
|
||||
class="progressive-gradient pointer-events-none absolute -bottom-6 left-0 z-[2] h-[10rem] w-full overflow-hidden rounded-xl"
|
||||
:style="`--transparency: ${Math.max(0, lerp(100, 0, bottomThreshold * 8))}%`"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
v-for="i in progressiveBlurIterations"
|
||||
:key="i"
|
||||
aria-hidden="true"
|
||||
class="absolute left-0 top-0 h-full w-full"
|
||||
:style="getBlurStyle(i)"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-[2] h-[196px] w-full"
|
||||
:style="
|
||||
bottomThreshold > 0
|
||||
? { background: 'linear-gradient(transparent 30%, var(--console-bg) 70%)' }
|
||||
: {}
|
||||
"
|
||||
></div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="pointer-events-none absolute left-0 top-0 z-[60] h-full w-full"
|
||||
:style="{
|
||||
visibility: isFullScreen ? 'hidden' : 'visible',
|
||||
}"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute -bottom-2 -right-2 h-7 w-7"
|
||||
:style="{
|
||||
background: `radial-gradient(circle at 0% 0%, transparent 50%, var(--color-raised-bg) 52%)`,
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="absolute -bottom-2 -left-2 h-7 w-7"
|
||||
:style="{
|
||||
background: `radial-gradient(circle at 100% 0%, transparent 50%, var(--color-raised-bg) 52%)`,
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
<div data-pyro-terminal-scroll-root class="relative h-full w-full">
|
||||
<div
|
||||
ref="scrollbarTrack"
|
||||
data-pyro-terminal-scrollbar-track
|
||||
class="absolute -right-1 bottom-16 top-4 z-[4] w-4"
|
||||
@mousedown="handleTrackClick"
|
||||
>
|
||||
<div
|
||||
data-pyro-terminal-scrollbar
|
||||
class="flex h-full justify-center rounded-full transition-all"
|
||||
:style="{ opacity: bottomThreshold > 0 ? '1' : '0.5' }"
|
||||
>
|
||||
<div
|
||||
ref="scrollbarThumb"
|
||||
data-pyro-terminal-scrollbar-thumb
|
||||
class="absolute w-1.5 cursor-default rounded-full bg-button-bg"
|
||||
:style="{
|
||||
height: `${getThumbHeight()}px`,
|
||||
transform: `translateY(${getThumbPosition()}px)`,
|
||||
}"
|
||||
@mousedown="startDragging"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref="scrollContainer"
|
||||
data-pyro-terminal-root
|
||||
class="scrollbar-none absolute left-0 top-0 h-full w-full select-text overflow-x-auto overflow-y-auto py-6 pb-[72px]"
|
||||
@scroll="handleScrollEvent"
|
||||
>
|
||||
<div data-pyro-terminal-virtual-height-watcher :style="{ height: `${totalHeight}px` }">
|
||||
<ul
|
||||
class="m-0 list-none p-0"
|
||||
data-pyro-terminal-virtual-list
|
||||
:style="{ transform: `translateY(${offsetY}px)` }"
|
||||
aria-live="polite"
|
||||
role="listbox"
|
||||
>
|
||||
<template v-for="(item, index) in visibleItems" :key="index">
|
||||
<li>
|
||||
<UiServersLogParser :log="item" :index="visibleStartIndex + index" />
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="absolute bottom-4 z-[3] w-full"
|
||||
:style="{
|
||||
filter: `drop-shadow(0 8px 12px rgba(0, 0, 0, ${lerp(0.1, 0.5, bottomThreshold)}))`,
|
||||
}"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<button
|
||||
data-pyro-fullscreen
|
||||
:label="isFullScreen ? 'Exit full screen' : 'Enter full screen'"
|
||||
class="experimental-styles-within absolute right-4 top-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<UiServersPanelTerminalMinimize v-if="isFullScreen" />
|
||||
<UiServersPanelTerminalFullscreen v-else />
|
||||
</button>
|
||||
|
||||
<Transition name="scroll-to-bottom">
|
||||
<button
|
||||
v-if="bottomThreshold > 0"
|
||||
data-pyro-scrolltobottom
|
||||
label="Scroll to bottom"
|
||||
class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-lg border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<RightArrowIcon class="rotate-90" />
|
||||
<span class="sr-only">Scroll to bottom</span>
|
||||
</button>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
|
||||
const { $cosmetics } = useNuxtApp();
|
||||
const cosmetics = $cosmetics;
|
||||
|
||||
const props = defineProps<{
|
||||
consoleOutput: string[];
|
||||
fullScreen: boolean;
|
||||
}>();
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const itemRefs = ref<HTMLElement[]>([]);
|
||||
const itemHeights = ref<number[]>([]);
|
||||
const averageItemHeight = ref(36);
|
||||
const bottomThreshold = ref(0);
|
||||
const bufferSize = 5;
|
||||
|
||||
const progressiveBlurIterations = ref(8);
|
||||
|
||||
const scrollTop = ref(0);
|
||||
const clientHeight = ref(0);
|
||||
const isFullScreen = ref(props.fullScreen);
|
||||
|
||||
const initial = ref(false);
|
||||
const userHasScrolled = ref(false);
|
||||
const isScrolledToBottom = ref(true);
|
||||
|
||||
const handleScrollEvent = () => {
|
||||
handleListScroll();
|
||||
};
|
||||
|
||||
const totalHeight = computed(
|
||||
() =>
|
||||
itemHeights.value.reduce((sum, height) => sum + height, 0) ||
|
||||
props.consoleOutput.length * averageItemHeight.value,
|
||||
);
|
||||
|
||||
watch(totalHeight, () => {
|
||||
if (!initial.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
initial.value = true;
|
||||
});
|
||||
|
||||
const lerp = (start: number, end: number, t: number) => start * (1 - t) + end * t;
|
||||
|
||||
const getBlurStyle = (i: number) => {
|
||||
const properBlurIteration = i + 1;
|
||||
const blur = lerp(0, 2 ** (properBlurIteration - 3), bottomThreshold.value);
|
||||
const singular = 100 / progressiveBlurIterations.value;
|
||||
let mask = "linear-gradient(";
|
||||
|
||||
switch (i) {
|
||||
case 0:
|
||||
mask += `rgba(0, 0, 0, 0) 0%, rgb(0, 0, 0) ${singular}%`;
|
||||
break;
|
||||
case 1:
|
||||
mask += `rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) ${singular}%, rgb(0, 0, 0) ${singular * 2}%`;
|
||||
break;
|
||||
case 2:
|
||||
mask += `rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 0) ${singular}%, rgba(0, 0, 0, 0) ${singular * 2}%, rgb(0, 0, 0) ${singular * 3}%`;
|
||||
break;
|
||||
default:
|
||||
mask += `rgba(0, 0, 0, 0) ${singular * (i - 3)}%, rgb(0, 0, 0) ${singular * (i + 1 - 3)}%, rgb(0, 0, 0) ${singular * (i + 2 - 3)}%, rgba(0, 0, 0, 0) ${singular * (i + 3 - 3)}%`;
|
||||
break;
|
||||
}
|
||||
|
||||
mask += `)`;
|
||||
|
||||
return {
|
||||
backdropFilter: `blur(${blur}px)`,
|
||||
mask,
|
||||
position: "absolute" as any,
|
||||
zIndex: progressiveBlurIterations.value - i,
|
||||
};
|
||||
};
|
||||
|
||||
const getItemOffset = (index: number) => {
|
||||
return itemHeights.value.slice(0, index).reduce((sum, height) => sum + height, 0);
|
||||
};
|
||||
|
||||
const visibleStartIndex = computed(() => {
|
||||
let index = 0;
|
||||
let offset = 0;
|
||||
while (
|
||||
index < props.consoleOutput.length &&
|
||||
offset < scrollTop.value - bufferSize * averageItemHeight.value
|
||||
) {
|
||||
offset += itemHeights.value[index] || averageItemHeight.value;
|
||||
index++;
|
||||
}
|
||||
return Math.max(0, index - 1);
|
||||
});
|
||||
|
||||
const visibleEndIndex = computed(() => {
|
||||
let index = visibleStartIndex.value;
|
||||
let offset = getItemOffset(index);
|
||||
while (
|
||||
index < props.consoleOutput.length &&
|
||||
offset < scrollTop.value + clientHeight.value + bufferSize * averageItemHeight.value
|
||||
) {
|
||||
offset += itemHeights.value[index] || averageItemHeight.value;
|
||||
index++;
|
||||
}
|
||||
return Math.min(props.consoleOutput.length - 1, index);
|
||||
});
|
||||
|
||||
const visibleItems = computed(() =>
|
||||
props.consoleOutput.slice(visibleStartIndex.value, visibleEndIndex.value + 1),
|
||||
);
|
||||
|
||||
const offsetY = computed(() => getItemOffset(visibleStartIndex.value));
|
||||
|
||||
const handleListScroll = () => {
|
||||
if (scrollContainer.value) {
|
||||
scrollTop.value = scrollContainer.value.scrollTop;
|
||||
clientHeight.value = scrollContainer.value.clientHeight;
|
||||
|
||||
const scrollHeight = scrollContainer.value.scrollHeight;
|
||||
isScrolledToBottom.value = scrollTop.value + clientHeight.value >= scrollHeight - 32; // threshold
|
||||
|
||||
if (!isScrolledToBottom.value) {
|
||||
userHasScrolled.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
const maxBottom = 256;
|
||||
bottomThreshold.value = Math.min(
|
||||
1,
|
||||
((scrollContainer.value?.scrollHeight || 1) - scrollTop.value - clientHeight.value) / maxBottom,
|
||||
);
|
||||
};
|
||||
|
||||
const updateItemHeights = () => {
|
||||
nextTick(() => {
|
||||
itemRefs.value.forEach((el, index) => {
|
||||
if (el) {
|
||||
const actualIndex = visibleStartIndex.value + index;
|
||||
itemHeights.value[actualIndex] = el.offsetHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const measuredHeights = itemHeights.value.filter((h) => h > 0);
|
||||
if (measuredHeights.length > 0) {
|
||||
averageItemHeight.value =
|
||||
measuredHeights.reduce((sum, height) => sum + height, 0) / measuredHeights.length;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const updateClientHeight = () => {
|
||||
if (scrollContainer.value) {
|
||||
clientHeight.value = scrollContainer.value.clientHeight;
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainer.value) {
|
||||
scrollContainer.value.scrollTop = scrollContainer.value.scrollHeight + 99999999;
|
||||
userHasScrolled.value = false;
|
||||
isScrolledToBottom.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const debouncedScrollToBottom = () => {
|
||||
requestAnimationFrame(() => {
|
||||
setTimeout(scrollToBottom, 0);
|
||||
});
|
||||
};
|
||||
|
||||
const scrollbarTrack = ref<HTMLElement | null>(null);
|
||||
const scrollbarThumb = ref<HTMLElement | null>(null);
|
||||
const isDragging = ref(false);
|
||||
const startY = ref(0);
|
||||
const startScrollTop = ref(0);
|
||||
|
||||
const getThumbHeight = () => {
|
||||
if (!scrollContainer.value || !scrollbarTrack.value) return 30;
|
||||
|
||||
const contentHeight = scrollContainer.value.scrollHeight;
|
||||
const viewportHeight = scrollContainer.value.clientHeight;
|
||||
const trackHeight = scrollbarTrack.value.clientHeight;
|
||||
|
||||
const heightRatio = viewportHeight / contentHeight;
|
||||
|
||||
const minThumbHeight = Math.min(40, trackHeight / 2);
|
||||
|
||||
const proposedHeight = Math.max(heightRatio * trackHeight, minThumbHeight);
|
||||
|
||||
return Math.min(proposedHeight, trackHeight);
|
||||
};
|
||||
|
||||
const getThumbPosition = () => {
|
||||
if (!scrollContainer.value || !scrollbarTrack.value) return 0;
|
||||
|
||||
const contentHeight = scrollContainer.value.scrollHeight;
|
||||
const viewportHeight = scrollContainer.value.clientHeight;
|
||||
const trackHeight = scrollbarTrack.value.clientHeight;
|
||||
const scrollProgress = scrollTop.value / (contentHeight - viewportHeight);
|
||||
|
||||
const thumbHeight = getThumbHeight();
|
||||
const availableTrackSpace = trackHeight - thumbHeight;
|
||||
|
||||
return Math.max(0, Math.min(scrollProgress * availableTrackSpace, availableTrackSpace));
|
||||
};
|
||||
|
||||
const startDragging = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (!scrollContainer.value || !scrollbarTrack.value) return;
|
||||
|
||||
isDragging.value = true;
|
||||
startY.value = event.clientY;
|
||||
startScrollTop.value = scrollContainer.value.scrollTop;
|
||||
|
||||
window.addEventListener("mousemove", handleDragging);
|
||||
window.addEventListener("mouseup", stopDragging);
|
||||
|
||||
document.body.style.userSelect = "none";
|
||||
document.body.style.pointerEvents = "none";
|
||||
};
|
||||
|
||||
const handleDragging = (event: MouseEvent) => {
|
||||
if (!isDragging.value || !scrollContainer.value || !scrollbarTrack.value) return;
|
||||
|
||||
const trackRect = scrollbarTrack.value.getBoundingClientRect();
|
||||
const deltaY = event.clientY - startY.value;
|
||||
|
||||
const trackHeight = trackRect.height;
|
||||
const contentHeight = scrollContainer.value.scrollHeight;
|
||||
const viewportHeight = scrollContainer.value.clientHeight;
|
||||
const maxScroll = contentHeight - viewportHeight;
|
||||
|
||||
const moveRatio = deltaY / trackHeight;
|
||||
const scrollDelta = moveRatio * maxScroll;
|
||||
|
||||
const newScrollTop = Math.max(0, Math.min(startScrollTop.value + scrollDelta, maxScroll));
|
||||
scrollContainer.value.scrollTop = newScrollTop;
|
||||
};
|
||||
|
||||
const stopDragging = () => {
|
||||
isDragging.value = false;
|
||||
|
||||
window.removeEventListener("mousemove", handleDragging);
|
||||
window.removeEventListener("mouseup", stopDragging);
|
||||
|
||||
document.body.style.userSelect = "";
|
||||
document.body.style.pointerEvents = "";
|
||||
};
|
||||
|
||||
const handleTrackClick = (event: MouseEvent) => {
|
||||
if (!scrollContainer.value || !scrollbarTrack.value || event.target === scrollbarThumb.value)
|
||||
return;
|
||||
|
||||
const trackRect = scrollbarTrack.value.getBoundingClientRect();
|
||||
const thumbHeight = getThumbHeight();
|
||||
|
||||
const clickOffset = event.clientY - trackRect.top;
|
||||
|
||||
const currentThumbPosition = getThumbPosition();
|
||||
const thumbCenterPosition = currentThumbPosition + thumbHeight / 2;
|
||||
const scrollAmount = clientHeight.value * (clickOffset < thumbCenterPosition ? -1 : 1);
|
||||
|
||||
const newScrollTop = Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
scrollContainer.value.scrollTop + scrollAmount,
|
||||
scrollContainer.value.scrollHeight - scrollContainer.value.clientHeight,
|
||||
),
|
||||
);
|
||||
|
||||
scrollContainer.value.scrollTop = newScrollTop;
|
||||
};
|
||||
|
||||
const enterFullScreen = () => {
|
||||
isFullScreen.value = true;
|
||||
document.body.style.overflow = "hidden";
|
||||
nextTick(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
});
|
||||
};
|
||||
|
||||
const exitFullScreen = () => {
|
||||
isFullScreen.value = false;
|
||||
document.body.style.overflow = "";
|
||||
nextTick(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
});
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (isFullScreen.value) {
|
||||
exitFullScreen();
|
||||
} else {
|
||||
enterFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && isFullScreen.value) {
|
||||
exitFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
nextTick(() => {
|
||||
updateItemHeights();
|
||||
setTimeout(scrollToBottom, 200);
|
||||
});
|
||||
window.addEventListener("resize", updateClientHeight);
|
||||
window.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", updateClientHeight);
|
||||
window.removeEventListener("keydown", handleKeydown);
|
||||
stopDragging();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.consoleOutput,
|
||||
() => {
|
||||
const newItemsCount = props.consoleOutput.length - itemHeights.value.length;
|
||||
if (newItemsCount > 0) {
|
||||
itemHeights.value.push(...Array(newItemsCount).fill(averageItemHeight.value));
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
updateItemHeights();
|
||||
if (!userHasScrolled.value || isScrolledToBottom.value) {
|
||||
debouncedScrollToBottom();
|
||||
}
|
||||
});
|
||||
},
|
||||
{ deep: true, immediate: true },
|
||||
);
|
||||
|
||||
watch([visibleStartIndex, visibleEndIndex], updateItemHeights);
|
||||
|
||||
watch(
|
||||
() => props.fullScreen,
|
||||
(newValue) => {
|
||||
isFullScreen.value = newValue;
|
||||
nextTick(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
watch(isFullScreen, () => {
|
||||
nextTick(() => {
|
||||
updateClientHeight();
|
||||
updateItemHeights();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:root {
|
||||
--console-bg: var(--color-bg);
|
||||
}
|
||||
|
||||
.terminal-font {
|
||||
font-family: var(--mono-font);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
html.light-mode .console {
|
||||
--console-bg: var(--color-bg);
|
||||
}
|
||||
|
||||
html.dark-mode .console {
|
||||
--console-bg: black;
|
||||
}
|
||||
|
||||
html.oled-mode .console {
|
||||
--console-bg: black;
|
||||
}
|
||||
|
||||
.console {
|
||||
background: var(--console-bg);
|
||||
}
|
||||
|
||||
.scrollbar-none {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-none::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-root]::-webkit-scrollbar,
|
||||
[data-pyro-terminal-root]::-webkit-scrollbar-thumb,
|
||||
[data-pyro-terminal-root]::-webkit-scrollbar-track-piece,
|
||||
[data-pyro-terminal-root]::-webkit-scrollbar-corner {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.screen-fixed {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 50;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
@keyframes scaleUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.scale-fullscreen {
|
||||
animation: scaleUp 190ms forwards;
|
||||
}
|
||||
|
||||
.progressive-gradient {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, var(--color-bg), transparent var(--transparency)) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
html.dark-mode .progressive-gradient {
|
||||
background: linear-gradient(
|
||||
to top,
|
||||
color-mix(in srgb, black, transparent var(--transparency)) 0%,
|
||||
rgba(0, 0, 0, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.scroll-to-bottom-enter-active,
|
||||
.scroll-to-bottom-leave-active {
|
||||
transition:
|
||||
opacity 300ms ease,
|
||||
transform 300ms ease;
|
||||
}
|
||||
|
||||
.scroll-to-bottom-enter-from,
|
||||
.scroll-to-bottom-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.4) translateY(2rem);
|
||||
}
|
||||
|
||||
[data-pyro-terminal-selected="true"] {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-selected="true"].first-selected {
|
||||
border-top-left-radius: 0.5rem;
|
||||
border-top-right-radius: 0.5rem;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-selected="true"].last-selected {
|
||||
border-bottom-left-radius: 0.5rem;
|
||||
border-bottom-right-radius: 0.5rem;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-root] {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
[data-pyro-terminal-root] * {
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.selection-in-progress {
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-5"
|
||||
>
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-5"
|
||||
>
|
||||
<path d="m15 15 6 6m-6-6v4.8m0-4.8h4.8" />
|
||||
<path d="M9 19.8V15m0 0H4.2M9 15l-6 6" />
|
||||
<path d="M15 4.2V9m0 0h4.8M15 9l6-6" />
|
||||
<path d="M9 4.2V9m0 0H4.2M9 9 3 3" />
|
||||
</svg>
|
||||
</template>
|
||||
14
apps/frontend/src/components/ui/servers/PoweredByPyro.vue
Normal file
14
apps/frontend/src/components/ui/servers/PoweredByPyro.vue
Normal file
@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<a
|
||||
href="https://pyro.host"
|
||||
target="_blank"
|
||||
class="mx-auto mt-8 flex select-none flex-row items-center gap-2 hover:underline"
|
||||
>
|
||||
<PyroIcon class="size-4 text-secondary" />
|
||||
<span class="text-sm text-secondary">Powered by Pyro</span>
|
||||
</a>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PyroIcon } from "@modrinth/assets";
|
||||
</script>
|
||||
167
apps/frontend/src/components/ui/servers/ProjectSelect.vue
Normal file
167
apps/frontend/src/components/ui/servers/ProjectSelect.vue
Normal file
@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div class="flex h-[400px] w-full max-w-xl flex-col overflow-hidden">
|
||||
<div class="iconified-input mb-4 w-full">
|
||||
<label class="hidden" for="search">Search</label>
|
||||
<SearchIcon aria-hidden="true" />
|
||||
<input
|
||||
id="search"
|
||||
v-model="queryFilter"
|
||||
name="search"
|
||||
type="search"
|
||||
:placeholder="`Search ${props.type}s...`"
|
||||
autocomplete="off"
|
||||
@keyup.enter="resetList"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div
|
||||
v-if="mods && mods.hits.length > 0"
|
||||
ref="scrollContainer"
|
||||
class="flex h-full w-full flex-col gap-2 overflow-y-scroll"
|
||||
>
|
||||
<div v-for="mod in mods.hits" :key="mod.title" class="rounded-lg px-2 py-2 hover:bg-bg">
|
||||
<div class="flex cursor-pointer gap-2" @click="toggleMod(mod.project_id)">
|
||||
<UiAvatar :src="mod.icon_url" class="!h-12 !min-h-12 !w-12 !min-w-12" />
|
||||
<div class="flex flex-col gap-1">
|
||||
<h1 class="m-0 text-2xl font-bold leading-none text-contrast">
|
||||
{{ mod.title }}
|
||||
</h1>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ mod.description.substring(0, 100) }}
|
||||
{{ mod.description.length > 100 ? "..." : "" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="expandedMods[mod.project_id]" class="mt-2 flex items-center gap-2">
|
||||
<DropdownSelect
|
||||
id="version-select"
|
||||
v-model="selectedVersions[mod.project_id]"
|
||||
name="version-select"
|
||||
:options="expandedMods[mod.project_id].versions"
|
||||
placeholder="Select version..."
|
||||
/>
|
||||
<Button icon-only @click="emits('select', mod, selectedVersions[mod.project_id])">
|
||||
<ChevronRightIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, SearchIcon } from "@modrinth/assets";
|
||||
import { Button, DropdownSelect } from "@modrinth/ui";
|
||||
import { useInfiniteScroll } from "@vueuse/core";
|
||||
|
||||
const emits = defineEmits(["select"]);
|
||||
|
||||
const props = defineProps<{
|
||||
type: "mod" | "modpack" | "plugin" | "datapack";
|
||||
isserver?: boolean;
|
||||
}>();
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id as string;
|
||||
const server = serverId ? await usePyroServer(serverId, ["general"]) : null;
|
||||
|
||||
const data = computed(() => (serverId ? server?.general : null));
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
const pages = ref(1);
|
||||
const page = ref(0);
|
||||
|
||||
const queryFilter = ref("");
|
||||
const facets = ref<any>([]);
|
||||
|
||||
if (props.isserver === false && props.type !== "modpack") {
|
||||
facets.value.push(`["categories:${data.value?.loader?.toLocaleLowerCase()}"]`);
|
||||
facets.value.push(`["versions:${data.value?.mc_version}"]`);
|
||||
}
|
||||
|
||||
facets.value.push(`["project_type:${props.type}"]`);
|
||||
|
||||
const buildFacetString = (facets: string[]) => {
|
||||
return "[" + facets.map((facet) => `${facet}`).join(",") + "]";
|
||||
};
|
||||
|
||||
const mods = ref<any>({ hits: [] });
|
||||
const modsStatus = ref("idle");
|
||||
|
||||
const loadMods = async () => {
|
||||
modsStatus.value = "loading";
|
||||
|
||||
const newMods = (await useBaseFetch(
|
||||
`search?query=${queryFilter.value}&facets=${buildFacetString(facets.value)}&index=relevance&limit=25&offset=${page.value * 25}`,
|
||||
{},
|
||||
false,
|
||||
)) as any;
|
||||
pages.value = newMods.total_hits;
|
||||
mods.value.hits.push(...newMods.hits);
|
||||
modsStatus.value = "success";
|
||||
};
|
||||
|
||||
const versions = reactive<{ [key: string]: any[] }>({});
|
||||
|
||||
const getVersions = async (projectId: string) => {
|
||||
if (!versions[projectId]) {
|
||||
const allVersions = (await useBaseFetch(`project/${projectId}/version`, {}, false)) as any;
|
||||
|
||||
if (props.isserver === false && props.type !== "modpack") {
|
||||
versions[projectId] = allVersions
|
||||
.filter((x: any) => x.loaders.includes(data.value?.loader?.toLocaleLowerCase()))
|
||||
.filter((x: any) => x.game_versions.includes(data.value?.mc_version))
|
||||
.map((x: any) => x.version_number);
|
||||
} else {
|
||||
versions[projectId] = allVersions.map((x: any) => x.version_number);
|
||||
}
|
||||
}
|
||||
return versions[projectId];
|
||||
};
|
||||
|
||||
const selectedVersions = reactive<{ [key: string]: string }>({});
|
||||
|
||||
const expandedMods = reactive<{ [key: string]: { expanded: boolean; versions: any[] } }>({});
|
||||
|
||||
const toggleMod = async (modId: string) => {
|
||||
if (!expandedMods[modId]) {
|
||||
expandedMods[modId] = { expanded: false, versions: [] };
|
||||
}
|
||||
expandedMods[modId].expanded = !expandedMods[modId].expanded;
|
||||
if (expandedMods[modId].expanded && expandedMods[modId].versions.length === 0) {
|
||||
expandedMods[modId].versions = await getVersions(modId);
|
||||
// Select the first version by default
|
||||
if (expandedMods[modId].versions.length > 0) {
|
||||
selectedVersions[modId] = expandedMods[modId].versions[0];
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
page.value++;
|
||||
await loadMods();
|
||||
};
|
||||
|
||||
const { reset } = useInfiniteScroll(scrollContainer, async () => {
|
||||
if (page.value <= pages.value) {
|
||||
await loadMore();
|
||||
console.log("loading more");
|
||||
console.log(page.value);
|
||||
console.log(pages.value);
|
||||
}
|
||||
});
|
||||
|
||||
const resetList = () => {
|
||||
mods.value.hits = [];
|
||||
Object.keys(expandedMods).forEach((key) => delete expandedMods[key]);
|
||||
Object.keys(selectedVersions).forEach((key) => delete selectedVersions[key]);
|
||||
page.value = 0;
|
||||
loadMods();
|
||||
reset();
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMods();
|
||||
});
|
||||
</script>
|
||||
94
apps/frontend/src/components/ui/servers/PyroLoading.vue
Normal file
94
apps/frontend/src/components/ui/servers/PyroLoading.vue
Normal file
@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<div class="flex h-[70vh] w-full flex-col items-center justify-center">
|
||||
<PyroIcon class="pyro-logo-animation size-32 opacity-10" />
|
||||
<p
|
||||
class="text-sm transition"
|
||||
:class="{ 'opacity-0': !showLoading, 'animate-pulse opacity-100': showLoading }"
|
||||
>
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { PyroIcon } from "@modrinth/assets";
|
||||
|
||||
const showLoading = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
showLoading.value = true;
|
||||
}, 5000);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes zoom-in {
|
||||
0% {
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.pyro-logo-animation {
|
||||
animation: zoom-in 0.8s
|
||||
linear(
|
||||
0 0%,
|
||||
0.01 0.8%,
|
||||
0.04 1.6%,
|
||||
0.161 3.3%,
|
||||
0.816 9.4%,
|
||||
1.046 11.9%,
|
||||
1.189 14.4%,
|
||||
1.231 15.7%,
|
||||
1.254 17%,
|
||||
1.259 17.8%,
|
||||
1.257 18.6%,
|
||||
1.236 20.45%,
|
||||
1.194 22.3%,
|
||||
1.057 27%,
|
||||
0.999 29.4%,
|
||||
0.955 32.1%,
|
||||
0.942 33.5%,
|
||||
0.935 34.9%,
|
||||
0.933 36.65%,
|
||||
0.939 38.4%,
|
||||
1 47.3%,
|
||||
1.011 49.95%,
|
||||
1.017 52.6%,
|
||||
1.016 56.4%,
|
||||
1 65.2%,
|
||||
0.996 70.2%,
|
||||
1.001 87.2%,
|
||||
1 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes fade-bg-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.bg-loading-animation {
|
||||
animation: fade-bg-in 0.12s linear forwards;
|
||||
}
|
||||
</style>
|
||||
60
apps/frontend/src/components/ui/servers/PyroModal.vue
Normal file
60
apps/frontend/src/components/ui/servers/PyroModal.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex h-full flex-col gap-4 py-6"
|
||||
:class="
|
||||
'flex h-full flex-col gap-4 py-6' +
|
||||
(danger
|
||||
? ' rounded-2xl border-2 border-solid border-[#cb2245] bg-[#fff5f6] dark:border-[#FF496E] dark:bg-[#270B11]'
|
||||
: '')
|
||||
"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-4 px-6">
|
||||
<div class="flex w-full items-center gap-4">
|
||||
<UiServersServerIcon v-if="data" :image="data.image" class="h-12 w-12 rounded-lg" />
|
||||
<div class="text-2xl font-extrabold text-contrast">{{ props.header }}</div>
|
||||
</div>
|
||||
<button
|
||||
:class="
|
||||
'h-8 w-8 rounded-full bg-button-bg p-2 text-contrast hover:bg-button-bgActive' +
|
||||
(danger ? 'hover:bg-[#ffffff20] [&&]:bg-[#ffffff10]' : '')
|
||||
"
|
||||
@click="$emit('modal')"
|
||||
>
|
||||
<XIcon class="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="border-0 border-b border-solid"
|
||||
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-button-bg'"
|
||||
></div>
|
||||
<div class="mt-2 h-full w-full overflow-auto px-6">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from "@modrinth/assets";
|
||||
|
||||
const emit = defineEmits(["modal"]);
|
||||
|
||||
const props = defineProps<{
|
||||
header?: string;
|
||||
data?: any;
|
||||
danger?: boolean;
|
||||
}>();
|
||||
|
||||
const onEscKeyRelease = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
emit("modal");
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
document.body.addEventListener("keyup", onEscKeyRelease);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener("keyup", onEscKeyRelease);
|
||||
});
|
||||
</script>
|
||||
75
apps/frontend/src/components/ui/servers/SaveBanner.vue
Normal file
75
apps/frontend/src/components/ui/servers/SaveBanner.vue
Normal file
@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<Transition name="save-banner">
|
||||
<div
|
||||
v-if="props.isVisible"
|
||||
data-pyro-save-banner
|
||||
class="fixed bottom-16 left-0 right-0 z-[6] mx-auto h-fit w-full max-w-4xl transition-all duration-300 sm:bottom-8"
|
||||
>
|
||||
<div class="mx-2 rounded-2xl border-2 border-solid border-button-border bg-bg-raised p-4">
|
||||
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
|
||||
<span class="font-bold text-contrast">Careful, you have unsaved changes!</span>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="transparent" color="standard">
|
||||
<button :disabled="props.isUpdating" @click="props.reset">Reset</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="standard" :color="props.restart ? 'standard' : 'brand'">
|
||||
<button :disabled="props.isUpdating" @click="props.save">
|
||||
{{ props.isUpdating ? "Saving..." : "Save" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="props.restart" type="standard" color="brand">
|
||||
<button :disabled="props.isUpdating" @click="saveAndRestart">
|
||||
{{ props.isUpdating ? "Saving..." : "Save & restart" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
isUpdating: boolean;
|
||||
restart?: boolean;
|
||||
save: () => void;
|
||||
reset: () => void;
|
||||
isVisible: boolean;
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const saveAndRestart = async () => {
|
||||
props.save();
|
||||
await props.server.general?.power("Restart");
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-banner-enter-active {
|
||||
transition:
|
||||
opacity 300ms,
|
||||
transform 300ms;
|
||||
}
|
||||
|
||||
.save-banner-leave-active {
|
||||
transition:
|
||||
opacity 200ms,
|
||||
transform 200ms;
|
||||
}
|
||||
|
||||
.save-banner-enter-from,
|
||||
.save-banner-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(100%) scale(0.98);
|
||||
}
|
||||
|
||||
.save-banner-enter-to,
|
||||
.save-banner-leave-from {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
33
apps/frontend/src/components/ui/servers/ServerGameLabel.vue
Normal file
33
apps/frontend/src/components/ui/servers/ServerGameLabel.vue
Normal file
@ -0,0 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="game"
|
||||
v-tooltip="'Change server version'"
|
||||
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
|
||||
>
|
||||
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
|
||||
<NuxtLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
||||
class="min-w-0 truncate text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
>
|
||||
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
|
||||
</NuxtLink>
|
||||
<div v-else class="min-w-0 truncate text-sm font-semibold">
|
||||
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GameIcon } from "@modrinth/assets";
|
||||
|
||||
defineProps<{
|
||||
game: string;
|
||||
mcVersion: string;
|
||||
isLink?: boolean;
|
||||
}>();
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id as string;
|
||||
</script>
|
||||
26
apps/frontend/src/components/ui/servers/ServerIcon.vue
Normal file
26
apps/frontend/src/components/ui/servers/ServerIcon.vue
Normal file
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<div
|
||||
class="experimental-styles-within flex size-24 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<client-only>
|
||||
<img
|
||||
v-if="image"
|
||||
class="h-full w-full select-none object-fill"
|
||||
alt="Server Icon"
|
||||
:src="image"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
class="h-full w-full select-none object-fill"
|
||||
alt="Server Icon"
|
||||
src="~/assets/images/servers/minecraft_server_icon.png"
|
||||
/>
|
||||
</client-only>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
image: string | undefined;
|
||||
}>();
|
||||
</script>
|
||||
41
apps/frontend/src/components/ui/servers/ServerInfoLabels.vue
Normal file
41
apps/frontend/src/components/ui/servers/ServerInfoLabels.vue
Normal file
@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div>
|
||||
<UiServersServerGameLabel
|
||||
v-if="showGameLabel"
|
||||
:game="serverData.game!"
|
||||
:mc-version="serverData.mc_version ?? ''"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<UiServersServerLoaderLabel
|
||||
v-if="showLoaderLabel"
|
||||
:loader="serverData.loader!"
|
||||
:loader-version="serverData.loader_version ?? ''"
|
||||
:no-separator="column"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<UiServersServerSubdomainLabel
|
||||
v-if="serverData.net.domain"
|
||||
:subdomain="serverData.net.domain"
|
||||
:no-separator="column"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<UiServersServerUptimeLabel
|
||||
v-if="uptimeSeconds"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:no-separator="column"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ServerInfoLabelsProps {
|
||||
serverData: Record<string, any>;
|
||||
showGameLabel: boolean;
|
||||
showLoaderLabel: boolean;
|
||||
uptimeSeconds?: number;
|
||||
column?: boolean;
|
||||
linked?: boolean;
|
||||
}
|
||||
|
||||
defineProps<ServerInfoLabelsProps>();
|
||||
</script>
|
||||
101
apps/frontend/src/components/ui/servers/ServerListing.vue
Normal file
101
apps/frontend/src/components/ui/servers/ServerListing.vue
Normal file
@ -0,0 +1,101 @@
|
||||
<template>
|
||||
<NuxtLink class="contents" :to="`/servers/manage/${props.server_id}`">
|
||||
<div
|
||||
class="flex cursor-pointer flex-row items-center overflow-x-hidden rounded-3xl bg-bg-raised p-4 transition-transform duration-100 active:scale-95"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<UiServersServerIcon :image="image" />
|
||||
<div class="ml-8 flex flex-col gap-2.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
>
|
||||
<UiAvatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
alt="Server Icon"
|
||||
/>
|
||||
Using {{ projectData?.title || "Unknown" }}
|
||||
</div>
|
||||
<div v-else class="min-h-[20px]"></div>
|
||||
|
||||
<UiServersServerInfoLabels
|
||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:show-subdomain-label="showSubdomainLabel"
|
||||
:linked="false"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon } from "@modrinth/assets";
|
||||
import type { Project, Server } from "~/types/servers";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
const showGameLabel = computed(() => !!props.game);
|
||||
const showLoaderLabel = computed(() => !!props.loader);
|
||||
const showSubdomainLabel = computed(() => !!props.net?.domain);
|
||||
|
||||
let projectData: Ref<Project | null>;
|
||||
if (props.upstream) {
|
||||
const { data } = await useAsyncData<Project>(
|
||||
`server-project-${props.server_id}`,
|
||||
async (): Promise<Project> => {
|
||||
const result = await useBaseFetch(`project/${props.upstream?.project_id}`);
|
||||
return result as Project;
|
||||
},
|
||||
);
|
||||
projectData = data;
|
||||
} else {
|
||||
projectData = ref(null);
|
||||
}
|
||||
|
||||
const image = ref<string | undefined>();
|
||||
|
||||
onMounted(async () => {
|
||||
const auth = (await usePyroFetch(`servers/${props.server_id}/fs`)) as any;
|
||||
try {
|
||||
const fileData = await usePyroFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
});
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(fileData);
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => {
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
image.value = dataURL;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
||||
image.value = undefined;
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||
</script>
|
||||
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-8 overflow-x-hidden rounded-3xl bg-bg-raised p-4">
|
||||
<div class="relative grid place-content-center">
|
||||
<img
|
||||
no-shadow
|
||||
size="lg"
|
||||
alt="Server Icon"
|
||||
class="size-[96px] rounded-xl bg-bg-raised opacity-50"
|
||||
src="~/assets/images/servers/minecraft_server_icon.png"
|
||||
/>
|
||||
<div class="absolute inset-0 grid place-content-center">
|
||||
<UiServersIconsLoadingIcon class="size-8 animate-spin text-contrast" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="m-0 text-contrast">Your new server is being prepared.</h2>
|
||||
<p class="m-0">It'll appear here once it's ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -0,0 +1,35 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="loader"
|
||||
v-tooltip="'Change server loader'"
|
||||
class="flex min-w-0 flex-row items-center gap-4 truncate"
|
||||
>
|
||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<UiServersIconsLoaderIcon :loader="loader" class="flex shrink-0 [&&]:size-5" />
|
||||
<NuxtLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
||||
class="min-w-0 text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
>
|
||||
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||
</NuxtLink>
|
||||
<div v-else class="min-w-0 text-sm font-semibold">
|
||||
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
noSeparator?: boolean;
|
||||
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||
loaderVersion: string;
|
||||
isLink?: boolean;
|
||||
}>();
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id as string;
|
||||
</script>
|
||||
@ -0,0 +1,19 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-8">
|
||||
<img
|
||||
src="https://cdn.modrinth.com/servers/excitement.webp"
|
||||
alt=""
|
||||
class="max-w-[360px]"
|
||||
style="mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%)"
|
||||
/>
|
||||
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
|
||||
<p class="m-0">Modrinth Servers is a new way to play modded Minecraft with your friends.</p>
|
||||
<ButtonStyled size="large" type="standard" color="brand">
|
||||
<NuxtLink to="/servers#plan">Create a Server</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
</script>
|
||||
45
apps/frontend/src/components/ui/servers/ServerSidebar.vue
Normal file
45
apps/frontend/src/components/ui/servers/ServerSidebar.vue
Normal file
@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div class="static w-full grid-cols-1 md:relative md:flex">
|
||||
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
|
||||
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
|
||||
<div v-for="link in navLinks" :key="link.label">
|
||||
<NuxtLink
|
||||
:to="link.href"
|
||||
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
||||
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<component :is="link.icon" class="size-6" />
|
||||
{{ link.label }}
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<RightArrowIcon v-if="link.external" class="size-4" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full">
|
||||
<NuxtPage :route="props.route" :server="props.server" @reinstall="onReinstall" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const emit = defineEmits(["reinstall"]);
|
||||
|
||||
const props = defineProps<{
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||
route: RouteLocationNormalized;
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const onReinstall = (...args: any[]) => {
|
||||
emit("reinstall", ...args);
|
||||
};
|
||||
</script>
|
||||
18
apps/frontend/src/components/ui/servers/ServerSkeleton.vue
Normal file
18
apps/frontend/src/components/ui/servers/ServerSkeleton.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="n in count"
|
||||
:key="n"
|
||||
class="relative h-[128px] w-full animate-pulse rounded-3xl bg-bg-raised p-4"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
defineProps({
|
||||
count: {
|
||||
type: Number,
|
||||
default: 3,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
330
apps/frontend/src/components/ui/servers/ServerStats.vue
Normal file
330
apps/frontend/src/components/ui/servers/ServerStats.vue
Normal file
@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-stats
|
||||
style="font-variant-numeric: tabular-nums"
|
||||
class="flex select-none flex-col items-center gap-6 md:flex-row"
|
||||
>
|
||||
<div
|
||||
v-for="(metric, index) in metrics"
|
||||
:key="index"
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div
|
||||
class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1"
|
||||
:style="{
|
||||
backdropFilter: 'blur(6px)',
|
||||
}"
|
||||
>
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">
|
||||
{{ metric.value }}
|
||||
</h2>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
|
||||
</div>
|
||||
<h3 class="relative z-10 flex items-center gap-2 text-base font-normal text-secondary">
|
||||
{{ metric.title }}
|
||||
<WarningIcon
|
||||
v-tooltip="getPotentialWarning(metric)"
|
||||
:style="{
|
||||
color: 'var(--color-orange)',
|
||||
width: '1.25rem',
|
||||
height: '1.25rem',
|
||||
display: getPotentialWarning(metric) ? 'block' : 'none',
|
||||
}"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<component :is="metric.icon" class="absolute right-10 top-10 z-10" />
|
||||
<ClientOnly>
|
||||
<VueApexCharts
|
||||
v-if="
|
||||
metric.data.length && !(metric.title === 'Memory usage' && userPreferences.ramAsNumber)
|
||||
"
|
||||
ref="chart"
|
||||
type="area"
|
||||
height="142"
|
||||
:options="generateOptions(metric)"
|
||||
:series="[{ name: 'Chart', data: metric.data }]"
|
||||
class="chart chart-animation absolute bottom-0 left-0 right-0 w-full"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
:to="`/servers/manage/${serverId}/files`"
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
|
||||
{{ formatBytes(animatedStorageUsage) }}
|
||||
</h2>
|
||||
<!-- <h3 class="relative z-10 text-sm font-normal text-secondary">
|
||||
/ {{ formatBytes(props.data.current.storage_total_bytes) }}
|
||||
</h3> -->
|
||||
</div>
|
||||
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
|
||||
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import type { Stats } from "~/types/servers";
|
||||
import WarningIcon from "~/assets/images/utils/issues.svg?component";
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id;
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
ramAsNumber: false,
|
||||
autoRestart: false,
|
||||
backupWhileRunning: false,
|
||||
});
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<Stats>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const lerp = (a: number, b: number) => {
|
||||
return a + (b - a) * 0.5;
|
||||
};
|
||||
|
||||
// I told you it would go into prod
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 2) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return `${Math.round(value * 100) / 100} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
const animatedStorageUsage = ref(0);
|
||||
|
||||
const animateValue = (start: number, end: number, duration: number): void => {
|
||||
let startTimestamp: number | null = null;
|
||||
const step = (timestamp: number) => {
|
||||
if (!startTimestamp) startTimestamp = timestamp;
|
||||
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
|
||||
animatedStorageUsage.value = Math.floor(progress * (end - start) + start);
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
requestAnimationFrame(step);
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
animateValue(0, props.data.current.storage_usage_bytes, 250);
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.data.current.storage_usage_bytes,
|
||||
(newValue, oldValue) => {
|
||||
animateValue(oldValue, newValue, 250);
|
||||
},
|
||||
);
|
||||
|
||||
const metrics = ref([
|
||||
{
|
||||
title: "CPU usage",
|
||||
value: "0%",
|
||||
max: "100%",
|
||||
icon: markRaw(CPUIcon),
|
||||
data: [] as number[],
|
||||
},
|
||||
{
|
||||
title: "Memory usage",
|
||||
value: "0%",
|
||||
max: userPreferences.value.ramAsNumber
|
||||
? formatBytes(props.data.current.ram_total_bytes)
|
||||
: "100%",
|
||||
icon: markRaw(DBIcon),
|
||||
data: [] as number[],
|
||||
},
|
||||
]);
|
||||
|
||||
const updateMetrics = () => {
|
||||
console.log(props.data.current.ram_usage_bytes);
|
||||
metrics.value = metrics.value.map((metric, index) => {
|
||||
if (userPreferences.value.ramAsNumber && index === 1) {
|
||||
return {
|
||||
...metric,
|
||||
value: formatBytes(props.data.current.ram_usage_bytes),
|
||||
data: [...metric.data.slice(-10), props.data.current.ram_usage_bytes],
|
||||
max: formatBytes(props.data.current.ram_total_bytes),
|
||||
};
|
||||
} else {
|
||||
const currentValue =
|
||||
index === 0
|
||||
? props.data.current.cpu_percent
|
||||
: Math.min(
|
||||
(props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100,
|
||||
100,
|
||||
);
|
||||
const pastValue =
|
||||
index === 0
|
||||
? props.data.past.cpu_percent
|
||||
: Math.min(
|
||||
(props.data.past.ram_usage_bytes / props.data.past.ram_total_bytes) * 100,
|
||||
100,
|
||||
);
|
||||
|
||||
const newValue = lerp(currentValue, pastValue);
|
||||
return {
|
||||
...metric,
|
||||
value: `${newValue.toFixed(2)}%`,
|
||||
data: [...metric.data.slice(-10), newValue],
|
||||
// data: [36, 36],
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// aww, you gotta give em that rinth tuah, mod on that thang
|
||||
const getPotentialWarning = (metric: (typeof metrics.value)[0]) => {
|
||||
// make all words in the string lowercase, unless the word is in all caps
|
||||
const split = metric.title.split(" ");
|
||||
const title = split
|
||||
.map((word) => {
|
||||
if (word === word.toUpperCase()) {
|
||||
return word;
|
||||
}
|
||||
return word.toLowerCase();
|
||||
})
|
||||
.join(" ");
|
||||
let data = metric.data.at(-1) || 0;
|
||||
if (userPreferences.value.ramAsNumber) {
|
||||
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
|
||||
}
|
||||
switch (true) {
|
||||
case data >= 90:
|
||||
return `Your server's ${title} is very high.`;
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const generateOptions = (metric: (typeof metrics.value)[0]) => {
|
||||
let color = "var(--color-brand)";
|
||||
let data = metric.data.at(-1) || 0;
|
||||
if (userPreferences.value.ramAsNumber) {
|
||||
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
|
||||
}
|
||||
switch (true) {
|
||||
case data >= 90:
|
||||
color = "var(--color-red)";
|
||||
break;
|
||||
case data >= 80:
|
||||
color = "var(--color-orange)";
|
||||
break;
|
||||
}
|
||||
return {
|
||||
chart: {
|
||||
id: "stats",
|
||||
fontFamily:
|
||||
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
|
||||
foreColor: "var(--color-base)",
|
||||
toolbar: { show: false },
|
||||
zoom: { enabled: false },
|
||||
sparkline: { enabled: true },
|
||||
animations: {
|
||||
enabled: true,
|
||||
easing: "linear",
|
||||
dynamicAnimation: { speed: 1000 },
|
||||
},
|
||||
},
|
||||
stroke: { curve: "smooth" },
|
||||
fill: {
|
||||
colors: [color],
|
||||
type: "gradient",
|
||||
opacity: 1,
|
||||
gradient: {
|
||||
shade: "light",
|
||||
type: "vertical",
|
||||
shadeIntensity: 0,
|
||||
gradientToColors: [color],
|
||||
inverseColors: true,
|
||||
opacityFrom: 0.5,
|
||||
opacityTo: 0,
|
||||
stops: [0, 100],
|
||||
colorStops: [],
|
||||
},
|
||||
},
|
||||
grid: { show: false },
|
||||
legend: { show: false },
|
||||
colors: [color],
|
||||
dataLabels: { enabled: false },
|
||||
xaxis: {
|
||||
type: "numeric",
|
||||
lines: { show: false },
|
||||
axisBorder: { show: false },
|
||||
labels: { show: false },
|
||||
},
|
||||
yaxis: {
|
||||
min: 0,
|
||||
max: 100,
|
||||
tickAmount: 5,
|
||||
labels: { show: false },
|
||||
axisBorder: { show: false },
|
||||
axisTicks: { show: false },
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
};
|
||||
};
|
||||
|
||||
// watch(
|
||||
// metrics,
|
||||
// () => {
|
||||
// console.log(metrics.value[0].data.at(-1));
|
||||
// },
|
||||
// {
|
||||
// deep: true,
|
||||
// immediate: true,
|
||||
// },
|
||||
// );
|
||||
|
||||
let interval: number;
|
||||
|
||||
onMounted(() => {
|
||||
updateMetrics();
|
||||
interval = window.setInterval(updateMetrics, 1000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@keyframes chart-enter-animation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-animation {
|
||||
opacity: 0;
|
||||
animation: chart-enter-animation 0.5s ease-out forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="subdomain"
|
||||
v-tooltip="'Copy subdomain'"
|
||||
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
||||
>
|
||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<LinkIcon class="flex size-5 shrink-0" />
|
||||
<div
|
||||
class="flex min-w-0 text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
@click="copySubdomain"
|
||||
>
|
||||
{{ subdomain }}.modrinth.gg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LinkIcon } from "@modrinth/assets";
|
||||
const props = defineProps<{
|
||||
subdomain: string;
|
||||
noSeparator?: boolean;
|
||||
}>();
|
||||
|
||||
const copySubdomain = () => {
|
||||
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
|
||||
addNotification({
|
||||
group: "servers",
|
||||
title: "Subdomain copied",
|
||||
text: "Your subdomain has been copied to your clipboard.",
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = computed(() => route.params.id as string);
|
||||
</script>
|
||||
@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="uptimeSeconds || uptimeSeconds !== 0"
|
||||
v-tooltip="`Online for ${verboseUptime}`"
|
||||
class="flex min-w-0 flex-row items-center gap-4"
|
||||
data-pyro-uptime
|
||||
>
|
||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<UiServersTimer class="flex size-5 shrink-0" />
|
||||
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
|
||||
{{ formattedUptime }}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
uptimeSeconds: number;
|
||||
noSeparator?: boolean;
|
||||
}>();
|
||||
|
||||
const formattedUptime = computed(() => {
|
||||
const days = Math.floor(props.uptimeSeconds / (24 * 3600));
|
||||
const hours = Math.floor((props.uptimeSeconds % (24 * 3600)) / 3600);
|
||||
const minutes = Math.floor((props.uptimeSeconds % 3600) / 60);
|
||||
const seconds = props.uptimeSeconds % 60;
|
||||
|
||||
let formatted = "";
|
||||
if (days > 0) {
|
||||
formatted += `${days}d `;
|
||||
}
|
||||
if (hours > 0 || days > 0) {
|
||||
formatted += `${hours}h `;
|
||||
}
|
||||
formatted += `${minutes}m ${seconds}s`;
|
||||
|
||||
return formatted.trim();
|
||||
});
|
||||
|
||||
const verboseUptime = computed(() => {
|
||||
const days = Math.floor(props.uptimeSeconds / (24 * 3600));
|
||||
const hours = Math.floor((props.uptimeSeconds % (24 * 3600)) / 3600);
|
||||
const minutes = Math.floor((props.uptimeSeconds % 3600) / 60);
|
||||
const seconds = props.uptimeSeconds % 60;
|
||||
|
||||
let verbose = "";
|
||||
if (days > 0) {
|
||||
verbose += `${days} day${days > 1 ? "s" : ""} `;
|
||||
}
|
||||
if (hours > 0) {
|
||||
verbose += `${hours} hour${hours > 1 ? "s" : ""} `;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
verbose += `${minutes} minute${minutes > 1 ? "s" : ""} `;
|
||||
}
|
||||
verbose += `${seconds} second${seconds > 1 ? "s" : ""}`;
|
||||
|
||||
return verbose.trim();
|
||||
});
|
||||
</script>
|
||||
444
apps/frontend/src/components/ui/servers/TeleportDropdownMenu.vue
Normal file
444
apps/frontend/src/components/ui/servers/TeleportDropdownMenu.vue
Normal file
@ -0,0 +1,444 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
data-pyro-dropdown
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="relative inline-block h-9 w-full max-w-80"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@mousedown.prevent
|
||||
@keydown="handleKeyDown"
|
||||
>
|
||||
<div
|
||||
data-pyro-dropdown-trigger
|
||||
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
enter-active-class="transition-opacity duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-opacity duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
ref="optionsContainer"
|
||||
data-pyro-dropdown-options
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown.stop="handleDropdownKeyDown"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:style="{ height: `${virtualListHeight}px` }"
|
||||
data-pyro-dropdown-options-virtual-scroller
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.index"
|
||||
data-pyro-dropdown-option
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
|
||||
width: '100%',
|
||||
height: `${ITEM_HEIGHT}px`,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
||||
role="option"
|
||||
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
|
||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mouseover="focusedOptionIndex = item.index"
|
||||
@focus="focusedOptionIndex = item.index"
|
||||
>
|
||||
<input
|
||||
:id="`${name}-${item.index}`"
|
||||
v-model="radioValue"
|
||||
type="radio"
|
||||
:value="item.option"
|
||||
:name="name"
|
||||
class="hidden"
|
||||
/>
|
||||
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
|
||||
{{ displayName(item.option) }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon } from "@modrinth/assets";
|
||||
import { computed, ref, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import type { CSSProperties } from "vue";
|
||||
|
||||
const ITEM_HEIGHT = 44;
|
||||
const BUFFER_ITEMS = 5;
|
||||
|
||||
type OptionValue = string | number | Record<string, any>;
|
||||
|
||||
interface Props {
|
||||
options: OptionValue[];
|
||||
name: string;
|
||||
defaultValue?: OptionValue | null;
|
||||
placeholder?: string | number | null;
|
||||
modelValue?: OptionValue | null;
|
||||
renderUp?: boolean;
|
||||
disabled?: boolean;
|
||||
displayName?: (option: OptionValue) => string;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultValue: null,
|
||||
placeholder: null,
|
||||
modelValue: null,
|
||||
renderUp: false,
|
||||
disabled: false,
|
||||
displayName: (option: OptionValue) => String(option),
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "input", value: OptionValue): void;
|
||||
(e: "change", value: { option: OptionValue; index: number }): void;
|
||||
(e: "update:modelValue", value: OptionValue): void;
|
||||
}>();
|
||||
|
||||
const dropdownVisible = ref(false);
|
||||
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue);
|
||||
const focusedOptionIndex = ref<number | null>(null);
|
||||
const focusedOptionRef = ref<HTMLElement | null>(null);
|
||||
const dropdown = ref<HTMLElement | null>(null);
|
||||
const optionsContainer = ref<HTMLElement | null>(null);
|
||||
const scrollTop = ref(0);
|
||||
const isRenderingUp = ref(false);
|
||||
const virtualListHeight = ref(300);
|
||||
const lastFocusedElement = ref<HTMLElement | null>(null);
|
||||
|
||||
const positionStyle = ref<CSSProperties>({
|
||||
position: "fixed",
|
||||
top: "0px",
|
||||
left: "0px",
|
||||
width: "0px",
|
||||
zIndex: 999,
|
||||
});
|
||||
|
||||
const handleOptionRef = (el: HTMLElement | null, index: number) => {
|
||||
if (focusedOptionIndex.value === index) {
|
||||
focusedOptionRef.value = el;
|
||||
}
|
||||
};
|
||||
|
||||
const onFocus = async () => {
|
||||
if (!props.disabled) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement;
|
||||
dropdownVisible.value = true;
|
||||
await updatePosition();
|
||||
nextTick(() => {
|
||||
dropdown.value?.focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
|
||||
closeDropdown();
|
||||
}
|
||||
};
|
||||
|
||||
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
|
||||
let currentNode: HTMLElement | null = element;
|
||||
while (currentNode) {
|
||||
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
|
||||
return true;
|
||||
}
|
||||
currentNode = currentNode.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT);
|
||||
|
||||
const visibleOptions = computed(() => {
|
||||
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS;
|
||||
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS;
|
||||
|
||||
return Array.from({ length: visibleCount }, (_, i) => {
|
||||
const index = startIndex + i;
|
||||
if (index >= 0 && index < props.options.length) {
|
||||
return {
|
||||
index,
|
||||
option: props.options[index],
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}).filter((item): item is { index: number; option: OptionValue } => item !== null);
|
||||
});
|
||||
|
||||
const selectedOption = computed(() => {
|
||||
if (selectedValue.value !== null && selectedValue.value !== undefined) {
|
||||
return props.displayName(selectedValue.value as OptionValue);
|
||||
}
|
||||
return props.placeholder || "Select an option";
|
||||
});
|
||||
|
||||
const radioValue = computed<OptionValue>({
|
||||
get() {
|
||||
return props.modelValue ?? selectedValue.value ?? "";
|
||||
},
|
||||
set(newValue: OptionValue) {
|
||||
emit("update:modelValue", newValue);
|
||||
selectedValue.value = newValue;
|
||||
},
|
||||
});
|
||||
|
||||
const triggerClasses = computed(() => ({
|
||||
"cursor-not-allowed opacity-50 grayscale": props.disabled,
|
||||
"rounded-b-none": dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||
"rounded-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
}));
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!dropdown.value) return;
|
||||
|
||||
await nextTick();
|
||||
const triggerRect = dropdown.value.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const margin = 8;
|
||||
|
||||
const contentHeight = props.options.length * ITEM_HEIGHT;
|
||||
const preferredHeight = Math.min(contentHeight, 300);
|
||||
|
||||
const spaceBelow = viewportHeight - triggerRect.bottom;
|
||||
const spaceAbove = triggerRect.top;
|
||||
|
||||
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow;
|
||||
|
||||
virtualListHeight.value = isRenderingUp.value
|
||||
? Math.min(spaceAbove - margin, preferredHeight)
|
||||
: Math.min(spaceBelow - margin, preferredHeight);
|
||||
|
||||
positionStyle.value = {
|
||||
position: "fixed",
|
||||
left: `${triggerRect.left}px`,
|
||||
width: `${triggerRect.width}px`,
|
||||
zIndex: 999,
|
||||
...(isRenderingUp.value
|
||||
? { bottom: `${viewportHeight - triggerRect.top}px`, top: "auto" }
|
||||
: { top: `${triggerRect.bottom}px`, bottom: "auto" }),
|
||||
};
|
||||
};
|
||||
|
||||
const openDropdown = async () => {
|
||||
if (!props.disabled) {
|
||||
closeAllDropdowns();
|
||||
dropdownVisible.value = true;
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement;
|
||||
await updatePosition();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleDropdown = () => {
|
||||
if (!props.disabled) {
|
||||
if (dropdownVisible.value) {
|
||||
closeDropdown();
|
||||
} else {
|
||||
openDropdown();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (dropdownVisible.value) {
|
||||
requestAnimationFrame(() => {
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
const target = event.target as HTMLElement;
|
||||
scrollTop.value = target.scrollTop;
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!dropdownVisible.value) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
lastFocusedElement.value = document.activeElement as HTMLElement;
|
||||
toggleDropdown();
|
||||
}
|
||||
} else {
|
||||
handleDropdownKeyDown(event);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDropdownKeyDown = (event: KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
focusNextOption();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
focusPreviousOption();
|
||||
break;
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "Tab":
|
||||
event.preventDefault();
|
||||
if (event.shiftKey) {
|
||||
focusPreviousOption();
|
||||
} else {
|
||||
focusNextOption();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
dropdownVisible.value = false;
|
||||
focusedOptionIndex.value = null;
|
||||
if (lastFocusedElement.value) {
|
||||
lastFocusedElement.value.focus();
|
||||
lastFocusedElement.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const closeAllDropdowns = () => {
|
||||
const event = new CustomEvent("close-all-dropdowns");
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
|
||||
const selectOption = (option: OptionValue, index: number) => {
|
||||
radioValue.value = option;
|
||||
emit("change", { option, index });
|
||||
closeDropdown();
|
||||
};
|
||||
|
||||
const focusNextOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = 0;
|
||||
} else {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
|
||||
}
|
||||
scrollToFocused();
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
if (focusedOptionIndex.value === null) {
|
||||
focusedOptionIndex.value = props.options.length - 1;
|
||||
} else {
|
||||
focusedOptionIndex.value =
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
|
||||
}
|
||||
scrollToFocused();
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToFocused = () => {
|
||||
if (focusedOptionIndex.value === null) return;
|
||||
|
||||
const optionsElement = optionsContainer.value?.querySelector(".overflow-y-auto");
|
||||
if (!optionsElement) return;
|
||||
|
||||
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT;
|
||||
const scrollBottom = optionsElement.clientHeight;
|
||||
|
||||
if (targetScrollTop < optionsElement.scrollTop) {
|
||||
optionsElement.scrollTop = targetScrollTop;
|
||||
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
|
||||
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("scroll", handleResize, true);
|
||||
window.addEventListener("click", (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
window.addEventListener("close-all-dropdowns", closeDropdown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("scroll", handleResize, true);
|
||||
window.removeEventListener("click", (event) => {
|
||||
if (!isChildOfDropdown(event.target as HTMLElement)) {
|
||||
closeDropdown();
|
||||
}
|
||||
});
|
||||
window.removeEventListener("close-all-dropdowns", closeDropdown);
|
||||
lastFocusedElement.value = null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
selectedValue.value = newValue;
|
||||
},
|
||||
);
|
||||
|
||||
watch(dropdownVisible, async (newValue) => {
|
||||
if (newValue) {
|
||||
await updatePosition();
|
||||
scrollTop.value = 0;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
433
apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue
Normal file
433
apps/frontend/src/components/ui/servers/TeleportOverflowMenu.vue
Normal file
@ -0,0 +1,433 @@
|
||||
<template>
|
||||
<div data-pyro-telepopover-wrapper class="relative">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
class="teleport-overflow-menu-trigger"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="transform scale-75 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-125 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-75 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="option.id"
|
||||
type="transparent"
|
||||
role="menuitem"
|
||||
:color="option.color"
|
||||
>
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</button>
|
||||
<nuxt-link
|
||||
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement;
|
||||
}
|
||||
"
|
||||
:href="option.action"
|
||||
target="_blank"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</a>
|
||||
<span v-else>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from "vue";
|
||||
import { onClickOutside, useElementHover } from "@vueuse/core";
|
||||
|
||||
interface Option {
|
||||
id: string;
|
||||
action?: (() => void) | string;
|
||||
shown?: boolean;
|
||||
color?: "standard" | "brand" | "red" | "orange" | "green" | "blue" | "purple";
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Option[];
|
||||
hoverable?: boolean;
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select", option: Option): void;
|
||||
}>();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const selectedIndex = ref(-1);
|
||||
const menuRef = ref<HTMLElement | null>(null);
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const isMouseDown = ref(false);
|
||||
const typeAheadBuffer = ref("");
|
||||
const typeAheadTimeout = ref<number | null>(null);
|
||||
const menuItemsRef = ref<HTMLElement[]>([]);
|
||||
|
||||
const hoveringTrigger = useElementHover(triggerRef);
|
||||
const hoveringMenu = useElementHover(menuRef);
|
||||
|
||||
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value);
|
||||
|
||||
const menuStyle = ref({
|
||||
top: "0px",
|
||||
left: "0px",
|
||||
});
|
||||
|
||||
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false));
|
||||
|
||||
const calculateMenuPosition = () => {
|
||||
if (!triggerRef.value || !menuRef.value) return { top: "0px", left: "0px" };
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||
const menuRect = menuRef.value.getBoundingClientRect();
|
||||
const menuWidth = menuRect.width;
|
||||
const menuHeight = menuRect.height;
|
||||
const margin = 8;
|
||||
|
||||
let top: number;
|
||||
let left: number;
|
||||
|
||||
// okay gang lets calculate this shit
|
||||
// from the top now yall
|
||||
// y
|
||||
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
|
||||
top = triggerRect.bottom + margin;
|
||||
} else if (triggerRect.top - menuHeight - margin >= 0) {
|
||||
top = triggerRect.top - menuHeight - margin;
|
||||
} else {
|
||||
top = Math.max(margin, window.innerHeight - menuHeight - margin);
|
||||
}
|
||||
|
||||
// x
|
||||
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
|
||||
left = triggerRect.left;
|
||||
} else if (triggerRect.right - menuWidth - margin >= 0) {
|
||||
left = triggerRect.right - menuWidth;
|
||||
} else {
|
||||
left = Math.max(margin, window.innerWidth - menuWidth - margin);
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
};
|
||||
};
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation();
|
||||
if (!props.hoverable) {
|
||||
if (isOpen.value) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openMenu();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openMenu = () => {
|
||||
isOpen.value = true;
|
||||
disableBodyScroll();
|
||||
nextTick(() => {
|
||||
menuStyle.value = calculateMenuPosition();
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
focusFirstMenuItem();
|
||||
});
|
||||
};
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen.value = false;
|
||||
selectedIndex.value = -1;
|
||||
enableBodyScroll();
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
};
|
||||
|
||||
const selectOption = (option: Option) => {
|
||||
emit("select", option);
|
||||
if (typeof option.action === "function") {
|
||||
option.action();
|
||||
}
|
||||
closeMenu();
|
||||
};
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
event.preventDefault();
|
||||
isMouseDown.value = true;
|
||||
};
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (props.hoverable) {
|
||||
openMenu();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.hoverable) {
|
||||
setTimeout(() => {
|
||||
if (!hovering.value) {
|
||||
closeMenu();
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!isOpen.value || !isMouseDown.value) return;
|
||||
|
||||
const menuRect = menuRef.value?.getBoundingClientRect();
|
||||
if (!menuRect) return;
|
||||
|
||||
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]');
|
||||
if (!menuItems) return;
|
||||
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect();
|
||||
if (
|
||||
event.clientX >= itemRect.left &&
|
||||
event.clientX <= itemRect.right &&
|
||||
event.clientY >= itemRect.top &&
|
||||
event.clientY <= itemRect.bottom
|
||||
) {
|
||||
selectedIndex.value = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleItemClick = (option: Option, index: number) => {
|
||||
selectedIndex.value = index;
|
||||
selectOption(option);
|
||||
};
|
||||
|
||||
const handleMouseOver = (index: number) => {
|
||||
selectedIndex.value = index;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
};
|
||||
|
||||
// Scrolling is disabled for keyboard navigation
|
||||
const disableBodyScroll = () => {
|
||||
// Make opening not shift page when there's a vertical scrollbar
|
||||
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth;
|
||||
if (scrollBarWidth > 0) {
|
||||
document.body.style.paddingRight = `${scrollBarWidth}px`;
|
||||
} else {
|
||||
document.body.style.paddingRight = "";
|
||||
}
|
||||
|
||||
document.body.style.overflow = "hidden";
|
||||
};
|
||||
|
||||
const enableBodyScroll = () => {
|
||||
document.body.style.paddingRight = "";
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
|
||||
const focusFirstMenuItem = () => {
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
menuItemsRef.value[0].focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isOpen.value) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
openMenu();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = 0;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
}
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = filteredOptions.value.length - 1;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (selectedIndex.value >= 0) {
|
||||
selectOption(filteredOptions.value[selectedIndex.value]);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeMenu();
|
||||
triggerRef.value?.focus();
|
||||
break;
|
||||
case "Tab":
|
||||
event.preventDefault();
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
if (event.shiftKey) {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length;
|
||||
} else {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length;
|
||||
}
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
}
|
||||
break;
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
typeAheadBuffer.value += event.key.toLowerCase();
|
||||
const matchIndex = filteredOptions.value.findIndex((option) =>
|
||||
option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
);
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex;
|
||||
menuItemsRef.value[selectedIndex.value].focus();
|
||||
}
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value);
|
||||
}
|
||||
typeAheadTimeout.value = setTimeout(() => {
|
||||
typeAheadBuffer.value = "";
|
||||
}, 1000) as unknown as number;
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleResizeOrScroll = () => {
|
||||
if (isOpen.value) {
|
||||
menuStyle.value = calculateMenuPosition();
|
||||
}
|
||||
};
|
||||
|
||||
const throttle = (func: (...args: any[]) => void, limit: number): ((...args: any[]) => void) => {
|
||||
let inThrottle: boolean;
|
||||
return function (...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
func(...args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => (inThrottle = false), limit);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100);
|
||||
|
||||
onMounted(() => {
|
||||
triggerRef.value?.addEventListener("keydown", handleKeydown);
|
||||
window.addEventListener("resize", throttledHandleResizeOrScroll);
|
||||
window.addEventListener("scroll", throttledHandleResizeOrScroll);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
triggerRef.value?.removeEventListener("keydown", handleKeydown);
|
||||
window.removeEventListener("resize", throttledHandleResizeOrScroll);
|
||||
window.removeEventListener("scroll", throttledHandleResizeOrScroll);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value);
|
||||
}
|
||||
enableBodyScroll();
|
||||
});
|
||||
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
nextTick(() => {
|
||||
menuRef.value?.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
} else {
|
||||
menuRef.value?.removeEventListener("keydown", handleKeydown);
|
||||
}
|
||||
});
|
||||
|
||||
onClickOutside(menuRef, (event) => {
|
||||
if (!triggerRef.value?.contains(event.target as Node)) {
|
||||
closeMenu();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
17
apps/frontend/src/components/ui/servers/Timer.vue
Normal file
17
apps/frontend/src/components/ui/servers/Timer.vue
Normal file
@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="10" x2="14" y1="2" y2="2" />
|
||||
<line x1="12" x2="15" y1="14" y2="11" />
|
||||
<circle cx="12" cy="14" r="8" />
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 12.5 8 15l2 2.5" />
|
||||
<path d="m14 12.5 2 2.5-2 2.5" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,26 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<path
|
||||
d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v3.3"
|
||||
/>
|
||||
<path d="m21.7 19.4-.9-.3" />
|
||||
<path d="m15.2 16.9-.9-.3" />
|
||||
<path d="m16.6 21.7.3-.9" />
|
||||
<path d="m19.1 15.2.3-.9" />
|
||||
<path d="m19.6 21.7-.4-1" />
|
||||
<path d="m16.8 15.3-.4-1" />
|
||||
<path d="m14.3 19.6 1-.4" />
|
||||
<path d="m20.7 16.8 1-.4" />
|
||||
</svg>
|
||||
</template>
|
||||
20
apps/frontend/src/components/ui/servers/icons/EarthIcon.vue
Normal file
20
apps/frontend/src/components/ui/servers/icons/EarthIcon.vue
Normal file
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
|
||||
<path
|
||||
d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"
|
||||
/>
|
||||
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
|
||||
</svg>
|
||||
</template>
|
||||
172
apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue
Normal file
172
apps/frontend/src/components/ui/servers/icons/LoaderIcon.vue
Normal file
@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<svg
|
||||
v-if="loader === 'Fabric'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path fill="none" d="M0 0h24v24H0z" />
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="23"
|
||||
d="m820 761-85.6-87.6c-4.6-4.7-10.4-9.6-25.9 1-19.9 13.6-8.4 21.9-5.2 25.4 8.2 9 84.1 89 97.2 104 2.5 2.8-20.3-22.5-6.5-39.7 5.4-7 18-12 26-3 6.5 7.3 10.7 18-3.4 29.7-24.7 20.4-102 82.4-127 103-12.5 10.3-28.5 2.3-35.8-6-7.5-8.9-30.6-34.6-51.3-58.2-5.5-6.3-4.1-19.6 2.3-25 35-30.3 91.9-73.8 111.9-90.8"
|
||||
transform="matrix(.08671 0 0 .0867 -49.8 -56)"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Quilt'"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="2"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<defs>
|
||||
<path
|
||||
id="quilt"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="65.6"
|
||||
d="M442.5 233.9c0-6.4-5.2-11.6-11.6-11.6h-197c-6.4 0-11.6 5.2-11.6 11.6v197c0 6.4 5.2 11.6 11.6 11.6h197c6.4 0 11.6-5.2 11.6-11.7v-197Z"
|
||||
></path>
|
||||
</defs>
|
||||
<path fill="none" d="M0 0h24v24H0z"></path>
|
||||
<use
|
||||
xlink:href="#quilt"
|
||||
stroke-width="65.6"
|
||||
transform="matrix(.03053 0 0 .03046 -3.2 -3.2)"
|
||||
></use>
|
||||
<use xlink:href="#quilt" stroke-width="65.6" transform="matrix(.03053 0 0 .03046 -3.2 7)"></use>
|
||||
<use
|
||||
xlink:href="#quilt"
|
||||
stroke-width="65.6"
|
||||
transform="matrix(.03053 0 0 .03046 6.9 -3.2)"
|
||||
></use>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="70.4"
|
||||
d="M442.5 234.8c0-7-5.6-12.5-12.5-12.5H234.7c-6.8 0-12.4 5.6-12.4 12.5V430c0 6.9 5.6 12.5 12.4 12.5H430c6.9 0 12.5-5.6 12.5-12.5V234.8Z"
|
||||
transform="rotate(45 3.5 24) scale(.02843 .02835)"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Forge'"
|
||||
ml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="1.5"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path fill="none" d="M0 0h24v24H0z"></path>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
d="M2 7.5h8v-2h12v2s-7 3.4-7 6 3.1 3.1 3.1 3.1l.9 3.9H5l1-4.1s3.8.1 4-2.9c.2-2.7-6.5-.7-8-6Z"
|
||||
></path>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'NeoForge'"
|
||||
enable-background="new 0 0 24 24"
|
||||
version="1.1"
|
||||
viewBox="0 0 24 24"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path d="m12 19.2v2m0-2v2" />
|
||||
<path
|
||||
d="m8.4 1.3c0.5 1.5 0.7 3 0.1 4.6-0.2 0.5-0.9 1.5-1.6 1.5m8.7-6.1c-0.5 1.5-0.7 3-0.1 4.6 0.2 0.6 0.9 1.5 1.6 1.5"
|
||||
/>
|
||||
<path d="m3.6 15.8h-1.7m18.5 0h1.7" />
|
||||
<path d="m3.2 12.1h-1.7m19.3 0h1.8" />
|
||||
<path d="m8.1 12.7v1.6m7.8-1.6v1.6" />
|
||||
<path d="m10.8 18h1.2m0 1.2-1.2-1.2m2.4 0h-1.2m0 1.2 1.2-1.2" />
|
||||
<path
|
||||
d="m4 9.7c-0.5 1.2-0.8 2.4-0.8 3.7 0 3.1 2.9 6.3 5.3 8.2 0.9 0.7 2.2 1.1 3.4 1.1m0.1-17.8c-1.1 0-2.1 0.2-3.2 0.7m11.2 4.1c0.5 1.2 0.8 2.4 0.8 3.7 0 3.1-2.9 6.3-5.3 8.2-0.9 0.7-2.2 1.1-3.4 1.1m-0.1-17.8c1.1 0 2.1 0.2 3.2 0.7"
|
||||
/>
|
||||
<path
|
||||
d="m4 9.7c-0.2-1.8-0.3-3.7 0.5-5.5s2.2-2.6 3.9-3m11.6 8.5c0.2-1.9 0.3-3.7-0.5-5.5s-2.2-2.6-3.9-3"
|
||||
/>
|
||||
<path d="m12 21.2-2.4 0.4m2.4-0.4 2.4 0.4" />
|
||||
</g>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Paper'"
|
||||
xml:space="preserve"
|
||||
fill-rule="evenodd"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-miterlimit="1.5"
|
||||
clip-rule="evenodd"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path fill="none" d="M0 0h24v24H0z" />
|
||||
<path fill="none" stroke="currentColor" stroke-width="2" d="m12 18 6 2 3-17L2 14l6 2" />
|
||||
<path stroke="currentColor" stroke-width="2" d="m9 21-1-5 4 2-3 3Z" />
|
||||
<path fill="currentColor" d="m12 18-4-2 10-9-6 11Z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Spigot'"
|
||||
viewBox="0 0 332 284"
|
||||
style="
|
||||
fill-rule: evenodd;
|
||||
clip-rule: evenodd;
|
||||
stroke-linejoin: round;
|
||||
fill: none;
|
||||
fill-rule: nonzero;
|
||||
stroke-width: 24px;
|
||||
"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M147.5,27l27,-15l27.5,15l66.5,0l0,33.5l-73,-0.912l0,45.5l26,-0.088l0,31.5l-12.5,0l0,15.5l16,21.5l35,0l0,-21.5l35.5,0l0,21.5l24.5,0l0,55.5l-24.5,0l0,17l-35.5,0l0,-27l-35,0l-55.5,14.5l-67.5,-14.5l-15,14.5l18,12.5l-3,24.5l-41.5,1.5l-48.5,-19.5l6,-19l24.5,-4.5l16,-41l79,-36l-7,-15.5l0,-31.5l23.5,0l0,-45.5l-73.5,0l0,-32.5l67,0Z"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else-if="loader === 'Bukkit'"
|
||||
viewBox="0 0 292 319"
|
||||
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<g transform="matrix(1,0,0,1,0,-5)">
|
||||
<path
|
||||
d="M12,109.5L12,155L34.5,224L57.5,224L57.5,271L81,294L160,294L160,172L259.087,172L265,155L265,109.5M12,109.5L12,64L34.5,64L34.5,41L81,17L195.5,17L241,41L241,64L265,64L265,109.5M12,109.5L81,109.5L81,132L195.5,132L195.5,109.5L265,109.5M264.087,204L264.087,244M207.5,272L207.5,312M250,272L250,312L280,312L280,272L250,272ZM192.5,204L192.5,244L222.5,244L222.5,204L192.5,204Z"
|
||||
style="fill: none; fill-rule: nonzero; stroke-width: 24px"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
<svg v-else-if="loader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z"
|
||||
clip-rule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
<LoaderIcon v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderIcon } from "@modrinth/assets";
|
||||
|
||||
defineProps<{
|
||||
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||
}>();
|
||||
</script>
|
||||
@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-8 text-[#FF496E]"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
10
apps/frontend/src/components/ui/servers/icons/SlashIcon.vue
Normal file
10
apps/frontend/src/components/ui/servers/icons/SlashIcon.vue
Normal file
@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32">
|
||||
<path
|
||||
d="M22 5L9 28"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-file-text"
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M10 9H8" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
</svg>
|
||||
</template>
|
||||
@ -23,6 +23,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showAdsWithPlus: false,
|
||||
|
||||
// Feature toggles
|
||||
projectTypesPrimaryNav: false,
|
||||
hidePlusPromoInUserMenu: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
103
apps/frontend/src/composables/pyroFetch.ts
Normal file
103
apps/frontend/src/composables/pyroFetch.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { $fetch, FetchError } from "ofetch";
|
||||
|
||||
interface PyroFetchOptions {
|
||||
method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
contentType?: string;
|
||||
body?: Record<string, any>;
|
||||
version?: number;
|
||||
override?: {
|
||||
url?: string;
|
||||
token?: string;
|
||||
};
|
||||
retry?: boolean;
|
||||
}
|
||||
|
||||
export class PyroFetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public statusCode?: number,
|
||||
public originalError?: Error,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "PyroFetchError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function usePyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> {
|
||||
const config = useRuntimeConfig();
|
||||
const auth = await useAuth();
|
||||
const authToken = auth.value?.token;
|
||||
|
||||
if (!authToken) {
|
||||
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
|
||||
}
|
||||
|
||||
const { method = "GET", contentType = "application/json", body, version = 0, override } = options;
|
||||
|
||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
|
||||
if (!base) {
|
||||
throw new PyroFetchError(
|
||||
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
|
||||
10001,
|
||||
);
|
||||
}
|
||||
|
||||
const fullUrl = override?.url
|
||||
? `https://${override.url}/${path.replace(/^\//, "")}`
|
||||
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
|
||||
|
||||
type HeadersRecord = Record<string, string>;
|
||||
|
||||
const headers: HeadersRecord = {
|
||||
Authorization: `Bearer ${override?.token ?? authToken}`,
|
||||
"Access-Control-Allow-Headers": "Authorization",
|
||||
"User-Agent": "Pyro/1.0 (https://pyro.host)",
|
||||
Vary: "Accept, Origin",
|
||||
"Content-Type": contentType,
|
||||
};
|
||||
|
||||
if (import.meta.client && typeof window !== "undefined") {
|
||||
headers.Origin = window.location.origin;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||
timeout: 10000,
|
||||
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("Fetch error:", error);
|
||||
if (error instanceof FetchError) {
|
||||
const statusCode = error.response?.status;
|
||||
const statusText = error.response?.statusText || "Unknown error";
|
||||
const errorMessages: { [key: number]: string } = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
502: "Bad Gateway",
|
||||
};
|
||||
const message =
|
||||
statusCode && statusCode in errorMessages
|
||||
? errorMessages[statusCode]
|
||||
: `HTTP Error: ${statusCode || "unknown"} ${statusText}`;
|
||||
throw new PyroFetchError(`[PYROFETCH][PYRO] ${message}`, statusCode, error);
|
||||
}
|
||||
throw new PyroFetchError(
|
||||
"[PYROFETCH][PYRO] An unexpected error occurred during the fetch operation.",
|
||||
undefined,
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
}
|
||||
1293
apps/frontend/src/composables/pyroServers.ts
Normal file
1293
apps/frontend/src/composables/pyroServers.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -38,7 +38,7 @@
|
||||
config.public.apiBaseUrl.startsWith('https://staging-api.modrinth.com') &&
|
||||
!cosmetics.hideStagingBanner
|
||||
"
|
||||
class="site-banner site-banner--warning"
|
||||
class="site-banner site-banner--warning [&>*]:z-[6]"
|
||||
>
|
||||
<div class="site-banner__title">
|
||||
<IssuesIcon aria-hidden="true" />
|
||||
@ -54,7 +54,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<header
|
||||
class="experimental-styles-within desktop-only relative z-[5] mx-auto my-4 grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-3 sm:grid-cols-[auto_1fr_auto]"
|
||||
class="experimental-styles-within desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-3 py-4 lg:grid-cols-[auto_1fr_auto]"
|
||||
>
|
||||
<div>
|
||||
<NuxtLink to="/" aria-label="Modrinth home page">
|
||||
@ -62,121 +62,165 @@
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<div
|
||||
class="col-span-2 row-start-2 flex flex-wrap justify-center gap-1 sm:col-span-1 sm:row-start-auto"
|
||||
class="col-span-2 row-start-2 flex flex-wrap justify-center gap-4 lg:col-span-1 lg:row-start-auto"
|
||||
>
|
||||
<div class="hidden xl:contents">
|
||||
<ButtonStyled type="transparent">
|
||||
<nuxt-link to="/mods" class="temp-nav-buttons"> Mods </nuxt-link>
|
||||
<template v-if="flags.projectTypesPrimaryNav">
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
||||
:highlighted-style="
|
||||
route.name === 'search-mods' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||
"
|
||||
>
|
||||
<nuxt-link to="/mods"> <BoxIcon aria-hidden="true" /> Mods </nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<nuxt-link to="/resourcepacks" class="temp-nav-buttons"> Resource Packs </nuxt-link>
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="
|
||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||
"
|
||||
:highlighted-style="
|
||||
route.name === 'search-mods' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||
"
|
||||
>
|
||||
<nuxt-link to="/resourcepacks">
|
||||
<PaintBrushIcon aria-hidden="true" /> Resource Packs
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<nuxt-link to="/datapacks" class="temp-nav-buttons"> Data Packs </nuxt-link>
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
|
||||
:highlighted-style="
|
||||
route.name === 'search-mods' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||
"
|
||||
>
|
||||
<nuxt-link to="/datapacks"> <BracesIcon aria-hidden="true" /> Data Packs </nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<nuxt-link to="/modpacks" class="temp-nav-buttons"> Modpacks </nuxt-link>
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
|
||||
:highlighted-style="
|
||||
route.name === 'search-mods' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||
"
|
||||
>
|
||||
<nuxt-link to="/modpacks"> <PackageOpenIcon aria-hidden="true" /> Modpacks </nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<nuxt-link to="/shaders" class="temp-nav-buttons"> Shaders </nuxt-link>
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
|
||||
:highlighted-style="
|
||||
route.name === 'search-mods' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||
"
|
||||
>
|
||||
<nuxt-link to="/shaders"> <GlassesIcon aria-hidden="true" /> Shaders </nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<nuxt-link to="/plugins" class="temp-nav-buttons"> Plugins </nuxt-link>
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
|
||||
:highlighted-style="
|
||||
route.name === 'search-plugins' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||
"
|
||||
>
|
||||
<nuxt-link to="/plugins"> <PlugIcon aria-hidden="true" /> Plugins </nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="contents xl:hidden">
|
||||
<ButtonStyled type="transparent">
|
||||
<OverflowMenu
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||
</template>
|
||||
<template v-else>
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="isDiscovering || isDiscoveringSubpage"
|
||||
:highlighted-style="isDiscoveringSubpage ? 'main-nav-secondary' : 'main-nav-primary'"
|
||||
>
|
||||
<TeleportOverflowMenu
|
||||
:options="[
|
||||
{
|
||||
id: 'mods',
|
||||
link: '/mods',
|
||||
action: '/mods',
|
||||
},
|
||||
{
|
||||
id: 'resourcepacks',
|
||||
link: '/resourcepacks',
|
||||
action: '/resourcepacks',
|
||||
},
|
||||
{
|
||||
id: 'datapacks',
|
||||
link: '/datapacks',
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
link: '/plugins',
|
||||
action: '/datapacks',
|
||||
},
|
||||
{
|
||||
id: 'shaders',
|
||||
link: '/shaders',
|
||||
action: '/shaders',
|
||||
},
|
||||
{
|
||||
id: 'modpacks',
|
||||
link: '/modpacks',
|
||||
action: '/modpacks',
|
||||
},
|
||||
{
|
||||
id: 'plugins',
|
||||
action: '/plugins',
|
||||
},
|
||||
]"
|
||||
hoverable
|
||||
>
|
||||
<CompassIcon aria-hidden="true" /> Browse
|
||||
<BoxIcon
|
||||
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PaintBrushIcon
|
||||
v-else-if="
|
||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||
"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<BracesIcon
|
||||
v-else-if="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PackageOpenIcon
|
||||
v-else-if="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<GlassesIcon
|
||||
v-else-if="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<PlugIcon
|
||||
v-else-if="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<CompassIcon v-else aria-hidden="true" />
|
||||
<span class="hidden md:contents">Discover content</span>
|
||||
<span class="contents md:hidden">Discover</span>
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
|
||||
<template #mods> <BoxIcon aria-hidden="true" /> Mods </template>
|
||||
<template #resourcepacks>
|
||||
<PaintBrushIcon aria-hidden="true" /> Resource Packs
|
||||
</template>
|
||||
<template #datapacks> <BracesIcon aria-hidden="true" /> Data Packs </template>
|
||||
<template #plugins> <ServerIcon aria-hidden="true" /> Plugins </template>
|
||||
<template #plugins> <PlugIcon aria-hidden="true" /> Plugins </template>
|
||||
<template #shaders> <GlassesIcon aria-hidden="true" /> Shaders </template>
|
||||
<template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ButtonStyled type="transparent">
|
||||
<OverflowMenu
|
||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||
:options="[
|
||||
{
|
||||
id: 'servers',
|
||||
link: 'https://bisecthosting.com/modrinth',
|
||||
shown: false,
|
||||
},
|
||||
{
|
||||
id: 'app',
|
||||
link: '/app',
|
||||
},
|
||||
{
|
||||
id: 'plus',
|
||||
color: 'purple',
|
||||
link: '/plus',
|
||||
shown: !auth.user || !isPermission(auth.user.badges, 1 << 0),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<HamburgerIcon aria-hidden="true" /> More
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #servers> <ServerIcon aria-hidden="true" /> Host a server </template>
|
||||
<template #app> <DownloadIcon aria-hidden="true" /> Get Modrinth App </template>
|
||||
<template #plus>
|
||||
<ArrowBigUpDashIcon aria-hidden="true" /> Upgrade to Modrinth+
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
<div v-if="false" class="hidden lg:contents">
|
||||
<ButtonStyled v-if="false" type="transparent">
|
||||
<a href="https://bisecthosting.com/modrinth">
|
||||
<ServerIcon aria-hidden="true" /> Host a server
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<NuxtLink to="/app"> <DownloadIcon aria-hidden="true" /> Get Modrinth App </NuxtLink>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled
|
||||
v-if="!auth.user || !isPermission(auth.user.badges, 1 << 0)"
|
||||
type="transparent"
|
||||
color="purple"
|
||||
:highlighted="route.name.startsWith('servers')"
|
||||
:highlighted-style="
|
||||
route.name === 'servers' ? 'main-nav-primary' : 'main-nav-secondary'
|
||||
"
|
||||
>
|
||||
<NuxtLink to="/plus">
|
||||
<ArrowBigUpDashIcon aria-hidden="true" /> Upgrade to Modrinth+
|
||||
</NuxtLink>
|
||||
<nuxt-link to="/servers">
|
||||
<ServerIcon aria-hidden="true" />
|
||||
Host a server
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ButtonStyled type="transparent" :highlighted="route.name === 'app'">
|
||||
<nuxt-link to="/app">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
<span class="hidden md:contents">Get Modrinth App</span>
|
||||
<span class="contents md:hidden">Modrinth App</span>
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
@ -224,7 +268,12 @@
|
||||
<template #profile> <UserIcon aria-hidden="true" /> Profile </template>
|
||||
<template #notifications> <BellIcon aria-hidden="true" /> Notifications </template>
|
||||
<template #saved> <BookmarkIcon aria-hidden="true" /> Saved projects </template>
|
||||
<template #servers> <ServerIcon aria-hidden="true" /> My servers </template>
|
||||
<template #plus>
|
||||
<ArrowBigUpDashIcon aria-hidden="true" /> Upgrade to Modrinth+
|
||||
</template>
|
||||
<template #settings> <SettingsIcon aria-hidden="true" /> Settings </template>
|
||||
<template #flags> <ReportIcon aria-hidden="true" /> Feature flags </template>
|
||||
<template #projects> <BoxIcon aria-hidden="true" /> Projects </template>
|
||||
<template #organizations>
|
||||
<OrganizationIcon aria-hidden="true" /> Organizations
|
||||
@ -302,6 +351,10 @@
|
||||
<LibraryIcon class="icon" />
|
||||
{{ formatMessage(commonMessages.collectionsLabel) }}
|
||||
</NuxtLink>
|
||||
<NuxtLink class="iconified-button" to="/servers/manage">
|
||||
<ServerIcon class="icon" />
|
||||
{{ formatMessage(commonMessages.serversLabel) }}
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="auth.user.role === 'moderator' || auth.user.role === 'admin'"
|
||||
class="iconified-button"
|
||||
@ -521,6 +574,7 @@ import {
|
||||
HomeIcon,
|
||||
MoonIcon,
|
||||
SunIcon,
|
||||
PlugIcon,
|
||||
PlusIcon,
|
||||
DropdownIcon,
|
||||
LogOutIcon,
|
||||
@ -537,14 +591,15 @@ import {
|
||||
} from "@modrinth/assets";
|
||||
import { Button, ButtonStyled, OverflowMenu, Avatar } from "@modrinth/ui";
|
||||
|
||||
import CrossIcon from "assets/images/utils/x.svg";
|
||||
import NotificationIcon from "assets/images/sidebar/notifications.svg";
|
||||
import ModerationIcon from "assets/images/sidebar/admin.svg";
|
||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
|
||||
import { commonMessages } from "~/utils/common-messages.ts";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import OrganizationCreateModal from "~/components/ui/OrganizationCreateModal.vue";
|
||||
import CrossIcon from "assets/images/utils/x.svg";
|
||||
import NotificationIcon from "assets/images/sidebar/notifications.svg";
|
||||
import ModerationIcon from "assets/images/sidebar/admin.svg";
|
||||
import TeleportOverflowMenu from "~/components/ui/servers/TeleportOverflowMenu.vue";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@ -735,6 +790,7 @@ const isMobileMenuOpen = ref(false);
|
||||
const isBrowseMenuOpen = ref(false);
|
||||
const navRoutes = computed(() => [
|
||||
{
|
||||
id: "mods",
|
||||
label: formatMessage(getProjectTypeMessage("mod", true)),
|
||||
href: "/mods",
|
||||
},
|
||||
@ -766,6 +822,12 @@ const userMenuOptions = computed(() => {
|
||||
id: "profile",
|
||||
link: `/user/${auth.value.user.username}`,
|
||||
},
|
||||
{
|
||||
id: "plus",
|
||||
link: "/plus",
|
||||
color: "purple",
|
||||
shown: !flags.value.hidePlusPromoInUserMenu && !isPermission(auth.value.user.badges, 1 << 0),
|
||||
},
|
||||
{
|
||||
id: "notifications",
|
||||
link: "/dashboard/notifications",
|
||||
@ -774,6 +836,15 @@ const userMenuOptions = computed(() => {
|
||||
id: "saved",
|
||||
link: "/dashboard/collections",
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
link: "/servers/manage",
|
||||
},
|
||||
{
|
||||
id: "flags",
|
||||
link: "/flags",
|
||||
shown: flags.value.developerMode,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
link: "/settings",
|
||||
@ -836,6 +907,10 @@ const userMenuOptions = computed(() => {
|
||||
return options;
|
||||
});
|
||||
|
||||
const isDiscovering = computed(() => route.name && route.name.startsWith("search-"));
|
||||
|
||||
const isDiscoveringSubpage = computed(() => route.name && route.name.startsWith("type-id"));
|
||||
|
||||
onMounted(() => {
|
||||
if (window && import.meta.client) {
|
||||
window.history.scrollRestoration = "auto";
|
||||
@ -894,6 +969,7 @@ function runAnalytics() {
|
||||
const config = useRuntimeConfig();
|
||||
const replacedUrl = config.public.apiBaseUrl.replace("v2/", "");
|
||||
|
||||
try {
|
||||
setTimeout(() => {
|
||||
$fetch(`${replacedUrl}analytics/view`, {
|
||||
method: "POST",
|
||||
@ -907,6 +983,9 @@ function runAnalytics() {
|
||||
.then(() => {})
|
||||
.catch(() => {});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`Sending analytics failed (CORS error? If so, ignore)`, e);
|
||||
}
|
||||
}
|
||||
function toggleMobileMenu() {
|
||||
isMobileMenuOpen.value = !isMobileMenuOpen.value;
|
||||
@ -1080,6 +1159,8 @@ function hideStagingBanner() {
|
||||
}
|
||||
|
||||
.email-nag {
|
||||
z-index: 6;
|
||||
position: relative;
|
||||
background-color: var(--color-raised-bg);
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@ -1090,13 +1171,28 @@ function hideStagingBanner() {
|
||||
}
|
||||
|
||||
.site-banner--warning {
|
||||
background-color: var(--color-red-bg);
|
||||
// On some pages, there's gradient backgrounds that seep underneath
|
||||
// the banner, so we need to add a solid color underlay.
|
||||
background-color: black;
|
||||
border-bottom: 2px solid var(--color-red);
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
grid-template: "title actions" "description actions";
|
||||
padding-block: var(--gap-xl);
|
||||
padding-inline: max(calc((100% - 80rem) / 2 + var(--gap-md)), var(--gap-xl));
|
||||
z-index: 4;
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--color-red-bg);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.site-banner__title {
|
||||
grid-area: title;
|
||||
@ -1337,10 +1433,5 @@ function hideStagingBanner() {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.temp-nav-buttons.router-link-exact-active {
|
||||
color: var(--color-contrast) !important;
|
||||
background-color: var(--color-brand-highlight) !important;
|
||||
}
|
||||
</style>
|
||||
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
||||
|
||||
@ -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(() => {
|
||||
}
|
||||
}
|
||||
|
||||
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(() => [
|
||||
|
||||
BIN
apps/frontend/src/public/Monocraft.ttf
Normal file
BIN
apps/frontend/src/public/Monocraft.ttf
Normal file
Binary file not shown.
36
apps/frontend/src/server/routes/loader-versions.ts
Normal file
36
apps/frontend/src/server/routes/loader-versions.ts
Normal file
@ -0,0 +1,36 @@
|
||||
const getLoaderVersions = async (loader: string) => {
|
||||
const loaderVersions = await fetch(
|
||||
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
|
||||
);
|
||||
return loaderVersions.json();
|
||||
};
|
||||
|
||||
const getLoaderVersion = async (loader: string, version: string) => {
|
||||
const loaderVersion = await fetch(
|
||||
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
|
||||
);
|
||||
return loaderVersion.json();
|
||||
};
|
||||
|
||||
export default defineEventHandler(async (e) => {
|
||||
const params = new URLSearchParams(e._path?.split("?")[1] ?? "");
|
||||
if (!params.has("loader"))
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Missing loader",
|
||||
}),
|
||||
{ status: 400, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
const loader = params.get("loader");
|
||||
const version = params.get("version");
|
||||
if (version) {
|
||||
const loaderVersion = await getLoaderVersion(loader!, version);
|
||||
return new Response(JSON.stringify(loaderVersion), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
const loaderVersions = await getLoaderVersions(loader!);
|
||||
return new Response(JSON.stringify(loaderVersions), {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user