You've already forked pages
forked from didirus/AstralRinth
Merge commit 'affeec82f0f868d7e8260896584497dd4ea6465f' into feature-clean
This commit is contained in:
@@ -75,7 +75,7 @@ import {
|
||||
RightArrowIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { computed, shallowRef, ref } from "vue";
|
||||
import { renderToString } from "@vue/server-renderer";
|
||||
import { renderToString } from "vue/server-renderer";
|
||||
import { useRouter, useRoute } from "vue-router";
|
||||
import {
|
||||
UiServersIconsCogFolderIcon,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<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',
|
||||
'duration-20 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"
|
||||
@@ -76,25 +76,23 @@
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Sort files"
|
||||
aria-label="Filter view"
|
||||
:options="[
|
||||
{ id: 'normal', action: () => $emit('sort', 'default') },
|
||||
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
||||
{ id: 'created', action: () => $emit('sort', 'created') },
|
||||
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
||||
{ id: 'all', action: () => $emit('filter', 'all') },
|
||||
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
|
||||
]"
|
||||
>
|
||||
<span class="hidden whitespace-pre text-sm font-medium sm:block">
|
||||
{{ sortMethodLabel }}
|
||||
</span>
|
||||
<SortAscendingIcon aria-hidden="true" />
|
||||
<div class="flex items-center gap-1">
|
||||
<FilterIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<span class="hidden text-sm font-medium sm:block">
|
||||
{{ filterLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #normal> Alphabetical </template>
|
||||
<template #modified> Date modified </template>
|
||||
<template #created> Date created </template>
|
||||
<template #filesOnly> Files only </template>
|
||||
<template #foldersOnly> Folders only </template>
|
||||
<template #all>Show all</template>
|
||||
<template #filesOnly>Files only</template>
|
||||
<template #foldersOnly>Folders only</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 w-full text-sm sm:w-48">
|
||||
@@ -148,9 +146,9 @@ import {
|
||||
DropdownIcon,
|
||||
FolderOpenIcon,
|
||||
SearchIcon,
|
||||
SortAscendingIcon,
|
||||
HomeIcon,
|
||||
ChevronRightIcon,
|
||||
FilterIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed } from "vue";
|
||||
@@ -159,15 +157,15 @@ import { useIntersectionObserver } from "@vueuse/core";
|
||||
const props = defineProps<{
|
||||
breadcrumbSegments: string[];
|
||||
searchQuery: string;
|
||||
sortMethod: string;
|
||||
currentFilter: 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;
|
||||
(e: "filter", type: string): void;
|
||||
}>();
|
||||
|
||||
const pyroFilesSentinel = ref<HTMLElement | null>(null);
|
||||
@@ -181,18 +179,14 @@ useIntersectionObserver(
|
||||
{ threshold: [0, 1] },
|
||||
);
|
||||
|
||||
const sortMethodLabel = computed(() => {
|
||||
switch (props.sortMethod) {
|
||||
case "modified":
|
||||
return "Date modified";
|
||||
case "created":
|
||||
return "Date created";
|
||||
const filterLabel = computed(() => {
|
||||
switch (props.currentFilter) {
|
||||
case "filesOnly":
|
||||
return "Files only";
|
||||
case "foldersOnly":
|
||||
return "Folders only";
|
||||
default:
|
||||
return "Alphabetical";
|
||||
return "Show all";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<UiServersPyroLoading v-if="state.isLoading" />
|
||||
<div v-if="state.isLoading" />
|
||||
<div
|
||||
v-if="state.hasError"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="flex w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised px-3 py-2 text-xs font-bold uppercase"
|
||||
class="sticky top-12 z-20 flex h-8 w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised bg-bg px-3 text-xs font-bold uppercase"
|
||||
>
|
||||
<div class="min-w-[48px]"></div>
|
||||
<span class="flex w-full">Name</span>
|
||||
<button
|
||||
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
|
||||
@click="$emit('sort', 'name')"
|
||||
>
|
||||
<span>Name</span>
|
||||
<ChevronUpIcon v-if="sortField === 'name' && !sortDesc" class="h-3 w-3" aria-hidden="true" />
|
||||
<ChevronDownIcon v-if="sortField === 'name' && sortDesc" class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="flex shrink-0 gap-4 text-right md:gap-12">
|
||||
<span class="hidden min-w-[160px] md:block">Created</span>
|
||||
<span class="mr-4 min-w-[160px]">Modified</span>
|
||||
<div class="min-w-[36px]"></div>
|
||||
<button
|
||||
class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'created')"
|
||||
>
|
||||
<span>Created</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'created' && !sortDesc"
|
||||
class="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'created' && sortDesc"
|
||||
class="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="mr-4 hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'modified')"
|
||||
>
|
||||
<span>Modified</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'modified' && !sortDesc"
|
||||
class="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'modified' && sortDesc"
|
||||
class="h-3 w-3"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div class="min-w-[24px]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ChevronDownIcon from "./icons/ChevronDownIcon.vue";
|
||||
import ChevronUpIcon from "./icons/ChevronUpIcon.vue";
|
||||
|
||||
defineProps<{
|
||||
sortField: string;
|
||||
sortDesc: boolean;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "sort", field: string): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
76
apps/frontend/src/components/ui/servers/InstallingTicker.vue
Normal file
76
apps/frontend/src/components/ui/servers/InstallingTicker.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="ticker-container">
|
||||
<div class="ticker-content">
|
||||
<div
|
||||
v-for="(message, index) in msgs"
|
||||
:key="message"
|
||||
class="ticker-item text-xs"
|
||||
:class="{ active: index === currentIndex % msgs.length }"
|
||||
>
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from "vue";
|
||||
|
||||
const msgs = [
|
||||
"Organizing files...",
|
||||
"Downloading mods...",
|
||||
"Configuring server...",
|
||||
"Setting up environment...",
|
||||
"Adding Java...",
|
||||
];
|
||||
|
||||
const currentIndex = ref(0);
|
||||
|
||||
let intervalId: NodeJS.Timeout | null = null;
|
||||
|
||||
onMounted(() => {
|
||||
intervalId = setInterval(() => {
|
||||
currentIndex.value = (currentIndex.value + 1) % msgs.length;
|
||||
}, 3000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ticker-container {
|
||||
height: 20px;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ticker-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ticker-item {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-secondary-text);
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
filter: blur(4px);
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.ticker-item.active {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
@@ -28,6 +29,7 @@
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
@@ -47,6 +49,7 @@
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
@@ -60,6 +63,7 @@ const props = defineProps<{
|
||||
loader: string | null;
|
||||
loader_version: string | null;
|
||||
};
|
||||
isInstalling?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button @click="onSelect">
|
||||
<button :disabled="isInstalling" @click="onSelect">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader ? "Reinstall" : "Install" }}
|
||||
</button>
|
||||
@@ -52,6 +52,7 @@ interface Props {
|
||||
loader: LoaderInfo;
|
||||
currentLoader: string | null;
|
||||
loaderVersion: string | null;
|
||||
isInstalling?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
91
apps/frontend/src/components/ui/servers/LogLine.vue
Normal file
91
apps/frontend/src/components/ui/servers/LogLine.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<div
|
||||
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
|
||||
@mouseenter="checkOverflow"
|
||||
@touchstart="checkOverflow"
|
||||
>
|
||||
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
|
||||
<span v-html="sanitizedLog"></span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isOverflowing"
|
||||
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
|
||||
type="button"
|
||||
@click.stop="$emit('show-full-log', props.log)"
|
||||
>
|
||||
...
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import Convert from "ansi-to-html";
|
||||
import DOMPurify from "dompurify";
|
||||
|
||||
const props = defineProps<{
|
||||
log: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
"show-full-log": [log: string];
|
||||
}>();
|
||||
|
||||
const logContent = ref<HTMLElement | null>(null);
|
||||
const isOverflowing = ref(false);
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (logContent.value && !isOverflowing.value) {
|
||||
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth;
|
||||
}
|
||||
};
|
||||
|
||||
const convert = new Convert({
|
||||
fg: "#FFF",
|
||||
bg: "#000",
|
||||
newline: false,
|
||||
escapeXML: true,
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const sanitizedLog = computed(() =>
|
||||
DOMPurify.sanitize(convert.toHtml(props.log), {
|
||||
ALLOWED_TAGS: ["span"],
|
||||
ALLOWED_ATTR: ["style"],
|
||||
USE_PROFILES: { html: true },
|
||||
}),
|
||||
);
|
||||
|
||||
const preventSelection = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
logContent.value?.addEventListener("mousedown", preventSelection);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
logContent.value?.removeEventListener("mousedown", preventSelection);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parsed-log {
|
||||
background: transparent;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.parsed-log:hover {
|
||||
background: rgba(128, 128, 128, 0.25);
|
||||
transition: 0s;
|
||||
}
|
||||
|
||||
.log-content > span {
|
||||
user-select: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
@@ -1,107 +0,0 @@
|
||||
<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>
|
||||
@@ -1,31 +0,0 @@
|
||||
<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>
|
||||
@@ -1,77 +0,0 @@
|
||||
<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>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="'Changing ' + props.project?.title + ' version'"
|
||||
@hide="onHide"
|
||||
@show="onShow"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">
|
||||
Select the version of {{ props.project?.title || "the modpack" }} you want to install on
|
||||
your server.
|
||||
</p>
|
||||
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
|
||||
Currently installed: {{ props.currentVersion.version_number }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-if="props.versions?.length"
|
||||
v-model="selectedVersion"
|
||||
:options="versionOptions"
|
||||
placeholder="Select version..."
|
||||
name="version"
|
||||
class="w-full max-w-full"
|
||||
/>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<input
|
||||
id="modpack-hard-reset"
|
||||
v-model="hardReset"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
If enabled, existing mods, worlds, and configurations, will be deleted before installing
|
||||
the new modpack version.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
|
||||
<button
|
||||
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<DownloadIcon class="size-4" />
|
||||
{{ isLoading ? "Installing..." : hardReset ? "Erase and install" : "Install" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isLoading" @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { DownloadIcon, XIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
project: any;
|
||||
versions: any[];
|
||||
currentVersion?: any;
|
||||
currentVersionId?: string;
|
||||
serverStatus?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?];
|
||||
}>();
|
||||
|
||||
const modal = ref();
|
||||
const hardReset = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const selectedVersion = ref("");
|
||||
|
||||
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || []);
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (!selectedVersion.value || !props.project?.id) return;
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id;
|
||||
|
||||
await props.server.general?.reinstall(
|
||||
props.server.serverId,
|
||||
false,
|
||||
props.project.id,
|
||||
versionId,
|
||||
undefined,
|
||||
hardReset.value,
|
||||
);
|
||||
|
||||
emit("reinstall");
|
||||
hide();
|
||||
} catch (error) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.serverStatus,
|
||||
(newStatus) => {
|
||||
if (newStatus === "installing") {
|
||||
hide();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const onShow = () => {
|
||||
hardReset.value = false;
|
||||
selectedVersion.value =
|
||||
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? "";
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
hardReset.value = false;
|
||||
selectedVersion.value = "";
|
||||
isLoading.value = false;
|
||||
};
|
||||
|
||||
const show = () => modal.value?.show();
|
||||
const hide = () => modal.value?.hide();
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
</style>
|
||||
281
apps/frontend/src/components/ui/servers/PlatformMrpackModal.vue
Normal file
281
apps/frontend/src/components/ui/servers/PlatformMrpackModal.vue
Normal file
@@ -0,0 +1,281 @@
|
||||
<template>
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<p
|
||||
v-if="isMrpackModalSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
||||
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
>
|
||||
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?
|
||||
</p>
|
||||
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
|
||||
<div class="mx-auto flex flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<UploadIcon class="size-10" />
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 9v6" />
|
||||
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||
</svg>
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||
>
|
||||
<ServerIcon class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
|
||||
<input
|
||||
type="file"
|
||||
accept=".mrpack"
|
||||
class=""
|
||||
:disabled="isLoading"
|
||||
@change="uploadMrpack"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<input
|
||||
id="hard-reset"
|
||||
v-model="hardReset"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="backup-server-mrpack">
|
||||
Backup server
|
||||
</label>
|
||||
<input
|
||||
id="backup-server-mrpack"
|
||||
v-model="backupServer"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>Creates a backup of your server before proceeding.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button :disabled="canInstall" @click="handleReinstall">
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isBackingUp
|
||||
? "Backing up..."
|
||||
: isMrpackModalSecondPhase
|
||||
? "Erase and install"
|
||||
: loadingServerCheck
|
||||
? "Loading..."
|
||||
: isDangerous
|
||||
? "Erase and install"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
@click="
|
||||
if (isMrpackModalSecondPhase) {
|
||||
isMrpackModalSecondPhase = false;
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?];
|
||||
}>();
|
||||
|
||||
const mrpackModal = ref();
|
||||
const isMrpackModalSecondPhase = ref(false);
|
||||
const hardReset = ref(false);
|
||||
const backupServer = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isBackingUp = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const mrpackFile = ref<File | null>(null);
|
||||
|
||||
const isDangerous = computed(() => hardReset.value);
|
||||
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
||||
|
||||
const uploadMrpack = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (!target.files || target.files.length === 0) {
|
||||
return;
|
||||
}
|
||||
mrpackFile.value = target.files[0];
|
||||
};
|
||||
|
||||
const performBackup = async (): Promise<boolean> => {
|
||||
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;
|
||||
const backupId = await props.server.backups?.create(backupName);
|
||||
isBackingUp.value = true;
|
||||
let attempts = 0;
|
||||
while (true) {
|
||||
attempts++;
|
||||
if (attempts > 100) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await props.server.refresh(["backups"]);
|
||||
const backups = await props.server.backups?.data;
|
||||
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||
if (backup && !backup.ongoing) {
|
||||
isBackingUp.value = false;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (hardReset.value && !backupServer.value && !isMrpackModalSecondPhase.value) {
|
||||
isMrpackModalSecondPhase.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (backupServer.value && !(await performBackup())) {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
if (!mrpackFile.value) {
|
||||
throw new Error("No mrpack file selected");
|
||||
}
|
||||
|
||||
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
|
||||
type: mrpackFile.value.type,
|
||||
});
|
||||
|
||||
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
|
||||
|
||||
emit("reinstall", {
|
||||
loader: "mrpack",
|
||||
lVersion: "",
|
||||
mVersion: "",
|
||||
});
|
||||
|
||||
await nextTick();
|
||||
window.scrollTo(0, 0);
|
||||
hide();
|
||||
} catch (error) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onShow = () => {
|
||||
hardReset.value = false;
|
||||
backupServer.value = false;
|
||||
isMrpackModalSecondPhase.value = false;
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
mrpackFile.value = null;
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
onShow();
|
||||
};
|
||||
|
||||
const show = () => mrpackModal.value?.show();
|
||||
const hide = () => mrpackModal.value?.hide();
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,551 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="versionSelectModal"
|
||||
:header="
|
||||
isSecondPhase
|
||||
? 'Confirming reinstallation'
|
||||
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
|
||||
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
|
||||
"
|
||||
@hide="onHide"
|
||||
@show="onShow"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<p
|
||||
v-if="isSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isSecondPhase ? '-12px' : '0',
|
||||
marginTop: isSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
>
|
||||
{{
|
||||
backupServer
|
||||
? "A backup will be created before proceeding with the reinstallation, then all data will be erased from your server. Are you sure you want to continue?"
|
||||
: "This will reinstall your server and erase all data. Are you sure you want to continue?"
|
||||
}}
|
||||
</p>
|
||||
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
|
||||
<div class="mx-auto flex flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<UiServersIconsLoaderIcon class="size-10" :loader="selectedLoader" />
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 9v6" />
|
||||
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||
</svg>
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||
>
|
||||
<ServerIcon class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-sm font-bold text-contrast">Minecraft version</div>
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-model="selectedMCVersion"
|
||||
name="mcVersion"
|
||||
:options="mcVersions"
|
||||
class="w-full max-w-[100%]"
|
||||
placeholder="Select Minecraft version..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
|
||||
class="flex w-full flex-col gap-2 rounded-2xl p-4"
|
||||
:class="{
|
||||
'bg-table-alternateRow':
|
||||
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
|
||||
'bg-highlight-red':
|
||||
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-sm font-bold text-contrast">{{ selectedLoader }} version</div>
|
||||
|
||||
<template v-if="!selectedMCVersion">
|
||||
<div
|
||||
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
|
||||
>
|
||||
Select a Minecraft version to see available versions
|
||||
<DropdownIcon class="absolute right-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isLoading">
|
||||
<div
|
||||
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
|
||||
>
|
||||
<UiServersIconsLoadingIcon class="mr-2 animate-spin" />
|
||||
Loading versions...
|
||||
<DropdownIcon class="absolute right-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selectedLoaderVersions.length > 0">
|
||||
<UiServersTeleportDropdownMenu
|
||||
v-model="selectedLoaderVersion"
|
||||
name="loaderVersion"
|
||||
:options="selectedLoaderVersions"
|
||||
class="w-full max-w-[100%]"
|
||||
:placeholder="
|
||||
selectedLoader.toLowerCase() === 'paper' ||
|
||||
selectedLoader.toLowerCase() === 'purpur'
|
||||
? `Select build number...`
|
||||
: `Select loader version...`
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<input
|
||||
id="hard-reset"
|
||||
v-model="hardReset"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="backup-server">
|
||||
Backup server
|
||||
</label>
|
||||
<input
|
||||
id="backup-server"
|
||||
v-model="backupServer"
|
||||
class="switch stylized-toggle shrink-0"
|
||||
type="checkbox"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
Creates a backup of your server before proceeding with the installation or
|
||||
reinstallation.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button :disabled="canInstall" @click="handleReinstall">
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isBackingUp
|
||||
? "Backing up..."
|
||||
: isLoading
|
||||
? "Installing..."
|
||||
: isSecondPhase
|
||||
? "Erase and install"
|
||||
: hardReset
|
||||
? "Continue"
|
||||
: "Install"
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
@click="
|
||||
if (isSecondPhase) {
|
||||
isSecondPhase = false;
|
||||
} else {
|
||||
hide();
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ isSecondPhase ? "Go back" : "Cancel" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
|
||||
interface LoaderVersion {
|
||||
id: string;
|
||||
stable: boolean;
|
||||
loaders: {
|
||||
id: string;
|
||||
url: string;
|
||||
stable: boolean;
|
||||
}[];
|
||||
}
|
||||
|
||||
type VersionMap = Record<string, LoaderVersion[]>;
|
||||
type VersionCache = Record<string, any>;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
currentLoader: Loaders | undefined;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?];
|
||||
}>();
|
||||
|
||||
const versionSelectModal = ref();
|
||||
const isSecondPhase = ref(false);
|
||||
const hardReset = ref(false);
|
||||
const backupServer = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const isBackingUp = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const serverCheckError = ref("");
|
||||
|
||||
const selectedLoader = ref<Loaders>("Vanilla");
|
||||
const selectedMCVersion = ref("");
|
||||
const selectedLoaderVersion = ref("");
|
||||
|
||||
const paperVersions = ref<Record<string, number[]>>({});
|
||||
const purpurVersions = ref<Record<string, string[]>>({});
|
||||
const loaderVersions = ref<VersionMap>({});
|
||||
const cachedVersions = ref<VersionCache>({});
|
||||
|
||||
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
|
||||
|
||||
const fetchLoaderVersions = async () => {
|
||||
const versions = await Promise.all(
|
||||
versionStrings.map(async (loader) => {
|
||||
const runFetch = async (iterations: number) => {
|
||||
if (iterations > 5) {
|
||||
throw new Error("Failed to fetch loader versions");
|
||||
}
|
||||
try {
|
||||
const res = await $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);
|
||||
return { [loader]: [] };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {});
|
||||
};
|
||||
|
||||
const fetchPaperVersions = async (mcVersion: string) => {
|
||||
try {
|
||||
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`);
|
||||
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a);
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchPurpurVersions = async (mcVersion: string) => {
|
||||
try {
|
||||
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`);
|
||||
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
|
||||
(a: string, b: string) => parseInt(b) - parseInt(a),
|
||||
);
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const selectedLoaderVersions = computed(() => {
|
||||
const loader = selectedLoader.value.toLowerCase();
|
||||
|
||||
if (loader === "paper") {
|
||||
return paperVersions.value[selectedMCVersion.value] || [];
|
||||
}
|
||||
|
||||
if (loader === "purpur") {
|
||||
return purpurVersions.value[selectedMCVersion.value] || [];
|
||||
}
|
||||
|
||||
if (loader === "vanilla") {
|
||||
return [];
|
||||
}
|
||||
|
||||
let apiLoader = loader;
|
||||
if (loader === "neoforge") {
|
||||
apiLoader = "neo";
|
||||
}
|
||||
|
||||
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
|
||||
// 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.value[apiLoader]
|
||||
?.find((x) => x.id === selectedMCVersion.value)
|
||||
?.loaders.map((x) => x.id) || []
|
||||
);
|
||||
});
|
||||
|
||||
watch(selectedLoader, async () => {
|
||||
if (selectedMCVersion.value) {
|
||||
selectedLoaderVersion.value = "";
|
||||
serverCheckError.value = "";
|
||||
|
||||
await checkVersionAvailability(selectedMCVersion.value);
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
selectedLoaderVersions,
|
||||
(newVersions) => {
|
||||
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
|
||||
selectedLoaderVersion.value = String(newVersions[0]); // Ensure string type
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
const checkVersionAvailability = async (version: string) => {
|
||||
if (!version || version.trim().length < 3) return;
|
||||
|
||||
isLoading.value = true;
|
||||
loadingServerCheck.value = true;
|
||||
|
||||
try {
|
||||
const mcRes =
|
||||
cachedVersions.value[version] ||
|
||||
(await $fetch(`/loader-versions?loader=minecraft&version=${version}`));
|
||||
|
||||
cachedVersions.value[version] = mcRes;
|
||||
|
||||
if (!mcRes.downloads?.server) {
|
||||
serverCheckError.value = "We couldn't find a server.jar for this version.";
|
||||
return;
|
||||
}
|
||||
|
||||
const loader = selectedLoader.value.toLowerCase();
|
||||
if (loader === "paper" || loader === "purpur") {
|
||||
const fetchFn = loader === "paper" ? fetchPaperVersions : fetchPurpurVersions;
|
||||
const result = await fetchFn(version);
|
||||
if (!result) {
|
||||
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
serverCheckError.value = "";
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
serverCheckError.value = "Failed to fetch versions.";
|
||||
} finally {
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
watch(selectedMCVersion, checkVersionAvailability);
|
||||
|
||||
onMounted(() => {
|
||||
fetchLoaderVersions();
|
||||
});
|
||||
|
||||
const tags = useTags();
|
||||
const mcVersions = tags.value.gameVersions
|
||||
.filter((x) => x.version_type === "release")
|
||||
.map((x) => x.version)
|
||||
.filter((x) => {
|
||||
const segment = parseInt(x.split(".")[1], 10);
|
||||
return !isNaN(segment) && segment > 2;
|
||||
});
|
||||
|
||||
const isDangerous = computed(() => hardReset.value);
|
||||
const canInstall = computed(() => {
|
||||
const conds =
|
||||
!selectedMCVersion.value ||
|
||||
isLoading.value ||
|
||||
loadingServerCheck.value ||
|
||||
serverCheckError.value.trim().length > 0;
|
||||
|
||||
if (selectedLoader.value.toLowerCase() === "vanilla") {
|
||||
return conds;
|
||||
}
|
||||
|
||||
return conds || !selectedLoaderVersion.value;
|
||||
});
|
||||
|
||||
const performBackup = async (): Promise<boolean> => {
|
||||
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;
|
||||
const backupId = await props.server.backups?.create(backupName);
|
||||
isBackingUp.value = true;
|
||||
let attempts = 0;
|
||||
while (true) {
|
||||
attempts++;
|
||||
if (attempts > 100) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
await props.server.refresh(["backups"]);
|
||||
const backups = await props.server.backups?.data;
|
||||
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||
if (backup && !backup.ongoing) {
|
||||
isBackingUp.value = false;
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Backup Failed",
|
||||
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (hardReset.value && !isSecondPhase.value) {
|
||||
isSecondPhase.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (backupServer.value) {
|
||||
isBackingUp.value = true;
|
||||
if (!(await performBackup())) {
|
||||
isBackingUp.value = false;
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
isBackingUp.value = false;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
|
||||
try {
|
||||
await props.server.general?.reinstall(
|
||||
props.server.serverId,
|
||||
true,
|
||||
selectedLoader.value,
|
||||
selectedMCVersion.value,
|
||||
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
|
||||
hardReset.value,
|
||||
);
|
||||
|
||||
emit("reinstall", {
|
||||
loader: selectedLoader.value,
|
||||
lVersion: selectedLoaderVersion.value,
|
||||
mVersion: selectedMCVersion.value,
|
||||
});
|
||||
|
||||
hide();
|
||||
} catch (error) {
|
||||
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",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onShow = () => {
|
||||
selectedMCVersion.value = props.server.general?.mc_version || "";
|
||||
selectedLoaderVersion.value = "";
|
||||
hardReset.value = false;
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
hardReset.value = false;
|
||||
backupServer.value = false;
|
||||
isSecondPhase.value = false;
|
||||
serverCheckError.value = "";
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
selectedMCVersion.value = "";
|
||||
selectedLoaderVersion.value = "";
|
||||
serverCheckError.value = "";
|
||||
paperVersions.value = {};
|
||||
purpurVersions.value = {};
|
||||
};
|
||||
|
||||
const show = (loader: Loaders) => {
|
||||
selectedLoader.value = loader;
|
||||
selectedMCVersion.value = props.server.general?.mc_version || "";
|
||||
versionSelectModal.value?.show();
|
||||
};
|
||||
const hide = () => versionSelectModal.value?.hide();
|
||||
|
||||
defineExpose({ show, hide });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,167 +0,0 @@
|
||||
<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>
|
||||
@@ -1,94 +0,0 @@
|
||||
<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>
|
||||
@@ -1,60 +0,0 @@
|
||||
<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-divider'"
|
||||
></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>
|
||||
@@ -39,7 +39,7 @@ const props = defineProps<{
|
||||
save: () => void;
|
||||
reset: () => void;
|
||||
isVisible: boolean;
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const saveAndRestart = async () => {
|
||||
|
||||
@@ -8,13 +8,19 @@
|
||||
<NuxtLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
||||
class="min-w-0 truncate text-sm font-semibold"
|
||||
class="flex min-w-0 items-center truncate text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
>
|
||||
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
{{ game[0].toUpperCase() + game.slice(1) }}
|
||||
<span v-if="mcVersion">{{ mcVersion }}</span>
|
||||
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div v-else class="min-w-0 truncate text-sm font-semibold">
|
||||
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
|
||||
<div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
|
||||
{{ game[0].toUpperCase() + game.slice(1) }}
|
||||
<span v-if="mcVersion">{{ mcVersion }}</span>
|
||||
<span v-else class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
<div>
|
||||
<UiServersServerGameLabel
|
||||
v-if="showGameLabel"
|
||||
:game="serverData.game!"
|
||||
:game="serverData.game"
|
||||
:mc-version="serverData.mc_version ?? ''"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<UiServersServerLoaderLabel
|
||||
v-if="showLoaderLabel"
|
||||
:loader="serverData.loader!"
|
||||
:loader="serverData.loader"
|
||||
:loader-version="serverData.loader_version ?? ''"
|
||||
:no-separator="column"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<UiServersServerSubdomainLabel
|
||||
v-if="serverData.net.domain"
|
||||
v-if="serverData.net?.domain"
|
||||
:subdomain="serverData.net.domain"
|
||||
:no-separator="column"
|
||||
:is-link="linked"
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
: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"
|
||||
/>
|
||||
@@ -85,9 +84,12 @@ import type { Project, Server } from "~/types/servers";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
if (props.server_id) {
|
||||
await usePyroServer(props.server_id, ["general"]);
|
||||
}
|
||||
|
||||
const showGameLabel = computed(() => !!props.game);
|
||||
const showLoaderLabel = computed(() => !!props.loader);
|
||||
const showSubdomainLabel = computed(() => !!props.net?.domain);
|
||||
|
||||
let projectData: Ref<Project | null>;
|
||||
if (props.upstream) {
|
||||
@@ -103,39 +105,11 @@ if (props.upstream) {
|
||||
projectData = ref(null);
|
||||
}
|
||||
|
||||
const image = ref<string | undefined>();
|
||||
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (import.meta.server && projectData.value?.icon_url) {
|
||||
await usePyroServer(props.server_id!, ["general"]);
|
||||
}
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="loader"
|
||||
v-tooltip="'Change server loader'"
|
||||
class="flex min-w-0 flex-row items-center gap-4 truncate"
|
||||
>
|
||||
<div 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" />
|
||||
<UiServersIconsLoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
|
||||
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
|
||||
<NuxtLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
||||
class="min-w-0 text-sm font-semibold"
|
||||
class="flex min-w-0 items-center text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
>
|
||||
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||
<span v-if="loader">
|
||||
{{ loader }}
|
||||
<span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||
</span>
|
||||
<span v-else class="flex gap-2">
|
||||
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
<div v-else class="min-w-0 text-sm font-semibold">
|
||||
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||
<span v-if="loader">
|
||||
{{ loader }}
|
||||
<span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||
</span>
|
||||
<span v-else class="flex gap-2">
|
||||
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -25,8 +36,8 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
noSeparator?: boolean;
|
||||
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||
loaderVersion: string;
|
||||
loader?: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||
loaderVersion?: string;
|
||||
isLink?: boolean;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ 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"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const onReinstall = (...args: any[]) => {
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<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>
|
||||
@@ -9,44 +9,34 @@
|
||||
: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 class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
||||
<div class="relative z-10">
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">{{ metric.value }}</h2>
|
||||
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
|
||||
</div>
|
||||
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
|
||||
{{ metric.title }}
|
||||
<WarningIcon
|
||||
v-if="metric.warning"
|
||||
v-tooltip="metric.warning"
|
||||
class="size-5"
|
||||
:style="{ color: 'var(--color-orange)' }"
|
||||
/>
|
||||
</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 class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
|
||||
</div>
|
||||
|
||||
<component :is="metric.icon" class="absolute right-10 top-10 z-10" />
|
||||
<ClientOnly>
|
||||
<VueApexCharts
|
||||
v-if="
|
||||
metric.data.length && !(metric.title === 'Memory usage' && userPreferences.ramAsNumber)
|
||||
"
|
||||
ref="chart"
|
||||
v-if="metric.showGraph"
|
||||
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"
|
||||
:options="getChartOptions(metric.warning)"
|
||||
:series="[{ name: metric.title, data: metric.data }]"
|
||||
class="chart absolute bottom-0 left-0 right-0 w-full opacity-0"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
@@ -57,21 +47,17 @@
|
||||
>
|
||||
<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) }}
|
||||
{{ formatBytes(stats.storage_usage_bytes) }}
|
||||
</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>
|
||||
|
||||
<h3 class="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 { ref, computed, shallowRef } from "vue";
|
||||
import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import type { Stats } from "~/types/servers";
|
||||
@@ -79,252 +65,132 @@ import WarningIcon from "~/assets/images/utils/issues.svg?component";
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id;
|
||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
ramAsNumber: false,
|
||||
autoRestart: false,
|
||||
backupWhileRunning: false,
|
||||
});
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||
const props = defineProps<{ data: Stats }>();
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<Stats>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const stats = shallowRef(props.data.current);
|
||||
|
||||
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"];
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 2) {
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
unit++;
|
||||
}
|
||||
|
||||
return `${Math.round(value * 100) / 100} ${units[unitIndex]}`;
|
||||
return `${Math.round(value * 10) / 10} ${units[unit]}`;
|
||||
};
|
||||
|
||||
const animatedStorageUsage = ref(0);
|
||||
const cpuData = ref<number[]>(Array(20).fill(0));
|
||||
const ramData = ref<number[]>(Array(20).fill(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);
|
||||
const updateGraphData = (arr: number[], newValue: number) => {
|
||||
arr.push(newValue);
|
||||
arr.shift();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
animateValue(0, props.data.current.storage_usage_bytes, 250);
|
||||
const metrics = computed(() => {
|
||||
const ramPercent = Math.min(
|
||||
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
|
||||
100,
|
||||
);
|
||||
const cpuPercent = Math.min(stats.value.cpu_percent, 100);
|
||||
|
||||
updateGraphData(cpuData.value, cpuPercent);
|
||||
updateGraphData(ramData.value, ramPercent);
|
||||
|
||||
return [
|
||||
{
|
||||
title: "CPU usage",
|
||||
value: `${cpuPercent.toFixed(2)}%`,
|
||||
max: "100%",
|
||||
icon: CPUIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: true,
|
||||
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
|
||||
},
|
||||
{
|
||||
title: "Memory usage",
|
||||
value: userPreferences.value.ramAsNumber
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.toFixed(2)}%`,
|
||||
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
|
||||
icon: DBIcon,
|
||||
data: ramData.value,
|
||||
showGraph: true,
|
||||
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const getChartOptions = (hasWarning: string | null) => ({
|
||||
chart: {
|
||||
type: "area",
|
||||
animations: { enabled: false },
|
||||
sparkline: { enabled: true },
|
||||
toolbar: { show: false },
|
||||
padding: {
|
||||
left: -10,
|
||||
right: -10,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
},
|
||||
stroke: { curve: "smooth", width: 3 },
|
||||
fill: {
|
||||
type: "gradient",
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.25,
|
||||
opacityTo: 0.05,
|
||||
stops: [0, 100],
|
||||
},
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
grid: { show: false },
|
||||
xaxis: {
|
||||
labels: { show: false },
|
||||
axisBorder: { show: false },
|
||||
type: "numeric",
|
||||
tickAmount: 20,
|
||||
range: 20,
|
||||
},
|
||||
yaxis: {
|
||||
show: false,
|
||||
min: 0,
|
||||
max: 100,
|
||||
forceNiceScale: false,
|
||||
},
|
||||
colors: [hasWarning ? "var(--color-orange)" : "var(--color-brand)"],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.data.current.storage_usage_bytes,
|
||||
(newValue, oldValue) => {
|
||||
animateValue(oldValue, newValue, 250);
|
||||
() => props.data.current,
|
||||
(newStats) => {
|
||||
stats.value = newStats;
|
||||
},
|
||||
);
|
||||
|
||||
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% {
|
||||
.chart {
|
||||
animation: fadeIn 0.2s ease-out 0.2s forwards;
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
width: calc(100% + 48px) !important;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-animation {
|
||||
opacity: 0;
|
||||
animation: chart-enter-animation 0.5s ease-out forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="subdomain"
|
||||
v-if="subdomain && !isHidden"
|
||||
v-tooltip="'Copy custom URL'"
|
||||
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
||||
>
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LinkIcon } from "@modrinth/assets";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
subdomain: string;
|
||||
noSeparator?: boolean;
|
||||
@@ -29,12 +31,18 @@ const copySubdomain = () => {
|
||||
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
|
||||
addNotification({
|
||||
group: "servers",
|
||||
title: "Subdomain copied",
|
||||
text: "Your subdomain has been copied to your clipboard.",
|
||||
title: "Custom URL copied",
|
||||
text: "Your server's URL has been copied to your clipboard.",
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = computed(() => route.params.id as string);
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
|
||||
hideSubdomainLabel: false,
|
||||
});
|
||||
|
||||
const isHidden = computed(() => userPreferences.value.hideSubdomainLabel);
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<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" />
|
||||
<UiServersIconsTimer class="flex size-5 shrink-0" />
|
||||
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
|
||||
{{ formattedUptime }}
|
||||
</time>
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
<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"
|
||||
<div class="relative inline-block h-9 w-full max-w-80">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
:aria-controls="listboxId"
|
||||
:aria-labelledby="listboxId"
|
||||
class="duration-50 flex h-full w-full cursor-pointer select-none appearance-none items-center justify-between gap-4 rounded-xl border-none bg-button-bg px-4 py-2 shadow-sm !outline-none transition-all ease-in-out"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
@keydown="handleTriggerKeyDown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
@@ -35,27 +30,28 @@
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
:id="listboxId"
|
||||
ref="optionsContainer"
|
||||
data-pyro-dropdown-options
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
:aria-activedescendant="activeDescendant"
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg outline-none"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown.stop="handleDropdownKeyDown"
|
||||
@keydown="handleListboxKeyDown"
|
||||
>
|
||||
<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,
|
||||
@@ -65,32 +61,20 @@
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
||||
:id="`${listboxId}-option-${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"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
|
||||
'rounded-t-xl': item.index === 0 && isRenderingUp,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mouseover="focusedOptionIndex = item.index"
|
||||
@focus="focusedOptionIndex = item.index"
|
||||
@mousemove="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>
|
||||
{{ displayName(item.option) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,13 +124,14 @@ const emit = defineEmits<{
|
||||
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 isOpen = ref(false);
|
||||
const openDropdownCount = ref(0);
|
||||
const listboxId = `pyro-listbox-${Math.random().toString(36).substring(2, 11)}`;
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null);
|
||||
|
||||
const positionStyle = ref<CSSProperties>({
|
||||
position: "fixed",
|
||||
@@ -156,41 +141,6 @@ const positionStyle = ref<CSSProperties>({
|
||||
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(() => {
|
||||
@@ -233,10 +183,10 @@ const triggerClasses = computed(() => ({
|
||||
}));
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!dropdown.value) return;
|
||||
if (!triggerRef.value) return;
|
||||
|
||||
await nextTick();
|
||||
const triggerRect = dropdown.value.getBoundingClientRect();
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const margin = 8;
|
||||
|
||||
@@ -263,20 +213,6 @@ const updatePosition = async () => {
|
||||
};
|
||||
};
|
||||
|
||||
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) {
|
||||
@@ -300,61 +236,6 @@ const handleScroll = (event: Event) => {
|
||||
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);
|
||||
@@ -373,9 +254,6 @@ const focusNextOption = () => {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
|
||||
}
|
||||
scrollToFocused();
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
@@ -386,9 +264,6 @@ const focusPreviousOption = () => {
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
|
||||
}
|
||||
scrollToFocused();
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToFocused = () => {
|
||||
@@ -407,6 +282,119 @@ const scrollToFocused = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const openDropdown = async () => {
|
||||
if (!props.disabled) {
|
||||
closeAllDropdowns();
|
||||
dropdownVisible.value = true;
|
||||
isOpen.value = true;
|
||||
openDropdownCount.value++;
|
||||
document.body.style.overflow = "hidden";
|
||||
await updatePosition();
|
||||
|
||||
nextTick(() => {
|
||||
optionsContainer.value?.focus();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const closeDropdown = () => {
|
||||
if (isOpen.value) {
|
||||
dropdownVisible.value = false;
|
||||
isOpen.value = false;
|
||||
openDropdownCount.value--;
|
||||
if (openDropdownCount.value === 0) {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
focusedOptionIndex.value = null;
|
||||
triggerRef.value?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTriggerKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
if (!dropdownVisible.value) {
|
||||
openDropdown();
|
||||
focusedOptionIndex.value = event.key === "ArrowUp" ? props.options.length - 1 : 0;
|
||||
} else if (event.key === "ArrowDown") {
|
||||
focusNextOption();
|
||||
} else {
|
||||
focusPreviousOption();
|
||||
}
|
||||
break;
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (!dropdownVisible.value) {
|
||||
openDropdown();
|
||||
focusedOptionIndex.value = 0;
|
||||
} else if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "Tab":
|
||||
if (dropdownVisible.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const handleListboxKeyDown = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case "Enter":
|
||||
case " ":
|
||||
event.preventDefault();
|
||||
if (focusedOptionIndex.value !== null) {
|
||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
|
||||
}
|
||||
break;
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
focusNextOption();
|
||||
break;
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
focusPreviousOption();
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
closeDropdown();
|
||||
break;
|
||||
case "Tab":
|
||||
event.preventDefault();
|
||||
break;
|
||||
case "Home":
|
||||
event.preventDefault();
|
||||
focusedOptionIndex.value = 0;
|
||||
scrollToFocused();
|
||||
break;
|
||||
case "End":
|
||||
event.preventDefault();
|
||||
focusedOptionIndex.value = props.options.length - 1;
|
||||
scrollToFocused();
|
||||
break;
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
const char = event.key.toLowerCase();
|
||||
const index = props.options.findIndex((option) =>
|
||||
props.displayName(option).toLowerCase().startsWith(char),
|
||||
);
|
||||
if (index !== -1) {
|
||||
focusedOptionIndex.value = index;
|
||||
scrollToFocused();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("scroll", handleResize, true);
|
||||
@@ -416,6 +404,10 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
window.addEventListener("close-all-dropdowns", closeDropdown);
|
||||
|
||||
if (selectedValue.value) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -427,7 +419,13 @@ onUnmounted(() => {
|
||||
}
|
||||
});
|
||||
window.removeEventListener("close-all-dropdowns", closeDropdown);
|
||||
lastFocusedElement.value = null;
|
||||
|
||||
if (isOpen.value) {
|
||||
openDropdownCount.value--;
|
||||
if (openDropdownCount.value === 0) {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -443,4 +441,19 @@ watch(dropdownVisible, async (newValue) => {
|
||||
scrollTop.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const activeDescendant = computed(() =>
|
||||
focusedOptionIndex.value !== null ? `${listboxId}-option-${focusedOptionIndex.value}` : undefined,
|
||||
);
|
||||
|
||||
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
|
||||
let currentNode: HTMLElement | null = element;
|
||||
while (currentNode) {
|
||||
if (currentNode === triggerRef.value || currentNode === optionsContainer.value) {
|
||||
return true;
|
||||
}
|
||||
currentNode = currentNode.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-chevron-down"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-chevron-up"
|
||||
>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -272,6 +272,10 @@ const constructServerProperties = (properties: any): string => {
|
||||
|
||||
const processImage = async (iconUrl: string | undefined) => {
|
||||
const image = ref<string | null>(null);
|
||||
const sharedImage = useState<string | undefined>(
|
||||
`server-icon-${internalServerRefrence.value.serverId}`,
|
||||
() => undefined,
|
||||
);
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
||||
try {
|
||||
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
||||
@@ -293,6 +297,7 @@ const processImage = async (iconUrl: string | undefined) => {
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
internalServerRefrence.value.general.image = dataURL;
|
||||
image.value = dataURL;
|
||||
sharedImage.value = dataURL; // Store in useState
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
@@ -300,7 +305,7 @@ const processImage = async (iconUrl: string | undefined) => {
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
||||
console.log("[PYROSERVERS] No server icon found");
|
||||
sharedImage.value = undefined;
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
@@ -973,7 +978,6 @@ const modules: any = {
|
||||
suspend: suspendServer,
|
||||
getMotd,
|
||||
setMotd,
|
||||
fetchConfigFile,
|
||||
},
|
||||
content: {
|
||||
get: async (serverId: string) => {
|
||||
@@ -1131,8 +1135,7 @@ type GeneralFunctions = {
|
||||
setMotd: (motd: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* INTERNAL: Gets the config file of a server.
|
||||
* @param fileName - The name of the file.
|
||||
* @deprecated Use fs.downloadFile instead
|
||||
*/
|
||||
fetchConfigFile: (fileName: string) => Promise<any>;
|
||||
};
|
||||
@@ -1389,8 +1392,15 @@ export type Server<T extends avaliableModules> = {
|
||||
/**
|
||||
* Refreshes the included modules of the server
|
||||
* @param refreshModules - The modules to refresh.
|
||||
* @param options - The options to use when refreshing the modules.
|
||||
*/
|
||||
refresh: (refreshModules?: avaliableModules) => Promise<void>;
|
||||
refresh: (
|
||||
refreshModules?: avaliableModules,
|
||||
options?: {
|
||||
preserveConnection?: boolean;
|
||||
preserveInstallState?: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
setError: (error: Error) => void;
|
||||
error?: Error;
|
||||
serverId: string;
|
||||
@@ -1398,33 +1408,53 @@ export type Server<T extends avaliableModules> = {
|
||||
|
||||
export const usePyroServer = async (serverId: string, includedModules: avaliableModules) => {
|
||||
const server: Server<typeof includedModules> = reactive({
|
||||
refresh: async (refreshModules?: avaliableModules) => {
|
||||
refresh: async (
|
||||
refreshModules?: avaliableModules,
|
||||
options?: {
|
||||
preserveConnection?: boolean;
|
||||
preserveInstallState?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (server.general?.status === "installing" && !refreshModules) {
|
||||
return;
|
||||
}
|
||||
|
||||
const modulesToRefresh = refreshModules || includedModules;
|
||||
const promises: Promise<void>[] = [];
|
||||
if (refreshModules) {
|
||||
for (const module of refreshModules) {
|
||||
|
||||
const uniqueModules = [...new Set(modulesToRefresh)];
|
||||
|
||||
for (const module of uniqueModules) {
|
||||
const mods = modules[module];
|
||||
if (mods.get) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const mods = modules[module];
|
||||
if (mods.get) {
|
||||
const data = await mods.get(serverId);
|
||||
server[module] = { ...server[module], ...data };
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
for (const module of includedModules) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const mods = modules[module];
|
||||
if (mods.get) {
|
||||
const data = await mods.get(serverId);
|
||||
server[module] = { ...server[module], ...data };
|
||||
const data = await mods.get(serverId);
|
||||
if (data) {
|
||||
if (module === "general" && options?.preserveConnection) {
|
||||
const updatedData = {
|
||||
...server[module],
|
||||
...data,
|
||||
};
|
||||
if (server[module]?.image) {
|
||||
updatedData.image = server[module].image;
|
||||
}
|
||||
if (server[module]?.motd) {
|
||||
updatedData.motd = server[module].motd;
|
||||
}
|
||||
if (options.preserveInstallState && server[module]?.status === "installing") {
|
||||
updatedData.status = "installing";
|
||||
}
|
||||
server[module] = updatedData;
|
||||
} else {
|
||||
server[module] = { ...server[module], ...data };
|
||||
}
|
||||
}
|
||||
})(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
},
|
||||
setError: (error: Error) => {
|
||||
|
||||
@@ -1,61 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="universal-card">
|
||||
<h2 class="label__title size-card-header">License</h2>
|
||||
<p class="label__description">
|
||||
It is important to choose a proper license for your
|
||||
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
|
||||
list or provide a custom license. You may also provide a custom URL to your chosen license;
|
||||
otherwise, the license text will be displayed. See our
|
||||
<a
|
||||
href="https://blog.modrinth.com/licensing-guide/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-link"
|
||||
>
|
||||
licensing guide
|
||||
</a>
|
||||
for more information.
|
||||
</p>
|
||||
|
||||
<div class="adjacent-input">
|
||||
<label for="license-multiselect">
|
||||
<span class="label__title size-card-header">License</span>
|
||||
<span class="label__title">Select a license</span>
|
||||
<span class="label__description">
|
||||
It is very important to choose a proper license for your
|
||||
{{ $formatProjectType(project.project_type).toLowerCase() }}. You may choose one from
|
||||
our list or provide a custom license. You may also provide a custom URL to your chosen
|
||||
license; otherwise, the license text will be displayed.
|
||||
<span v-if="license && license.friendly === 'Custom'" class="label__subdescription">
|
||||
Enter a valid
|
||||
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
|
||||
SPDX license identifier</a
|
||||
>
|
||||
in the marked area. If your license does not have a SPDX identifier (for example, if
|
||||
you created the license yourself or if the license is Minecraft-specific), simply
|
||||
check the box and enter the name of the license instead.
|
||||
</span>
|
||||
<span class="label__subdescription">
|
||||
Confused? See our
|
||||
<a
|
||||
href="https://blog.modrinth.com/licensing-guide/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-link"
|
||||
>
|
||||
licensing guide</a
|
||||
>
|
||||
for more information.
|
||||
</span>
|
||||
How users are and aren't allowed to use your project.
|
||||
</span>
|
||||
</label>
|
||||
<div class="input-stack">
|
||||
<Multiselect
|
||||
id="license-multiselect"
|
||||
|
||||
<div class="w-1/2">
|
||||
<DropdownSelect
|
||||
v-model="license"
|
||||
name="License selector"
|
||||
:options="builtinLicenses"
|
||||
:display-name="(chosen: BuiltinLicense) => chosen.friendly"
|
||||
placeholder="Select license..."
|
||||
track-by="short"
|
||||
label="friendly"
|
||||
:options="defaultLicenses"
|
||||
:searchable="true"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:class="{
|
||||
'known-error': license?.short === '' && showKnownErrors,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adjacent-input" v-if="license.requiresOnlyOrLater">
|
||||
<label for="or-later-checkbox">
|
||||
<span class="label__title">Later editions</span>
|
||||
<span class="label__description">
|
||||
The license you selected has an "or later" clause. If you check this box, users may use
|
||||
your project under later editions of the license.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Checkbox
|
||||
id="or-later-checkbox"
|
||||
v-model="allowOrLater"
|
||||
:disabled="!hasPermission"
|
||||
description="Allow later editions"
|
||||
class="w-1/2"
|
||||
>
|
||||
Allow later editions
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div class="adjacent-input">
|
||||
<label for="license-url">
|
||||
<span class="label__title">License URL</span>
|
||||
<span class="label__description" v-if="license?.friendly !== 'Custom'">
|
||||
The web location of the full license text. If you don't provide a link, the license text
|
||||
will be displayed instead.
|
||||
</span>
|
||||
<span class="label__description" v-else>
|
||||
The web location of the full license text. You have to provide a link since this is a
|
||||
custom license.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div class="w-1/2">
|
||||
<input
|
||||
id="license-url"
|
||||
v-model="licenseUrl"
|
||||
type="url"
|
||||
maxlength="2048"
|
||||
:placeholder="license?.friendly !== 'Custom' ? `License URL (optional)` : `License URL`"
|
||||
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adjacent-input" v-if="license?.friendly === 'Custom'">
|
||||
<label for="license-spdx" v-if="!nonSpdxLicense">
|
||||
<span class="label__title">SPDX identifier</span>
|
||||
<span class="label__description">
|
||||
If your license does not have an offical
|
||||
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
|
||||
SPDX license identifier</a
|
||||
>, check the box and enter the name of the license instead.
|
||||
</span>
|
||||
</label>
|
||||
<label for="license-name" v-else>
|
||||
<span class="label__title">License name</span>
|
||||
<span class="label__description"
|
||||
>The full name of the license. If the license has a SPDX identifier, please uncheck the
|
||||
checkbox and use the identifier instead.</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div class="input-stack w-1/2">
|
||||
<input
|
||||
v-if="!nonSpdxLicense"
|
||||
v-model="license.short"
|
||||
id="license-spdx"
|
||||
class="w-full"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
placeholder="SPDX identifier"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="license?.requiresOnlyOrLater"
|
||||
v-model="allowOrLater"
|
||||
<input
|
||||
v-else
|
||||
v-model="license.short"
|
||||
id="license-name"
|
||||
class="w-full"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
placeholder="License name"
|
||||
:disabled="!hasPermission"
|
||||
description="Allow later editions of this license"
|
||||
>
|
||||
Allow later editions of this license
|
||||
</Checkbox>
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-if="license?.friendly === 'Custom'"
|
||||
v-model="nonSpdxLicense"
|
||||
@@ -64,31 +131,18 @@
|
||||
>
|
||||
License does not have a SPDX identifier
|
||||
</Checkbox>
|
||||
<input
|
||||
v-if="license?.friendly === 'Custom'"
|
||||
v-model="license.short"
|
||||
type="text"
|
||||
maxlength="2048"
|
||||
:placeholder="nonSpdxLicense ? 'License name' : 'SPDX identifier'"
|
||||
:class="{
|
||||
'known-error': license.short === '' && showKnownErrors,
|
||||
}"
|
||||
:disabled="!hasPermission"
|
||||
/>
|
||||
<input
|
||||
v-model="licenseUrl"
|
||||
type="url"
|
||||
maxlength="2048"
|
||||
placeholder="License URL (optional)"
|
||||
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-stack">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges || license === null"
|
||||
:disabled="
|
||||
!hasChanges ||
|
||||
!hasPermission ||
|
||||
(license.friendly === 'Custom' && (license.short === '' || licenseUrl === ''))
|
||||
"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
@@ -99,199 +153,109 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Multiselect from "vue-multiselect";
|
||||
import Checkbox from "~/components/ui/Checkbox";
|
||||
<script setup lang="ts">
|
||||
import { Checkbox, DropdownSelect } from "@modrinth/ui";
|
||||
import {
|
||||
TeamMemberPermission,
|
||||
builtinLicenses,
|
||||
formatProjectType,
|
||||
type BuiltinLicense,
|
||||
type Project,
|
||||
type TeamMember,
|
||||
} from "@modrinth/utils";
|
||||
import { computed, ref, type Ref } from "vue";
|
||||
import SaveIcon from "~/assets/images/utils/save.svg?component";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Multiselect,
|
||||
Checkbox,
|
||||
SaveIcon,
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
patchProject: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {
|
||||
this.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: "Patch project function not found",
|
||||
type: "error",
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
licenseUrl: "",
|
||||
license: { friendly: "", short: "", requiresOnlyOrLater: false },
|
||||
allowOrLater: this.project.license.id.includes("-or-later"),
|
||||
nonSpdxLicense: this.project.license.id.includes("LicenseRef-"),
|
||||
showKnownErrors: false,
|
||||
};
|
||||
},
|
||||
async setup(props) {
|
||||
const defaultLicenses = shallowRef([
|
||||
{ friendly: "Custom", short: "" },
|
||||
{
|
||||
friendly: "All Rights Reserved/No License",
|
||||
short: "All-Rights-Reserved",
|
||||
},
|
||||
{ friendly: "Apache License 2.0", short: "Apache-2.0" },
|
||||
{
|
||||
friendly: 'BSD 2-Clause "Simplified" License',
|
||||
short: "BSD-2-Clause",
|
||||
},
|
||||
{
|
||||
friendly: 'BSD 3-Clause "New" or "Revised" License',
|
||||
short: "BSD-3-Clause",
|
||||
},
|
||||
{
|
||||
friendly: "CC Zero (Public Domain equivalent)",
|
||||
short: "CC0-1.0",
|
||||
},
|
||||
{ friendly: "CC-BY 4.0", short: "CC-BY-4.0" },
|
||||
{
|
||||
friendly: "CC-BY-SA 4.0",
|
||||
short: "CC-BY-SA-4.0",
|
||||
},
|
||||
{
|
||||
friendly: "CC-BY-NC 4.0",
|
||||
short: "CC-BY-NC-4.0",
|
||||
},
|
||||
{
|
||||
friendly: "CC-BY-NC-SA 4.0",
|
||||
short: "CC-BY-NC-SA-4.0",
|
||||
},
|
||||
{
|
||||
friendly: "CC-BY-ND 4.0",
|
||||
short: "CC-BY-ND-4.0",
|
||||
},
|
||||
{
|
||||
friendly: "CC-BY-NC-ND 4.0",
|
||||
short: "CC-BY-NC-ND-4.0",
|
||||
},
|
||||
{
|
||||
friendly: "GNU Affero General Public License v3",
|
||||
short: "AGPL-3.0",
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{
|
||||
friendly: "GNU Lesser General Public License v2.1",
|
||||
short: "LGPL-2.1",
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{
|
||||
friendly: "GNU Lesser General Public License v3",
|
||||
short: "LGPL-3.0",
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{
|
||||
friendly: "GNU General Public License v2",
|
||||
short: "GPL-2.0",
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{
|
||||
friendly: "GNU General Public License v3",
|
||||
short: "GPL-3.0",
|
||||
requiresOnlyOrLater: true,
|
||||
},
|
||||
{ friendly: "ISC License", short: "ISC" },
|
||||
{ friendly: "MIT License", short: "MIT" },
|
||||
{ friendly: "Mozilla Public License 2.0", short: "MPL-2.0" },
|
||||
{ friendly: "zlib License", short: "Zlib" },
|
||||
]);
|
||||
const props = defineProps<{
|
||||
project: Project;
|
||||
currentMember: TeamMember | undefined;
|
||||
patchProject: (payload: Object, quiet?: boolean) => Object;
|
||||
}>();
|
||||
|
||||
const licenseUrl = ref(props.project.license.url);
|
||||
|
||||
const licenseId = props.project.license.id;
|
||||
const trimmedLicenseId = licenseId
|
||||
.replaceAll("-only", "")
|
||||
.replaceAll("-or-later", "")
|
||||
.replaceAll("LicenseRef-", "");
|
||||
|
||||
const license = ref(
|
||||
defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
|
||||
friendly: "Custom",
|
||||
short: licenseId.replaceAll("LicenseRef-", ""),
|
||||
},
|
||||
);
|
||||
|
||||
if (licenseId === "LicenseRef-Unknown") {
|
||||
license.value = {
|
||||
friendly: "Unknown",
|
||||
short: licenseId.replaceAll("LicenseRef-", ""),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
defaultLicenses,
|
||||
licenseUrl,
|
||||
license,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasPermission() {
|
||||
const EDIT_DETAILS = 1 << 2;
|
||||
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS;
|
||||
},
|
||||
licenseId() {
|
||||
let id = "";
|
||||
if (this.license === null) return id;
|
||||
if (
|
||||
(this.nonSpdxLicense && this.license.friendly === "Custom") ||
|
||||
this.license.short === "All-Rights-Reserved" ||
|
||||
this.license.short === "Unknown"
|
||||
) {
|
||||
id += "LicenseRef-";
|
||||
}
|
||||
id += this.license.short;
|
||||
if (this.license.requiresOnlyOrLater) {
|
||||
id += this.allowOrLater ? "-or-later" : "-only";
|
||||
}
|
||||
if (this.nonSpdxLicense && this.license.friendly === "Custom") {
|
||||
id = id.replaceAll(" ", "-");
|
||||
}
|
||||
return id;
|
||||
},
|
||||
patchData() {
|
||||
const data = {};
|
||||
|
||||
if (this.licenseId !== this.project.license.id) {
|
||||
data.license_id = this.licenseId;
|
||||
data.license_url = this.licenseUrl ? this.licenseUrl : null;
|
||||
} else if (this.licenseUrl !== this.project.license.url) {
|
||||
data.license_url = this.licenseUrl ? this.licenseUrl : null;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
hasChanges() {
|
||||
return Object.keys(this.patchData).length > 0;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
saveChanges() {
|
||||
if (this.hasChanges) {
|
||||
this.patchProject(this.patchData);
|
||||
}
|
||||
},
|
||||
},
|
||||
const licenseUrl = ref(props.project.license.url);
|
||||
const license: Ref<{
|
||||
friendly: string;
|
||||
short: string;
|
||||
requiresOnlyOrLater?: boolean;
|
||||
}> = ref({
|
||||
friendly: "",
|
||||
short: "",
|
||||
requiresOnlyOrLater: false,
|
||||
});
|
||||
|
||||
const allowOrLater = ref(props.project.license.id.includes("-or-later"));
|
||||
const nonSpdxLicense = ref(props.project.license.id.includes("LicenseRef-"));
|
||||
|
||||
const oldLicenseId = props.project.license.id;
|
||||
const trimmedLicenseId = oldLicenseId
|
||||
.replaceAll("-only", "")
|
||||
.replaceAll("-or-later", "")
|
||||
.replaceAll("LicenseRef-", "");
|
||||
|
||||
license.value = builtinLicenses.find((x) => x.short === trimmedLicenseId) ?? {
|
||||
friendly: "Custom",
|
||||
short: oldLicenseId.replaceAll("LicenseRef-", ""),
|
||||
requiresOnlyOrLater: oldLicenseId.includes("-or-later"),
|
||||
};
|
||||
|
||||
if (oldLicenseId === "LicenseRef-Unknown") {
|
||||
// Mark it as not having a license, forcing the user to select one
|
||||
license.value = {
|
||||
friendly: "",
|
||||
short: oldLicenseId.replaceAll("LicenseRef-", ""),
|
||||
requiresOnlyOrLater: false,
|
||||
};
|
||||
}
|
||||
|
||||
const hasPermission = computed(() => {
|
||||
return (props.currentMember?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS;
|
||||
});
|
||||
|
||||
const licenseId = computed(() => {
|
||||
let id = "";
|
||||
|
||||
if (
|
||||
(nonSpdxLicense && license.value.friendly === "Custom") ||
|
||||
license.value.short === "All-Rights-Reserved" ||
|
||||
license.value.short === "Unknown"
|
||||
) {
|
||||
id += "LicenseRef-";
|
||||
}
|
||||
|
||||
id += license.value.short;
|
||||
if (license.value.requiresOnlyOrLater) {
|
||||
id += allowOrLater.value ? "-or-later" : "-only";
|
||||
}
|
||||
|
||||
if (nonSpdxLicense && license.value.friendly === "Custom") {
|
||||
id = id.replaceAll(" ", "-");
|
||||
}
|
||||
|
||||
return id;
|
||||
});
|
||||
|
||||
const patchRequestPayload = computed(() => {
|
||||
const payload: {
|
||||
license_id?: string;
|
||||
license_url?: string | null; // null = remove url
|
||||
} = {};
|
||||
|
||||
if (licenseId.value !== props.project.license.id) {
|
||||
payload.license_id = licenseId.value;
|
||||
}
|
||||
|
||||
if (licenseUrl.value !== props.project.license.url) {
|
||||
payload.license_url = licenseUrl.value ? licenseUrl.value : null;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(patchRequestPayload.value).length > 0;
|
||||
});
|
||||
|
||||
function saveChanges() {
|
||||
props.patchProject(patchRequestPayload.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
<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-red p-4">
|
||||
<PanelErrorIcon class="size-12 text-red" />
|
||||
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||
</div>
|
||||
@@ -343,7 +343,7 @@
|
||||
<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"
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
|
||||
>
|
||||
<UiServersPanelSpinner />
|
||||
Hang on, we're reconnecting to your server.
|
||||
@@ -352,10 +352,16 @@
|
||||
<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"
|
||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
|
||||
>
|
||||
<UiServersPanelSpinner />
|
||||
We're preparing your server, this may take a few minutes.
|
||||
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-bold"> We're preparing your server! </span>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtPage
|
||||
@@ -392,10 +398,9 @@ import {
|
||||
import DOMPurify from "dompurify";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
||||
import { reloadNuxtApp } from "#app";
|
||||
import { reloadNuxtApp, navigateTo } from "#app";
|
||||
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isReconnecting = ref(false);
|
||||
@@ -662,22 +667,49 @@ const newMCVersion = ref<string | null>(null);
|
||||
|
||||
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||
switch (data.result) {
|
||||
case "ok":
|
||||
case "ok": {
|
||||
if (!serverData.value) break;
|
||||
serverData.value.status = "available";
|
||||
|
||||
if (!isFirstMount.value) {
|
||||
await server.refresh();
|
||||
}
|
||||
|
||||
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;
|
||||
stopPolling();
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
let hasValidData = false;
|
||||
|
||||
while (!hasValidData && attempts < maxAttempts) {
|
||||
attempts++;
|
||||
|
||||
await server.refresh(["general"], {
|
||||
preserveConnection: true,
|
||||
preserveInstallState: true,
|
||||
});
|
||||
|
||||
if (serverData.value?.loader && serverData.value?.mc_version) {
|
||||
hasValidData = true;
|
||||
serverData.value.status = "available";
|
||||
await server.refresh(["content", "startup"]);
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!hasValidData) {
|
||||
console.error("Failed to get valid server data after installation");
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Error refreshing data after installation:", err);
|
||||
}
|
||||
|
||||
newLoader.value = null;
|
||||
newLoaderVersion.value = null;
|
||||
newMCVersion.value = null;
|
||||
error.value = null;
|
||||
break;
|
||||
}
|
||||
case "err": {
|
||||
console.log("failed to install");
|
||||
console.log(data);
|
||||
@@ -708,20 +740,9 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||
|
||||
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 loadeconsole
|
||||
// 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;
|
||||
}
|
||||
@@ -732,15 +753,9 @@ const onReinstall = (potentialArgs: any) => {
|
||||
newMCVersion.value = potentialArgs.mVersion;
|
||||
}
|
||||
|
||||
if (!isFirstMount.value) {
|
||||
server.refresh();
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
errorTitle.value = "Error";
|
||||
errorMessage.value = "An unexpected error occurred.";
|
||||
|
||||
console.log(serverData.value);
|
||||
};
|
||||
|
||||
const updateStats = (currentStats: Stats["current"]) => {
|
||||
@@ -762,7 +777,6 @@ const updatePowerState = (
|
||||
state: ServerState,
|
||||
details?: { oom_killed?: boolean; exit_code?: number },
|
||||
) => {
|
||||
console.log("Power state:", state, details);
|
||||
serverPowerState.value = state;
|
||||
|
||||
if (state === "crashed") {
|
||||
@@ -959,17 +973,15 @@ onUnmounted(() => {
|
||||
|
||||
watch(
|
||||
() => serverData.value?.status,
|
||||
(newStatus) => {
|
||||
(newStatus, oldStatus) => {
|
||||
if (isFirstMount.value) {
|
||||
isFirstMount.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStatus === "installing") {
|
||||
if (newStatus === "installing" && oldStatus !== "installing") {
|
||||
countdown.value = 15;
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
server.refresh();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -996,7 +1008,16 @@ definePageMeta({
|
||||
}
|
||||
|
||||
.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;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
display: block;
|
||||
height: 9rem;
|
||||
width: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(1rem);
|
||||
content: "";
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
@@ -1005,4 +1026,10 @@ definePageMeta({
|
||||
),
|
||||
var(--server-bg-image);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.mobile-blurred-servericon::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,10 @@
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled type="standard">
|
||||
<button @click="showbackupSettingsModal">
|
||||
<button
|
||||
:disabled="server.general?.status === 'installing'"
|
||||
@click="showbackupSettingsModal"
|
||||
>
|
||||
<SettingsIcon class="h-5 w-5" />
|
||||
Auto backups
|
||||
</button>
|
||||
@@ -63,13 +66,16 @@
|
||||
v-tooltip="
|
||||
isServerRunning && !userPreferences.backupWhileRunning
|
||||
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
|
||||
: ''
|
||||
: server.general?.status === 'installing'
|
||||
? 'Cannot create backups while server is being installed'
|
||||
: ''
|
||||
"
|
||||
class="w-full sm:w-fit"
|
||||
:disabled="
|
||||
(isServerRunning && !userPreferences.backupWhileRunning) ||
|
||||
data.used_backup_quota >= data.backup_quota ||
|
||||
backups.some((backup) => backup.ongoing)
|
||||
backups.some((backup) => backup.ongoing) ||
|
||||
server.general?.status === 'installing'
|
||||
"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
@@ -90,108 +96,111 @@
|
||||
automatically refresh when the backup is complete.
|
||||
</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"
|
||||
/>
|
||||
<LockIcon v-else-if="backup.locked" class="size-8" />
|
||||
<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 class="flex w-full flex-col gap-2">
|
||||
<li
|
||||
v-for="(backup, index) in backups"
|
||||
:key="backup.id"
|
||||
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-2 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"
|
||||
/>
|
||||
<LockIcon v-else-if="backup.locked" class="size-8" />
|
||||
<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 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
|
||||
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-1 text-xs">
|
||||
<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 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>
|
||||
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
direction="left"
|
||||
position="bottom"
|
||||
class="bg-transparent"
|
||||
:disabled="backups.some((b) => b.ongoing)"
|
||||
: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: 'lock',
|
||||
action: () => {
|
||||
if (backup.locked) {
|
||||
unlockBackup(backup.id);
|
||||
} else {
|
||||
lockBackup(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 v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
|
||||
<template v-else #lock> <LockIcon /> Lock </template>
|
||||
<template #download> <DownloadIcon /> Download </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</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: 'lock',
|
||||
action: () => {
|
||||
if (backup.locked) {
|
||||
unlockBackup(backup.id);
|
||||
} else {
|
||||
lockBackup(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 v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
|
||||
<template v-else #lock> <LockIcon /> Lock </template>
|
||||
<template #download> <DownloadIcon /> Download </template>
|
||||
<template #delete> <TrashIcon /> Delete </template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
|
||||
@@ -34,13 +34,18 @@
|
||||
<UiServersFilesBrowseNavbar
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
:search-query="searchQuery"
|
||||
:sort-method="sortMethod"
|
||||
:current-filter="viewFilter"
|
||||
@navigate="navigateToSegment"
|
||||
@sort="sortFiles"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@filter="handleFilter"
|
||||
@update:search-query="searchQuery = $event"
|
||||
/>
|
||||
<UiServersFilesLabelBar
|
||||
:sort-field="sortMethod"
|
||||
:sort-desc="sortDesc"
|
||||
@sort="handleSort"
|
||||
/>
|
||||
<FilesUploadDropdown
|
||||
v-if="props.server.fs"
|
||||
ref="uploadDropdownRef"
|
||||
@@ -94,7 +99,6 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
||||
<UiServersFilesLabelBar />
|
||||
<UiServersFileVirtualList
|
||||
:items="filteredItems"
|
||||
@delete="showDeleteModal"
|
||||
@@ -196,7 +200,8 @@ const operationHistory = ref<Operation[]>([]);
|
||||
const redoStack = ref<Operation[]>([]);
|
||||
|
||||
const searchQuery = ref("");
|
||||
const sortMethod = ref("default");
|
||||
const sortMethod = ref("name");
|
||||
const sortDesc = ref(false);
|
||||
|
||||
const maxResults = 100;
|
||||
const currentPage = ref(1);
|
||||
@@ -227,6 +232,14 @@ const uploadDropdownRef = ref();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
const viewFilter = ref("all");
|
||||
|
||||
const handleFilter = (type: string) => {
|
||||
viewFilter.value = type;
|
||||
sortMethod.value = "name";
|
||||
sortDesc.value = false;
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
|
||||
});
|
||||
@@ -567,6 +580,51 @@ const applyDefaultSort = (items: DirectoryItem[]) => {
|
||||
});
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (sortMethod.value === field) {
|
||||
sortDesc.value = !sortDesc.value;
|
||||
} else {
|
||||
sortMethod.value = field;
|
||||
sortDesc.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const applySort = (items: DirectoryItem[]) => {
|
||||
let result = [...items];
|
||||
|
||||
switch (viewFilter.value) {
|
||||
case "filesOnly":
|
||||
result = result.filter((item) => item.type !== "directory");
|
||||
break;
|
||||
case "foldersOnly":
|
||||
result = result.filter((item) => item.type === "directory");
|
||||
break;
|
||||
}
|
||||
|
||||
const compareItems = (a: DirectoryItem, b: DirectoryItem) => {
|
||||
if (viewFilter.value === "all") {
|
||||
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||
}
|
||||
|
||||
switch (sortMethod.value) {
|
||||
case "modified":
|
||||
return sortDesc.value
|
||||
? new Date(a.modified).getTime() - new Date(b.modified).getTime()
|
||||
: new Date(b.modified).getTime() - new Date(a.modified).getTime();
|
||||
case "created":
|
||||
return sortDesc.value
|
||||
? new Date(a.created).getTime() - new Date(b.created).getTime()
|
||||
: new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||
default:
|
||||
return sortDesc.value ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name);
|
||||
}
|
||||
};
|
||||
|
||||
result.sort(compareItems);
|
||||
return result;
|
||||
};
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
let result = [...items.value];
|
||||
|
||||
@@ -575,24 +633,7 @@ const filteredItems = computed(() => {
|
||||
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 "created":
|
||||
result.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).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;
|
||||
return applySort(result);
|
||||
});
|
||||
|
||||
const { reset } = useInfiniteScroll(
|
||||
@@ -656,10 +697,6 @@ const onAnywhereClicked = (e: MouseEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const sortFiles = (method: string) => {
|
||||
sortMethod.value = method;
|
||||
};
|
||||
|
||||
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"];
|
||||
|
||||
const editFile = async (item: { name: string; type: string; path: string }) => {
|
||||
@@ -717,7 +754,9 @@ watch(
|
||||
async (newQuery) => {
|
||||
currentPage.value = 1;
|
||||
searchQuery.value = "";
|
||||
sortMethod.value = "default";
|
||||
viewFilter.value = "all";
|
||||
sortMethod.value = "name";
|
||||
sortDesc.value = false;
|
||||
|
||||
currentPath.value = Array.isArray(newQuery.path)
|
||||
? newQuery.path.join("")
|
||||
|
||||
@@ -80,14 +80,19 @@
|
||||
<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"
|
||||
class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
|
||||
>
|
||||
<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>
|
||||
<!-- <div class="flex flex-row items-center gap-2 text-sm font-medium">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
Click and drag to select lines, then CMD+C to copy
|
||||
</div> -->
|
||||
<UiServersPanelTerminal :full-screen="fullScreen">
|
||||
<div class="relative w-full px-4 pt-4">
|
||||
<ul
|
||||
@@ -164,7 +169,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiServersPanelOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
|
||||
<div v-else-if="!isConnected && !isWsAuthIncorrect" />
|
||||
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
|
||||
<h2>Could not connect to the server.</h2>
|
||||
<p>
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiServersPyroLoading v-else />
|
||||
<div v-else />
|
||||
<UiServersSaveBanner
|
||||
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
|
||||
:server="props.server"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ data?.sftp_host }}
|
||||
</span>
|
||||
|
||||
<span class="text-xs uppercase text-secondary">server address</span>
|
||||
<span class="text-xs text-secondary">Server Address</span>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
@@ -41,9 +41,9 @@
|
||||
</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"
|
||||
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex h-8 items-center justify-between">
|
||||
<span class="font-bold text-contrast">
|
||||
{{ data?.sftp_username }}
|
||||
</span>
|
||||
@@ -57,12 +57,12 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-xs uppercase text-secondary">username</span>
|
||||
<span class="text-xs text-secondary">Username</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex h-8 items-center justify-between">
|
||||
<span class="font-bold text-contrast">
|
||||
{{
|
||||
showPassword ? data?.sftp_password : "*".repeat(data?.sftp_password?.length ?? 0)
|
||||
@@ -89,7 +89,7 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs uppercase text-secondary">password</span>
|
||||
<span class="text-xs text-secondary">Password</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
||||
</form>
|
||||
</NewModal>
|
||||
|
||||
<NewModal ref="editAllocationModal" header="Edit Allocation">
|
||||
<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
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!newAllocationName" type="submit">
|
||||
<SaveIcon /> Update Allocation
|
||||
<SaveIcon /> Update allocation
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
@@ -94,7 +94,7 @@
|
||||
/>
|
||||
|
||||
<div
|
||||
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow p-4"
|
||||
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow px-4 py-2"
|
||||
>
|
||||
<table
|
||||
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
|
||||
@@ -108,7 +108,7 @@
|
||||
>
|
||||
{{ record.type }}
|
||||
</span>
|
||||
<span class="text-xs uppercase text-secondary">type</span>
|
||||
<span class="text-xs text-secondary">Type</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-2/6 py-3 md:w-1/3">
|
||||
@@ -118,7 +118,7 @@
|
||||
>
|
||||
{{ record.name }}
|
||||
</span>
|
||||
<span class="text-xs uppercase text-secondary">name</span>
|
||||
<span class="text-xs text-secondary">Name</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
|
||||
@@ -128,7 +128,7 @@
|
||||
>
|
||||
{{ record.content }}
|
||||
</span>
|
||||
<span class="text-xs uppercase text-secondary">content</span>
|
||||
<span class="text-xs text-secondary">Content</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -190,7 +190,7 @@
|
||||
<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>
|
||||
<span class="hidden text-xs text-secondary sm:block">Name</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span
|
||||
@@ -198,7 +198,7 @@
|
||||
>
|
||||
{{ allocation.port }}
|
||||
</span>
|
||||
<span class="hidden text-xs uppercase text-secondary sm:block">port</span>
|
||||
<span class="hidden text-xs text-secondary sm:block">Port</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -59,6 +59,11 @@ const preferences = {
|
||||
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
|
||||
implemented: true,
|
||||
},
|
||||
hideSubdomainLabel: {
|
||||
displayName: "Hide subdomain label",
|
||||
description: "When enabled, the subdomain label will be hidden from the server header.",
|
||||
implemented: true,
|
||||
},
|
||||
autoRestart: {
|
||||
displayName: "Auto restart",
|
||||
description: "When enabled, your server will automatically restart if it crashes.",
|
||||
@@ -84,6 +89,7 @@ type UserPreferences = {
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
ramAsNumber: false,
|
||||
hideSubdomainLabel: false,
|
||||
autoRestart: false,
|
||||
powerDontAskAgain: false,
|
||||
backupWhileRunning: false,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
>
|
||||
Minecraft Wiki
|
||||
</NuxtLink>
|
||||
has more detailed information about each property.
|
||||
has more detailed information.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 rounded-2xl bg-table-alternateRow p-4">
|
||||
@@ -134,10 +134,29 @@ 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 { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
|
||||
const rawProps = await props.server.fs?.downloadFile("server.properties");
|
||||
if (!rawProps) return null;
|
||||
|
||||
const properties: Record<string, any> = {};
|
||||
const lines = rawProps.split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("#") || !line.includes("=")) continue;
|
||||
const [key, ...valueParts] = line.split("=");
|
||||
let value = valueParts.join("=");
|
||||
|
||||
if (value.toLowerCase() === "true" || value.toLowerCase() === "false") {
|
||||
value = value.toLowerCase() === "true";
|
||||
} else if (!isNaN(value as any) && value !== "") {
|
||||
value = Number(value);
|
||||
}
|
||||
|
||||
properties[key.trim()] = value;
|
||||
}
|
||||
|
||||
return properties;
|
||||
});
|
||||
|
||||
const liveProperties = ref<Record<string, any>>({});
|
||||
const originalProperties = ref<Record<string, any>>({});
|
||||
|
||||
@@ -35,10 +35,9 @@
|
||||
<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 }}. By default, only the Java versions compatible with this
|
||||
version of Minecraft are shown. Some mods or modpacks may require a specific Java
|
||||
version.
|
||||
The version of Java that your server will run on. By default, only the Java versions
|
||||
compatible with this version of Minecraft are shown. Some mods may require a
|
||||
different Java version to work properly.
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -4,44 +4,91 @@
|
||||
class="experimental-styles-within relative mx-auto flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||
>
|
||||
<div
|
||||
v-if="serverList.length > 0 || isPollingForNewServers"
|
||||
class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row"
|
||||
v-if="hasError || fetchError"
|
||||
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
|
||||
>
|
||||
<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 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-blue p-4">
|
||||
<HammerIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
|
||||
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
|
||||
<li>
|
||||
Our systems automatically alert our team when there's an issue. We are already working
|
||||
on getting them back online.
|
||||
</li>
|
||||
<li>
|
||||
If you recently purchased your Modrinth Server, it is currently in a queue and will
|
||||
appear here as soon as it's ready. <br />
|
||||
<span class="font-medium text-contrast"
|
||||
>Do not attempt to purchase a new server.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
If you require personalized support regarding the status of your server, please
|
||||
contact Modrinth Support.
|
||||
</li>
|
||||
|
||||
<li v-if="fetchError" class="text-red">
|
||||
<p>Error details:</p>
|
||||
<UiCopyCode
|
||||
:text="(fetchError as PyroFetchError).message || 'Unknown error'"
|
||||
:copyable="false"
|
||||
:selectable="false"
|
||||
:language="'json'"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</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 size="large" type="standard" color="brand">
|
||||
<a class="mt-6 !w-full" href="https://support.modrinth.com">Contact Modrinth Support</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" @click="() => reloadNuxtApp()">
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<LazyUiServersServerManageEmptyState
|
||||
v-if="serverList.length === 0 && !isPollingForNewServers"
|
||||
v-else-if="serverList.length === 0 && !isPollingForNewServers && !hasError"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div 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>
|
||||
|
||||
<ul v-if="filteredData.length > 0" class="m-0 flex flex-col gap-4 p-0">
|
||||
<UiServersServerListing
|
||||
v-for="server in filteredData"
|
||||
@@ -68,10 +115,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||
import Fuse from "fuse.js";
|
||||
import { PlusIcon, SearchIcon } from "@modrinth/assets";
|
||||
import { HammerIcon, PlusIcon, SearchIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { reloadNuxtApp } from "#app";
|
||||
import type { PyroFetchError } from "~/composables/pyroFetch";
|
||||
import type { Server } from "~/types/servers";
|
||||
|
||||
definePageMeta({
|
||||
@@ -87,21 +136,23 @@ interface ServerResponse {
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const hasError = ref(false);
|
||||
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 {
|
||||
data: serverResponse,
|
||||
error: fetchError,
|
||||
refresh,
|
||||
} = await useAsyncData<ServerResponse>("ServerList", () => usePyroFetch<ServerResponse>("servers"));
|
||||
|
||||
const serverList = computed(() => serverResponse.value?.servers || []);
|
||||
watch([fetchError, serverResponse], ([error, response]) => {
|
||||
hasError.value = !!error || !response;
|
||||
});
|
||||
|
||||
const serverList = computed(() => {
|
||||
if (!serverResponse.value) return [];
|
||||
return serverResponse.value.servers;
|
||||
});
|
||||
|
||||
const searchInput = ref("");
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { createGlobalState } from "@vueuse/core";
|
||||
import { type Ref, ref } from "vue";
|
||||
import { type Ref, shallowRef } from "vue";
|
||||
|
||||
/**
|
||||
* Maximum number of console output lines to store
|
||||
* @type {number}
|
||||
*/
|
||||
const maxLines = 5000;
|
||||
const maxLines = 10000;
|
||||
const batchTimeout = 300; // ms
|
||||
const initialBatchSize = 256;
|
||||
|
||||
/**
|
||||
* Provides a global console output state management system
|
||||
@@ -21,7 +23,56 @@ export const usePyroConsole = createGlobalState(() => {
|
||||
* Reactive array storing console output lines
|
||||
* @type {Ref<string[]>}
|
||||
*/
|
||||
const output: Ref<string[]> = ref<string[]>([]);
|
||||
const output: Ref<string[]> = shallowRef<string[]>([]);
|
||||
const searchQuery: Ref<string> = shallowRef("");
|
||||
const filteredOutput: Ref<string[]> = shallowRef([]);
|
||||
let searchRegex: RegExp | null = null;
|
||||
|
||||
let lineBuffer: string[] = [];
|
||||
let batchTimer: NodeJS.Timeout | null = null;
|
||||
let isProcessingInitialBatch = false;
|
||||
|
||||
let refilterTimer: NodeJS.Timeout | null = null;
|
||||
const refilterTimeout = 100; // ms
|
||||
|
||||
const updateFilter = () => {
|
||||
if (!searchQuery.value) {
|
||||
filteredOutput.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!searchRegex) {
|
||||
searchRegex = new RegExp(searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
||||
}
|
||||
|
||||
filteredOutput.value = output.value.filter((line) => searchRegex?.test(line) ?? false);
|
||||
};
|
||||
|
||||
const scheduleRefilter = () => {
|
||||
if (refilterTimer) clearTimeout(refilterTimer);
|
||||
refilterTimer = setTimeout(updateFilter, refilterTimeout);
|
||||
};
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (lineBuffer.length === 0) return;
|
||||
|
||||
const processedLines = lineBuffer.flatMap((line) => line.split("\n").filter(Boolean));
|
||||
|
||||
if (isProcessingInitialBatch && processedLines.length >= initialBatchSize) {
|
||||
isProcessingInitialBatch = false;
|
||||
output.value = processedLines.slice(-maxLines);
|
||||
} else {
|
||||
const newOutput = [...output.value, ...processedLines];
|
||||
output.value = newOutput.slice(-maxLines);
|
||||
}
|
||||
|
||||
lineBuffer = [];
|
||||
batchTimer = null;
|
||||
|
||||
if (searchQuery.value) {
|
||||
scheduleRefilter();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new output line to the console output
|
||||
@@ -30,10 +81,10 @@ export const usePyroConsole = createGlobalState(() => {
|
||||
* @param {string} line - The console output line to add
|
||||
*/
|
||||
const addLine = (line: string): void => {
|
||||
output.value.push(line);
|
||||
lineBuffer.push(line);
|
||||
|
||||
if (output.value.length > maxLines) {
|
||||
output.value.shift();
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBuffer, batchTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -45,11 +96,29 @@ export const usePyroConsole = createGlobalState(() => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const addLines = (lines: string[]): void => {
|
||||
output.value.push(...lines);
|
||||
|
||||
if (output.value.length > maxLines) {
|
||||
output.value.splice(0, output.value.length - maxLines);
|
||||
if (output.value.length === 0 && lines.length >= initialBatchSize) {
|
||||
isProcessingInitialBatch = true;
|
||||
lineBuffer = lines;
|
||||
flushBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
lineBuffer.push(...lines);
|
||||
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBuffer, batchTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets the search query and filters the output based on the query
|
||||
*
|
||||
* @param {string} query - The search query
|
||||
*/
|
||||
const setSearchQuery = (query: string): void => {
|
||||
searchQuery.value = query;
|
||||
searchRegex = null;
|
||||
updateFilter();
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -57,12 +126,39 @@ export const usePyroConsole = createGlobalState(() => {
|
||||
*/
|
||||
const clear = (): void => {
|
||||
output.value = [];
|
||||
filteredOutput.value = [];
|
||||
searchQuery.value = "";
|
||||
lineBuffer = [];
|
||||
isProcessingInitialBatch = false;
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer);
|
||||
batchTimer = null;
|
||||
}
|
||||
if (refilterTimer) {
|
||||
clearTimeout(refilterTimer);
|
||||
refilterTimer = null;
|
||||
}
|
||||
searchRegex = null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds the index of a line in the main output
|
||||
*
|
||||
* @param {string} line - The line to find
|
||||
* @returns {number} The index of the line, or -1 if not found
|
||||
*/
|
||||
const findLineIndex = (line: string): number => {
|
||||
return output.value.findIndex((l) => l === line);
|
||||
};
|
||||
|
||||
return {
|
||||
output,
|
||||
searchQuery,
|
||||
filteredOutput,
|
||||
addLine,
|
||||
addLines,
|
||||
setSearchQuery,
|
||||
clear,
|
||||
findLineIndex,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user