You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit 'dbde3c4669af10dd577590ed6980e5bd4552d13c' into feature-clean
This commit is contained in:
@@ -1,46 +0,0 @@
|
||||
<template>
|
||||
<OmorphiaAvatar
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
:size="size"
|
||||
:circle="circle"
|
||||
:no-shadow="noShadow"
|
||||
:loading="loading"
|
||||
:raised="raised"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Avatar as OmorphiaAvatar } from "@modrinth/ui";
|
||||
|
||||
const props = defineProps({
|
||||
src: {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: "2rem",
|
||||
},
|
||||
circle: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
noShadow: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loading: {
|
||||
type: String,
|
||||
default: "eager",
|
||||
},
|
||||
raised: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
</script>
|
||||
@@ -1,131 +0,0 @@
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
'badge flex items-center gap-1 font-semibold text-secondary ' + color + ' type--' + type
|
||||
"
|
||||
>
|
||||
<template v-if="color"> <span class="circle" /> {{ capitalizeString(type) }}</template>
|
||||
|
||||
<!-- User roles -->
|
||||
<template v-else-if="type === 'admin'"> <ModrinthIcon /> Modrinth Team</template>
|
||||
<template v-else-if="type === 'moderator'"> <ModeratorIcon /> Moderator</template>
|
||||
<template v-else-if="type === 'creator'"><CreatorIcon /> Creator</template>
|
||||
<template v-else-if="type === 'plus'"><PlusIcon /> Modrinth Plus</template>
|
||||
|
||||
<!-- Project statuses -->
|
||||
<template v-else-if="type === 'approved'"><GlobeIcon /> Public</template>
|
||||
<template v-else-if="type === 'approved-general'"><CheckIcon /> Approved</template>
|
||||
<template v-else-if="type === 'unlisted' || type === 'withheld'"
|
||||
><LinkIcon /> Unlisted</template
|
||||
>
|
||||
<template v-else-if="type === 'private'"><LockIcon /> Private</template>
|
||||
<template v-else-if="type === 'scheduled'"> <CalendarIcon /> Scheduled</template>
|
||||
<template v-else-if="type === 'draft'"><DraftIcon /> Draft</template>
|
||||
<template v-else-if="type === 'archived'"> <ArchiveIcon /> Archived</template>
|
||||
<template v-else-if="type === 'rejected'"><CrossIcon /> Rejected</template>
|
||||
<template v-else-if="type === 'processing'"> <ProcessingIcon /> Under review</template>
|
||||
|
||||
<!-- Team members -->
|
||||
<template v-else-if="type === 'accepted'"><CheckIcon /> Accepted</template>
|
||||
<template v-else-if="type === 'pending'"> <ProcessingIcon /> Pending </template>
|
||||
|
||||
<!-- Transaction statuses -->
|
||||
<template v-else-if="type === 'success'"><CheckIcon /> Success</template>
|
||||
|
||||
<!-- Report status -->
|
||||
<template v-else-if="type === 'closed'"> <CloseIcon /> Closed</template>
|
||||
|
||||
<!-- Other -->
|
||||
<template v-else> <span class="circle" /> {{ capitalizeString(type) }} </template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
GlobeIcon,
|
||||
LinkIcon,
|
||||
ModrinthIcon,
|
||||
PlusIcon,
|
||||
ScaleIcon as ModeratorIcon,
|
||||
BoxIcon as CreatorIcon,
|
||||
FileTextIcon as DraftIcon,
|
||||
XIcon as CrossIcon,
|
||||
ArchiveIcon,
|
||||
UpdatedIcon as ProcessingIcon,
|
||||
CheckIcon,
|
||||
LockIcon,
|
||||
CalendarIcon,
|
||||
XCircleIcon as CloseIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { capitalizeString } from "@modrinth/utils";
|
||||
|
||||
defineProps({
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
color: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.badge {
|
||||
.circle {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
margin-right: 0.25rem;
|
||||
background-color: var(--badge-color);
|
||||
}
|
||||
|
||||
svg {
|
||||
vertical-align: -15%;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&.type--closed,
|
||||
&.type--withheld,
|
||||
&.type--rejected,
|
||||
&.red {
|
||||
--badge-color: var(--color-red);
|
||||
}
|
||||
|
||||
&.type--pending,
|
||||
&.type--moderator,
|
||||
&.type--processing,
|
||||
&.type--scheduled,
|
||||
&.orange {
|
||||
--badge-color: var(--color-orange);
|
||||
}
|
||||
|
||||
&.type--accepted,
|
||||
&.type--admin,
|
||||
&.type--success,
|
||||
&.type--approved-general,
|
||||
&.green {
|
||||
--badge-color: var(--color-green);
|
||||
}
|
||||
|
||||
&.type--creator,
|
||||
&.blue {
|
||||
--badge-color: var(--color-blue);
|
||||
}
|
||||
|
||||
&.type--unlisted,
|
||||
&.type--plus,
|
||||
&.purple {
|
||||
--badge-color: var(--color-purple);
|
||||
}
|
||||
|
||||
&.type--private,
|
||||
&.type--approved,
|
||||
&.gray {
|
||||
--badge-color: var(--color-secondary);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<button class="code" :class="{ copied }" title="Copy code to clipboard" @click="copyText">
|
||||
<span>{{ text }}</span>
|
||||
<CheckIcon v-if="copied" />
|
||||
<ClipboardCopyIcon v-else />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { CheckIcon, ClipboardCopyIcon } from "@modrinth/assets";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
CheckIcon,
|
||||
ClipboardCopyIcon,
|
||||
},
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
copied: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
async copyText() {
|
||||
await navigator.clipboard.writeText(this.text);
|
||||
this.copied = true;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.code {
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
grid-gap: 0.5rem;
|
||||
font-family: var(--mono-font);
|
||||
font-size: var(--font-size-sm);
|
||||
margin: 0;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-code-bg);
|
||||
width: fit-content;
|
||||
border-radius: 10px;
|
||||
user-select: text;
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
filter 0.2s ease-in-out,
|
||||
transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
filter: brightness(0.85);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
filter: brightness(0.8);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -654,11 +654,11 @@ For a brief rundown of how this works:
|
||||
{
|
||||
name: "Insufficient",
|
||||
resultingMessage: `## Insufficient Gallery Images
|
||||
We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).
|
||||
Keep in mind that you should:
|
||||
- Set a featured image that best represents your project.
|
||||
- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description.
|
||||
- Upload any relevant images in your Description to your Gallery tab for best results.`,
|
||||
We ask that projects like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of its content per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).
|
||||
Keep in mind that you should:
|
||||
- Set a featured image that best represents your project.
|
||||
- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description.
|
||||
- Upload any relevant images in your Description to your Gallery tab for best results.`,
|
||||
},
|
||||
{
|
||||
name: "Not relevant",
|
||||
|
||||
@@ -104,13 +104,13 @@
|
||||
</nuxt-link>
|
||||
<template v-if="tags.rejectedStatuses.includes(notification.body.new_status)">
|
||||
has been
|
||||
<Badge :type="notification.body.new_status" />
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
<template v-else>
|
||||
updated from
|
||||
<Badge :type="notification.body.old_status" />
|
||||
<ProjectStatusBadge :status="notification.body.old_status" />
|
||||
to
|
||||
<Badge :type="notification.body.new_status" />
|
||||
<ProjectStatusBadge :status="notification.body.new_status" />
|
||||
</template>
|
||||
by the moderators.
|
||||
</template>
|
||||
@@ -331,16 +331,13 @@ import {
|
||||
XIcon,
|
||||
ExternalIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { useRelativeTime } from "@modrinth/ui";
|
||||
import { Avatar, ProjectStatusBadge, CopyCode, useRelativeTime } from "@modrinth/ui";
|
||||
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
||||
import { getProjectLink, getVersionLink } from "~/helpers/projects.js";
|
||||
import { getUserLink } from "~/helpers/users.js";
|
||||
import { acceptTeamInvite, removeSelfFromTeam } from "~/helpers/teams.js";
|
||||
import { markAsRead } from "~/helpers/notifications.ts";
|
||||
import DoubleIcon from "~/components/ui/DoubleIcon.vue";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||
import Categories from "~/components/ui/search/Categories.vue";
|
||||
|
||||
const app = useNuxtApp();
|
||||
|
||||
128
apps/frontend/src/components/ui/OptionGroup.vue
Normal file
128
apps/frontend/src/components/ui/OptionGroup.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
|
||||
>
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="`option-group-${index}`"
|
||||
ref="optionButtons"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 rounded-full bg-transparent px-4 py-2 font-semibold"
|
||||
:class="{
|
||||
'text-button-textSelected': modelValue === option,
|
||||
'text-primary': modelValue !== option,
|
||||
}"
|
||||
@click="setOption(option)"
|
||||
>
|
||||
<slot :option="option" :selected="modelValue === option" />
|
||||
</button>
|
||||
<div
|
||||
class="navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full bg-button-bgSelected p-1"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
right: sliderRightPx,
|
||||
bottom: sliderBottomPx,
|
||||
opacity: initialized ? 1 : 0,
|
||||
}"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
|
||||
const modelValue = defineModel<T>({ required: true });
|
||||
|
||||
const props = defineProps<{
|
||||
options: T[];
|
||||
}>();
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
const sliderLeft = ref(4);
|
||||
const sliderTop = ref(4);
|
||||
const sliderRight = ref(4);
|
||||
const sliderBottom = ref(4);
|
||||
|
||||
const sliderLeftPx = computed(() => `${sliderLeft.value}px`);
|
||||
const sliderTopPx = computed(() => `${sliderTop.value}px`);
|
||||
const sliderRightPx = computed(() => `${sliderRight.value}px`);
|
||||
const sliderBottomPx = computed(() => `${sliderBottom.value}px`);
|
||||
|
||||
const optionButtons = ref();
|
||||
|
||||
const initialized = ref(false);
|
||||
|
||||
function setOption(option: T) {
|
||||
modelValue.value = option;
|
||||
}
|
||||
|
||||
watch(modelValue, () => {
|
||||
startAnimation(props.options.indexOf(modelValue.value));
|
||||
});
|
||||
|
||||
function startAnimation(index: number) {
|
||||
const el = optionButtons.value[index];
|
||||
|
||||
if (!el || !el.offsetParent) return;
|
||||
|
||||
const newValues = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
};
|
||||
|
||||
if (sliderLeft.value === 4 && sliderRight.value === 4) {
|
||||
sliderLeft.value = newValues.left;
|
||||
sliderRight.value = newValues.right;
|
||||
sliderTop.value = newValues.top;
|
||||
sliderBottom.value = newValues.bottom;
|
||||
} else {
|
||||
const delay = 200;
|
||||
|
||||
if (newValues.left < sliderLeft.value) {
|
||||
sliderLeft.value = newValues.left;
|
||||
setTimeout(() => {
|
||||
sliderRight.value = newValues.right;
|
||||
}, delay);
|
||||
} else {
|
||||
sliderRight.value = newValues.right;
|
||||
setTimeout(() => {
|
||||
sliderLeft.value = newValues.left;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
if (newValues.top < sliderTop.value) {
|
||||
sliderTop.value = newValues.top;
|
||||
setTimeout(() => {
|
||||
sliderBottom.value = newValues.bottom;
|
||||
}, delay);
|
||||
} else {
|
||||
sliderBottom.value = newValues.bottom;
|
||||
setTimeout(() => {
|
||||
sliderTop.value = newValues.top;
|
||||
}, delay);
|
||||
}
|
||||
}
|
||||
initialized.value = true;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
startAnimation(props.options.indexOf(modelValue.value));
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.navtabs-transition {
|
||||
transition:
|
||||
all 150ms cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
|
||||
}
|
||||
|
||||
.card-shadow {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
@@ -1,196 +0,0 @@
|
||||
<template>
|
||||
<div v-if="count > 1" class="columns paginates">
|
||||
<a
|
||||
:class="{ disabled: page === 1 }"
|
||||
:tabindex="page === 1 ? -1 : 0"
|
||||
class="left-arrow paginate has-icon"
|
||||
aria-label="Previous Page"
|
||||
:href="linkFunction(page - 1)"
|
||||
@click.prevent="page !== 1 ? switchPage(page - 1) : null"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</a>
|
||||
<div
|
||||
v-for="(item, index) in pages"
|
||||
:key="'page-' + item + '-' + index"
|
||||
:class="{
|
||||
'page-number': page !== item,
|
||||
shrink: item > 99,
|
||||
}"
|
||||
class="page-number-container"
|
||||
>
|
||||
<div v-if="item === '-'" class="has-icon">
|
||||
<GapIcon />
|
||||
</div>
|
||||
<a
|
||||
v-else
|
||||
:class="{
|
||||
'page-number current': page === item,
|
||||
shrink: item > 99,
|
||||
}"
|
||||
:href="linkFunction(item)"
|
||||
@click.prevent="page !== item ? switchPage(item) : null"
|
||||
>
|
||||
{{ item }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<a
|
||||
:class="{
|
||||
disabled: page === pages[pages.length - 1],
|
||||
}"
|
||||
:tabindex="page === pages[pages.length - 1] ? -1 : 0"
|
||||
class="right-arrow paginate has-icon"
|
||||
aria-label="Next Page"
|
||||
:href="linkFunction(page + 1)"
|
||||
@click.prevent="page !== pages[pages.length - 1] ? switchPage(page + 1) : null"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { GapIcon, LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GapIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
},
|
||||
props: {
|
||||
page: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
},
|
||||
linkFunction: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => "/";
|
||||
},
|
||||
},
|
||||
},
|
||||
emits: ["switch-page"],
|
||||
computed: {
|
||||
pages() {
|
||||
let pages = [];
|
||||
|
||||
if (this.count > 7) {
|
||||
if (this.page + 3 >= this.count) {
|
||||
pages = [
|
||||
1,
|
||||
"-",
|
||||
this.count - 4,
|
||||
this.count - 3,
|
||||
this.count - 2,
|
||||
this.count - 1,
|
||||
this.count,
|
||||
];
|
||||
} else if (this.page > 5) {
|
||||
pages = [1, "-", this.page - 1, this.page, this.page + 1, "-", this.count];
|
||||
} else {
|
||||
pages = [1, 2, 3, 4, 5, "-", this.count];
|
||||
}
|
||||
} else {
|
||||
pages = Array.from({ length: this.count }, (_, i) => i + 1);
|
||||
}
|
||||
|
||||
return pages;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
switchPage(newPage) {
|
||||
this.$emit("switch-page", newPage);
|
||||
if (newPage !== null && newPage !== "" && !isNaN(newPage)) {
|
||||
this.$emit("switch-page", Math.min(Math.max(newPage, 1), this.count));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
a {
|
||||
position: relative;
|
||||
color: var(--color-button-text);
|
||||
box-shadow: var(--shadow-raised), var(--shadow-inset);
|
||||
|
||||
padding: 0.5rem 1rem;
|
||||
margin: 0;
|
||||
border-radius: 2rem;
|
||||
background: var(--color-raised-bg);
|
||||
|
||||
transition:
|
||||
opacity 0.5s ease-in-out,
|
||||
filter 0.2s ease-in-out,
|
||||
transform 0.05s ease-in-out,
|
||||
outline 0.2s ease-in-out;
|
||||
|
||||
&.page-number.current {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-brand-inverted);
|
||||
cursor: default;
|
||||
outline: 2px solid transparent;
|
||||
}
|
||||
|
||||
&.paginate.disabled {
|
||||
background-color: transparent;
|
||||
cursor: not-allowed;
|
||||
filter: grayscale(50%);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.has-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
svg {
|
||||
width: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.page-number-container,
|
||||
a,
|
||||
.has-icon {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.paginates {
|
||||
height: 2em;
|
||||
margin: 0.5rem 0;
|
||||
|
||||
> div,
|
||||
.has-icon {
|
||||
margin: 0 0.3em;
|
||||
}
|
||||
}
|
||||
|
||||
.left-arrow {
|
||||
margin-left: auto !important;
|
||||
}
|
||||
|
||||
.right-arrow {
|
||||
margin-right: auto !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 400px) {
|
||||
.paginates {
|
||||
font-size: 80%;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 530px) {
|
||||
a {
|
||||
width: 2.5rem;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -29,7 +29,7 @@
|
||||
{{ author }}
|
||||
</nuxt-link>
|
||||
</p>
|
||||
<Badge v-if="status && status !== 'approved'" :type="status" class="status" />
|
||||
<ProjectStatusBadge v-if="status && status !== 'approved'" :status="status" class="status" />
|
||||
</div>
|
||||
<p class="description">
|
||||
{{ description }}
|
||||
@@ -91,18 +91,16 @@
|
||||
|
||||
<script>
|
||||
import { CalendarIcon, UpdatedIcon, DownloadIcon, HeartIcon } from "@modrinth/assets";
|
||||
import { Avatar, ProjectStatusBadge, useRelativeTime } from "@modrinth/ui";
|
||||
import Categories from "~/components/ui/search/Categories.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import EnvironmentIndicator from "~/components/ui/EnvironmentIndicator.vue";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import { useRelativeTime } from "@modrinth/ui";
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ProjectStatusBadge,
|
||||
EnvironmentIndicator,
|
||||
Avatar,
|
||||
Categories,
|
||||
Badge,
|
||||
CalendarIcon,
|
||||
UpdatedIcon,
|
||||
DownloadIcon,
|
||||
|
||||
@@ -133,7 +133,7 @@ function generateTooltip({ series, seriesIndex, dataPointIndex, w }, props) {
|
||||
props,
|
||||
);
|
||||
} else {
|
||||
const returnTopN = 5;
|
||||
const returnTopN = 15;
|
||||
|
||||
const listEntries = series
|
||||
.map((value, index) => [
|
||||
|
||||
@@ -104,12 +104,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ReportIcon, UnknownIcon, VersionIcon } from "@modrinth/assets";
|
||||
import { Avatar, Badge, CopyCode, useRelativeTime } from "@modrinth/ui";
|
||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||
import { useRelativeTime } from "@modrinth/ui";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import ThreadSummary from "~/components/ui/thread/ThreadSummary.vue";
|
||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||
|
||||
const formatRelativeTime = useRelativeTime();
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
<script setup>
|
||||
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
||||
import { addReportMessage } from "~/helpers/threads.js";
|
||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||
|
||||
defineProps({
|
||||
moderation: {
|
||||
@@ -53,13 +54,13 @@ const threadIds = [
|
||||
|
||||
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
|
||||
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
|
||||
useBaseFetch(`users?ids=${encodeURIComponent(JSON.stringify(userIds))}`),
|
||||
fetchSegmented(userIds, (ids) => `users?ids=${asEncodedJsonArray(ids)}`),
|
||||
),
|
||||
await useAsyncData(`versions?ids=${JSON.stringify(versionIds)}`, () =>
|
||||
useBaseFetch(`versions?ids=${encodeURIComponent(JSON.stringify(versionIds))}`),
|
||||
fetchSegmented(versionIds, (ids) => `versions?ids=${asEncodedJsonArray(ids)}`),
|
||||
),
|
||||
await useAsyncData(`threads?ids=${JSON.stringify(threadIds)}`, () =>
|
||||
useBaseFetch(`threads?ids=${encodeURIComponent(JSON.stringify(threadIds))}`),
|
||||
fetchSegmented(threadIds, (ids) => `threads?ids=${asEncodedJsonArray(ids)}`),
|
||||
),
|
||||
]);
|
||||
|
||||
@@ -70,7 +71,7 @@ const versionProjects = versions.value.map((version) => version.project_id);
|
||||
const projectIds = [...new Set(reportedProjects.concat(versionProjects))];
|
||||
|
||||
const { data: projects } = await useAsyncData(`projects?ids=${JSON.stringify(projectIds)}`, () =>
|
||||
useBaseFetch(`projects?ids=${encodeURIComponent(JSON.stringify(projectIds))}`),
|
||||
fetchSegmented(projectIds, (ids) => `projects?ids=${asEncodedJsonArray(ids)}`),
|
||||
);
|
||||
|
||||
reports.value = rawReports.map((report) => {
|
||||
|
||||
@@ -45,9 +45,11 @@
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { IssuesIcon, PlusIcon, XIcon } from "@modrinth/assets";
|
||||
import { ModrinthServersFetchError, type ServerBackup } from "@modrinth/utils";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
@@ -64,7 +66,7 @@ const trimmedName = computed(() => backupName.value.trim());
|
||||
const nameExists = computed(() => {
|
||||
if (!props.server.backups?.data) return false;
|
||||
return props.server.backups.data.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
(backup: ServerBackup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -98,7 +100,7 @@ const createBackup = async () => {
|
||||
hideModal();
|
||||
await props.server.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
|
||||
isRateLimited.value = true;
|
||||
addNotification({
|
||||
type: "error",
|
||||
|
||||
@@ -20,13 +20,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ConfirmModal } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Backup } from "@modrinth/utils";
|
||||
import BackupItem from "~/components/ui/servers/BackupItem.vue";
|
||||
|
||||
defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "delete", backup: Backup | undefined): void;
|
||||
}>();
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from "@modrinth/ui";
|
||||
import { defineMessages, useVIntl } from "@vintl/vintl";
|
||||
import { ref } from "vue";
|
||||
import type { Backup } from "~/composables/pyroServers.ts";
|
||||
import type { Backup } from "@modrinth/utils";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@@ -48,10 +48,11 @@
|
||||
import { ref, nextTick, computed } from "vue";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { SpinnerIcon, SaveIcon, XIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Backup } from "@modrinth/utils";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
@@ -70,7 +71,7 @@ const nameExists = computed(() => {
|
||||
}
|
||||
|
||||
return props.server.backups.data.some(
|
||||
(backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
(backup: Backup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import { ConfirmModal, NewModal } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Backup } from "@modrinth/utils";
|
||||
import BackupItem from "~/components/ui/servers/BackupItem.vue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
@@ -59,10 +59,10 @@
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { XIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["backups"]>;
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
@@ -239,7 +239,7 @@ import {
|
||||
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
|
||||
import { ref, computed } from "vue";
|
||||
import { formatCategory, formatVersionsForDisplay, type Version } from "@modrinth/utils";
|
||||
import { formatCategory, formatVersionsForDisplay, type Mod, type Version } from "@modrinth/utils";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||
import ContentVersionFilter, {
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
import type { FSModule } from "~/composables/pyroServers.ts";
|
||||
import { FSModule } from "~/composables/servers/modules/fs.ts";
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
|
||||
@@ -75,13 +75,14 @@
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, SpinnerIcon, DownloadIcon, XIcon } from "@modrinth/assets";
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
import { handleError, type Server } from "~/composables/pyroServers.ts";
|
||||
import { handleError, ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const cf = ref(false);
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
const modal = ref<typeof NewModal>();
|
||||
@@ -110,24 +111,18 @@ const handleSubmit = async () => {
|
||||
if (!error.value) {
|
||||
// hide();
|
||||
try {
|
||||
const dry = await props.server.fs?.extractFile(trimmedUrl.value, true, true);
|
||||
const dry = await props.server.fs.extractFile(trimmedUrl.value, true, true);
|
||||
|
||||
if (!cf.value || dry.modpack_name) {
|
||||
await props.server.fs?.extractFile(trimmedUrl.value, true, false, true);
|
||||
await props.server.fs.extractFile(trimmedUrl.value, true, false, true);
|
||||
hide();
|
||||
} else {
|
||||
submitted.value = false;
|
||||
handleError(
|
||||
new ServersError(
|
||||
new ModrinthServersFetchError(
|
||||
"Could not find CurseForge modpack at that URL.",
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
context: "Error installing modpack",
|
||||
error: `url: ${url.value}`,
|
||||
description: "Could not find CurseForge modpack at that URL.",
|
||||
},
|
||||
404,
|
||||
new Error(`No modpack found at ${url.value}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ const props = defineProps<{
|
||||
loader: string | null;
|
||||
loader_version: string | null;
|
||||
};
|
||||
ignoreCurrentInstallation?: boolean;
|
||||
isInstalling?: boolean;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -1,80 +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 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
|
||||
</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="relative w-full">
|
||||
<input type="text" placeholder="Search logs" class="h-12 !w-full !pl-10 !pr-48" />
|
||||
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<div
|
||||
class="console relative h-full min-h-[516px] w-full overflow-hidden rounded-xl bg-bg text-sm"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } 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>
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
<template v-else>
|
||||
<ButtonStyled v-if="showStopButton" type="transparent">
|
||||
<button :disabled="!canTakeAction" @click="initiateAction('stop')">
|
||||
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
|
||||
<div class="flex gap-1">
|
||||
<StopCircleIcon class="h-5 w-5" />
|
||||
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
|
||||
@@ -120,14 +120,12 @@ import {
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
type ServerAction = "start" | "stop" | "restart" | "kill";
|
||||
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
|
||||
import type { PowerAction as ServerPowerAction, ServerState } from "@modrinth/utils";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
|
||||
interface PowerAction {
|
||||
action: ServerAction;
|
||||
action: ServerPowerAction;
|
||||
nextState: ServerState;
|
||||
}
|
||||
|
||||
@@ -142,7 +140,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: ServerAction): void;
|
||||
(e: "action", action: ServerPowerAction): void;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
@@ -170,7 +168,7 @@ const isStoppingState = computed(() => serverState.value === "stopping");
|
||||
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
|
||||
|
||||
const primaryActionText = computed(() => {
|
||||
const states: Record<ServerState, string> = {
|
||||
const states: Partial<Record<ServerState, string>> = {
|
||||
starting: "Starting...",
|
||||
restarting: "Restarting...",
|
||||
running: "Restart",
|
||||
@@ -193,7 +191,7 @@ const menuOptions = computed(() => [
|
||||
id: "kill",
|
||||
label: "Kill server",
|
||||
icon: SlashIcon,
|
||||
action: () => initiateAction("kill"),
|
||||
action: () => initiateAction("Kill"),
|
||||
},
|
||||
]),
|
||||
{
|
||||
@@ -221,17 +219,17 @@ async function copyId() {
|
||||
await navigator.clipboard.writeText(serverId as string);
|
||||
}
|
||||
|
||||
function initiateAction(action: ServerAction) {
|
||||
function initiateAction(action: ServerPowerAction) {
|
||||
if (!canTakeAction.value) return;
|
||||
|
||||
const stateMap: Record<ServerAction, ServerState> = {
|
||||
start: "starting",
|
||||
stop: "stopping",
|
||||
restart: "restarting",
|
||||
kill: "stopping",
|
||||
const stateMap: Record<ServerPowerAction, ServerState> = {
|
||||
Start: "starting",
|
||||
Stop: "stopping",
|
||||
Restart: "restarting",
|
||||
Kill: "stopping",
|
||||
};
|
||||
|
||||
if (action === "start") {
|
||||
if (action === "Start") {
|
||||
emit("action", action);
|
||||
serverState.value = stateMap[action];
|
||||
startingDelay.value = true;
|
||||
@@ -249,7 +247,7 @@ function initiateAction(action: ServerAction) {
|
||||
}
|
||||
|
||||
function handlePrimaryAction() {
|
||||
initiateAction(isRunning.value ? "restart" : "start");
|
||||
initiateAction(isRunning.value ? "Restart" : "Start");
|
||||
}
|
||||
|
||||
function executePowerAction() {
|
||||
@@ -263,7 +261,7 @@ function executePowerAction() {
|
||||
userPreferences.value.powerDontAskAgain = true;
|
||||
}
|
||||
|
||||
if (action === "start") {
|
||||
if (action === "Start") {
|
||||
startingDelay.value = true;
|
||||
setTimeout(() => (startingDelay.value = false), 5000);
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { ServerState } from "~/types/servers";
|
||||
import type { ServerState } from "@modrinth/utils";
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
running: { main: "bg-brand", bg: "bg-bg-green" },
|
||||
@@ -49,7 +49,7 @@ const STATUS_CLASSES = {
|
||||
unknown: { main: "", bg: "" },
|
||||
} as const;
|
||||
|
||||
const STATUS_TEXTS = {
|
||||
const STATUS_TEXTS: Partial<Record<ServerState, string>> = {
|
||||
running: "Running",
|
||||
stopped: "",
|
||||
crashed: "Crashed",
|
||||
@@ -63,7 +63,10 @@ defineProps<{
|
||||
const isExpanded = ref(false);
|
||||
|
||||
function getStatusClass(state: ServerState) {
|
||||
return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
|
||||
if (state in STATUS_CLASSES) {
|
||||
return STATUS_CLASSES[state as keyof typeof STATUS_CLASSES];
|
||||
}
|
||||
return STATUS_CLASSES.unknown;
|
||||
}
|
||||
|
||||
function getStatusText(state: ServerState) {
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
type="text"
|
||||
placeholder="Search logs"
|
||||
class="h-12 !w-full !pl-10 !pr-48"
|
||||
:disabled="loading"
|
||||
@keydown.escape="clearSearch"
|
||||
/>
|
||||
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
|
||||
<ButtonStyled v-if="searchInput" @click="clearSearch">
|
||||
<ButtonStyled v-if="searchInput && !loading" @click="clearSearch">
|
||||
<button class="absolute right-2 top-1/2 -translate-y-1/2">
|
||||
<XIcon class="h-5 w-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<span
|
||||
v-if="pyroConsole.filteredOutput.value.length && searchInput"
|
||||
v-if="pyroConsole.filteredOutput.value.length && searchInput && !loading"
|
||||
class="pointer-events-none absolute right-12 top-1/2 -translate-y-1/2 select-none whitespace-pre text-sm"
|
||||
>
|
||||
{{ pyroConsole.filteredOutput.value.length }}
|
||||
@@ -29,11 +30,13 @@
|
||||
:class="[
|
||||
'terminal-font console relative z-[1] flex h-full w-full flex-col items-center justify-between overflow-hidden rounded-t-xl px-1 text-sm transition-transform duration-300',
|
||||
{ 'scale-fullscreen screen-fixed inset-0 z-50 !rounded-none': isFullScreen },
|
||||
{ 'pointer-events-none': loading },
|
||||
]"
|
||||
:aria-hidden="loading"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
v-if="cosmetics.advancedRendering"
|
||||
v-if="cosmetics.advancedRendering && !loading"
|
||||
class="progressive-gradient pointer-events-none absolute -bottom-6 left-0 z-[2] h-[10rem] w-full overflow-hidden rounded-xl"
|
||||
:style="`--transparency: ${Math.max(0, lerp(100, 0, bottomThreshold * 8))}%`"
|
||||
aria-hidden="true"
|
||||
@@ -47,7 +50,7 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
v-else-if="!loading"
|
||||
class="pointer-events-none absolute bottom-0 left-0 right-0 z-[2] h-[196px] w-full"
|
||||
:style="
|
||||
bottomThreshold > 0
|
||||
@@ -79,6 +82,7 @@
|
||||
</div>
|
||||
<div data-pyro-terminal-scroll-root class="relative h-full w-full">
|
||||
<div
|
||||
v-if="!loading"
|
||||
ref="scrollbarTrack"
|
||||
data-pyro-terminal-scrollbar-track
|
||||
class="absolute -right-1 bottom-16 top-4 z-[4] w-4 overflow-hidden"
|
||||
@@ -118,7 +122,12 @@
|
||||
class="scrollbar-none absolute left-0 top-0 h-full w-full select-text overflow-x-auto overflow-y-auto py-6 pb-[72px]"
|
||||
@scroll.passive="() => handleListScroll()"
|
||||
>
|
||||
<div data-pyro-terminal-virtual-height-watcher :style="{ height: `${totalHeight}px` }">
|
||||
<div v-if="loading" class="h-full w-full" />
|
||||
<div
|
||||
v-else
|
||||
data-pyro-terminal-virtual-height-watcher
|
||||
:style="{ height: `${totalHeight}px` }"
|
||||
>
|
||||
<ul
|
||||
class="m-0 list-none p-0"
|
||||
data-pyro-terminal-virtual-list
|
||||
@@ -205,6 +214,7 @@
|
||||
<slot />
|
||||
</div>
|
||||
<button
|
||||
v-if="!loading"
|
||||
data-pyro-fullscreen
|
||||
:label="isFullScreen ? 'Exit full screen' : 'Enter full screen'"
|
||||
class="experimental-styles-within absolute right-4 top-4 z-[3] grid h-12 w-12 place-content-center rounded-full border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
|
||||
@@ -217,7 +227,7 @@
|
||||
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="hasSelection || isSingleLineSelected"
|
||||
v-if="(hasSelection || isSingleLineSelected) && !loading"
|
||||
class="absolute right-20 top-4 z-[3] flex flex-row items-center"
|
||||
:class="{ '!right-4': searchInput || hasSelection || isSingleLineSelected }"
|
||||
>
|
||||
@@ -247,7 +257,7 @@
|
||||
|
||||
<Transition name="scroll-to-bottom">
|
||||
<button
|
||||
v-if="bottomThreshold > 0 && !isScrolledToBottom"
|
||||
v-if="bottomThreshold > 0 && !isScrolledToBottom && !loading"
|
||||
data-pyro-scrolltobottom
|
||||
label="Scroll to bottom"
|
||||
class="scroll-to-bottom-btn experimental-styles-within absolute bottom-[4.5rem] right-4 z-[3] grid h-12 w-12 place-content-center rounded-full border-[1px] border-solid border-button-border bg-bg-raised text-contrast transition-all duration-200 hover:scale-110 active:scale-95"
|
||||
@@ -291,13 +301,14 @@ import { useDebounceFn } from "@vueuse/core";
|
||||
import { NewModal } from "@modrinth/ui";
|
||||
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
|
||||
import DOMPurify from "dompurify";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
import { useModrinthServersConsole } from "~/store/console.ts";
|
||||
|
||||
const { $cosmetics } = useNuxtApp();
|
||||
const cosmetics = $cosmetics;
|
||||
|
||||
const props = defineProps<{
|
||||
fullScreen: boolean;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const BUFFER_SIZE = 5;
|
||||
@@ -307,8 +318,8 @@ const SEPARATOR_HEIGHT = 32;
|
||||
const SCROLL_END_DELAY = 150;
|
||||
const progressiveBlurIterations = ref(8);
|
||||
|
||||
const pyroConsole = usePyroConsole();
|
||||
const consoleOutput = pyroConsole.output;
|
||||
const pyroConsole = useModrinthServersConsole();
|
||||
const consoleOutput = computed(() => (props.loading ? [] : pyroConsole.output.value));
|
||||
|
||||
const scrollContainer = ref<HTMLElement | null>(null);
|
||||
|
||||
|
||||
@@ -69,10 +69,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { DownloadIcon, XIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import { ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
project: any;
|
||||
versions: any[];
|
||||
currentVersion?: any;
|
||||
@@ -98,8 +99,7 @@ const handleReinstall = async () => {
|
||||
try {
|
||||
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id;
|
||||
|
||||
await props.server.general?.reinstall(
|
||||
props.server.serverId,
|
||||
await props.server.general.reinstall(
|
||||
false,
|
||||
props.project.id,
|
||||
versionId,
|
||||
@@ -110,7 +110,7 @@ const handleReinstall = async () => {
|
||||
emit("reinstall");
|
||||
hide();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Cannot reinstall server",
|
||||
|
||||
@@ -116,11 +116,12 @@
|
||||
<script setup lang="ts">
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import { ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
@@ -175,7 +176,7 @@ const handleReinstall = async () => {
|
||||
window.scrollTo(0, 0);
|
||||
hide();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Cannot reinstall server",
|
||||
|
||||
@@ -127,7 +127,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div
|
||||
v-if="!initialSetup"
|
||||
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
|
||||
@@ -146,7 +149,10 @@
|
||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||
</div>
|
||||
|
||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
||||
<BackupWarning
|
||||
v-if="!initialSetup"
|
||||
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
@@ -194,9 +200,9 @@
|
||||
import { BackupWarning, ButtonStyled, NewModal, Toggle } from "@modrinth/ui";
|
||||
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from "@modrinth/assets";
|
||||
import { $fetch } from "ofetch";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
import { type Loaders, ModrinthServersFetchError } from "@modrinth/utils";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@@ -214,9 +220,10 @@ type VersionMap = Record<string, LoaderVersion[]>;
|
||||
type VersionCache = Record<string, any>;
|
||||
|
||||
const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
currentLoader: Loaders | undefined;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
initialSetup?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -313,7 +320,7 @@ const selectedLoaderVersions = computed<string[]>(() => {
|
||||
const loader = selectedLoader.value.toLowerCase();
|
||||
|
||||
if (loader === "paper") {
|
||||
return paperVersions.value[selectedMCVersion.value].map((x) => `${x}`) || [];
|
||||
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || [];
|
||||
}
|
||||
|
||||
if (loader === "purpur") {
|
||||
@@ -451,12 +458,11 @@ const handleReinstall = async () => {
|
||||
|
||||
try {
|
||||
await props.server.general?.reinstall(
|
||||
props.server.serverId,
|
||||
true,
|
||||
selectedLoader.value,
|
||||
selectedMCVersion.value,
|
||||
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
|
||||
hardReset.value,
|
||||
props.initialSetup ? true : hardReset.value,
|
||||
);
|
||||
|
||||
emit("reinstall", {
|
||||
@@ -467,7 +473,7 @@ const handleReinstall = async () => {
|
||||
|
||||
hide();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
|
||||
addNotification({
|
||||
group: "server",
|
||||
title: "Cannot reinstall server",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
isUpdating: boolean;
|
||||
@@ -39,7 +39,7 @@ const props = defineProps<{
|
||||
save: () => void;
|
||||
reset: () => void;
|
||||
isVisible: boolean;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
}>();
|
||||
|
||||
const saveAndRestart = async () => {
|
||||
|
||||
278
apps/frontend/src/components/ui/servers/ServerInstallation.vue
Normal file
278
apps/frontend/src/components/ui/servers/ServerInstallation.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<LazyUiServersPlatformVersionSelectModal
|
||||
ref="versionSelectModal"
|
||||
:server="props.server"
|
||||
:current-loader="ignoreCurrentInstallation ? undefined : (data?.loader as Loaders)"
|
||||
:backup-in-progress="backupInProgress"
|
||||
:initial-setup="ignoreCurrentInstallation"
|
||||
@reinstall="emit('reinstall', $event)"
|
||||
/>
|
||||
|
||||
<LazyUiServersPlatformMrpackModal
|
||||
ref="mrpackModal"
|
||||
:server="props.server"
|
||||
@reinstall="emit('reinstall', $event)"
|
||||
/>
|
||||
|
||||
<LazyUiServersPlatformChangeModpackVersionModal
|
||||
ref="modpackVersionModal"
|
||||
:server="props.server"
|
||||
:project="data?.project"
|
||||
:versions="Array.isArray(versions) ? versions : []"
|
||||
:current-version="currentVersion"
|
||||
:current-version-id="data?.upstream?.version_id"
|
||||
:server-status="data?.status"
|
||||
@reinstall="emit('reinstall')"
|
||||
/>
|
||||
|
||||
<div class="flex h-full w-full flex-col">
|
||||
<div v-if="data && versions" class="flex w-full flex-col">
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="flex select-none flex-col items-center justify-between gap-2 lg:flex-row">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Modpack</h2>
|
||||
<div
|
||||
v-if="updateAvailable"
|
||||
class="rounded-full bg-bg-orange px-2 py-1 text-xs font-medium text-orange"
|
||||
>
|
||||
<span>Update available</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.upstream" class="flex gap-4">
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!w-full sm:!w-auto"
|
||||
:disabled="isInstalling"
|
||||
@click="mrpackModal.show()"
|
||||
>
|
||||
<UploadIcon class="size-4" /> Import .mrpack
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<!-- dumb hack to make a button link not a link -->
|
||||
<ButtonStyled>
|
||||
<template v-if="isInstalling">
|
||||
<button :disabled="isInstalling">
|
||||
<TransferIcon class="size-4" />
|
||||
Switch modpack
|
||||
</button>
|
||||
</template>
|
||||
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
|
||||
<TransferIcon class="size-4" />
|
||||
Switch modpack
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="data.upstream" class="flex flex-col gap-2">
|
||||
<div
|
||||
v-if="versionsError || currentVersionError"
|
||||
class="rounded-2xl border border-solid border-red p-4 text-contrast"
|
||||
>
|
||||
<p class="m-0 font-bold">Something went wrong while loading your modpack.</p>
|
||||
<p class="m-0 mb-2 mt-1 text-sm">
|
||||
{{ versionsError || currentVersionError }}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isInstalling" @click="refreshData">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<NewProjectCard
|
||||
v-if="!versionsError && !currentVersionError"
|
||||
class="!cursor-default !bg-bg !filter-none"
|
||||
:project="projectCardData"
|
||||
:categories="data.project?.categories || []"
|
||||
>
|
||||
<template #actions>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="isInstalling" @click="modpackVersionModal.show()">
|
||||
<SettingsIcon class="size-4" />
|
||||
Change version
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</NewProjectCard>
|
||||
</div>
|
||||
<div v-else class="flex w-full flex-col items-center gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled>
|
||||
<nuxt-link
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:class="{ disabled: backupInProgress }"
|
||||
class="!w-full sm:!w-auto"
|
||||
:to="`/modpacks?sid=${props.server.serverId}`"
|
||||
>
|
||||
<CompassIcon class="size-4" /> Find a modpack
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<span class="hidden sm:block">or</span>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||
:disabled="!!backupInProgress"
|
||||
class="!w-full sm:!w-auto"
|
||||
@click="mrpackModal.show()"
|
||||
>
|
||||
<UploadIcon class="size-4" /> Upload .mrpack file
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">Platform</h2>
|
||||
<p class="m-0">Your server's platform is the software that runs mods and plugins.</p>
|
||||
<div v-if="data.upstream" class="mt-2 flex items-center gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
The current platform was automatically selected based on your modpack.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex w-full flex-col gap-1 rounded-2xl"
|
||||
:class="{
|
||||
'pointer-events-none cursor-not-allowed select-none opacity-50':
|
||||
props.server.general?.status === 'installing',
|
||||
}"
|
||||
:tabindex="props.server.general?.status === 'installing' ? -1 : 0"
|
||||
>
|
||||
<UiServersLoaderSelector
|
||||
:data="
|
||||
ignoreCurrentInstallation
|
||||
? {
|
||||
loader: null,
|
||||
loader_version: null,
|
||||
}
|
||||
: data
|
||||
"
|
||||
:is-installing="isInstalling"
|
||||
@select-loader="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewProjectCard } from "@modrinth/ui";
|
||||
import { TransferIcon, UploadIcon, InfoIcon, CompassIcon, SettingsIcon } from "@modrinth/assets";
|
||||
import type { Loaders } from "@modrinth/utils";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer;
|
||||
ignoreCurrentInstallation?: boolean;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?];
|
||||
}>();
|
||||
|
||||
const isInstalling = computed(() => props.server.general?.status === "installing");
|
||||
|
||||
const versionSelectModal = ref();
|
||||
const mrpackModal = ref();
|
||||
const modpackVersionModal = ref();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
const {
|
||||
data: versions,
|
||||
error: versionsError,
|
||||
refresh: refreshVersions,
|
||||
} = await useAsyncData(
|
||||
`content-loader-versions-${data.value?.upstream?.project_id}`,
|
||||
async () => {
|
||||
if (!data.value?.upstream?.project_id) return [];
|
||||
try {
|
||||
const result = await useBaseFetch(`project/${data.value.upstream.project_id}/version`);
|
||||
return result || [];
|
||||
} catch (e) {
|
||||
console.error("couldnt fetch all versions:", e);
|
||||
throw new Error("Failed to load modpack versions.");
|
||||
}
|
||||
},
|
||||
{ default: () => [] },
|
||||
);
|
||||
|
||||
const {
|
||||
data: currentVersion,
|
||||
error: currentVersionError,
|
||||
refresh: refreshCurrentVersion,
|
||||
} = await useAsyncData(
|
||||
`content-loader-version-${data.value?.upstream?.version_id}`,
|
||||
async () => {
|
||||
if (!data.value?.upstream?.version_id) return null;
|
||||
try {
|
||||
const result = await useBaseFetch(`version/${data.value.upstream.version_id}`);
|
||||
return result || null;
|
||||
} catch (e) {
|
||||
console.error("couldnt fetch version:", e);
|
||||
throw new Error("Failed to load modpack version.");
|
||||
}
|
||||
},
|
||||
{ default: () => null },
|
||||
);
|
||||
|
||||
const projectCardData = computed(() => ({
|
||||
icon_url: data.value?.project?.icon_url,
|
||||
title: data.value?.project?.title,
|
||||
description: data.value?.project?.description,
|
||||
downloads: data.value?.project?.downloads,
|
||||
follows: data.value?.project?.followers,
|
||||
// @ts-ignore
|
||||
date_modified: currentVersion.value?.date_published || data.value?.project?.updated,
|
||||
}));
|
||||
|
||||
const selectLoader = (loader: string) => {
|
||||
versionSelectModal.value?.show(loader as Loaders);
|
||||
};
|
||||
|
||||
const refreshData = async () => {
|
||||
await Promise.all([refreshVersions(), refreshCurrentVersion()]);
|
||||
};
|
||||
|
||||
const updateAvailable = computed(() => {
|
||||
// so sorry
|
||||
// @ts-ignore
|
||||
if (!data.value?.upstream || !versions.value?.length || !currentVersion.value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
const latestVersion = versions.value[0];
|
||||
// @ts-ignore
|
||||
return latestVersion.id !== currentVersion.value.id;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.server.general?.status,
|
||||
async (newStatus, oldStatus) => {
|
||||
if (oldStatus === "installing" && newStatus === "available") {
|
||||
await Promise.all([
|
||||
refreshVersions(),
|
||||
refreshCurrentVersion(),
|
||||
props.server.refresh(["general"]),
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
|
||||
.button-base:active {
|
||||
scale: none !important;
|
||||
}
|
||||
</style>
|
||||
@@ -43,7 +43,14 @@
|
||||
</div>
|
||||
<div v-else class="min-h-[20px]"></div>
|
||||
|
||||
<div
|
||||
v-if="isConfiguring"
|
||||
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
|
||||
>
|
||||
<SparklesIcon class="size-5 shrink-0" /> New server
|
||||
</div>
|
||||
<UiServersServerInfoLabels
|
||||
v-else
|
||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
@@ -73,13 +80,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, HammerIcon, LockIcon } from "@modrinth/assets";
|
||||
import type { Project, Server } from "~/types/servers";
|
||||
import { ChevronRightIcon, LockIcon, SparklesIcon } from "@modrinth/assets";
|
||||
import type { Project, Server } from "@modrinth/utils";
|
||||
import { useModrinthServers } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
if (props.server_id) {
|
||||
await usePyroServer(props.server_id, ["general"]);
|
||||
await useModrinthServers(props.server_id, ["general"]);
|
||||
}
|
||||
|
||||
const showGameLabel = computed(() => !!props.game);
|
||||
@@ -102,8 +110,9 @@ if (props.upstream) {
|
||||
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
|
||||
|
||||
if (import.meta.server && projectData.value?.icon_url) {
|
||||
await usePyroServer(props.server_id!, ["general"]);
|
||||
await useModrinthServers(props.server_id!, ["general"]);
|
||||
}
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||
const isConfiguring = computed(() => props.flows?.intro);
|
||||
</script>
|
||||
|
||||
@@ -34,15 +34,15 @@
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from "@modrinth/assets";
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
import type { BackupInProgressReason } from "~/pages/servers/manage/[id].vue";
|
||||
import { ModrinthServer } from "~/composables/servers/modrinth-servers.ts";
|
||||
|
||||
const emit = defineEmits(["reinstall"]);
|
||||
|
||||
defineProps<{
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||
route: RouteLocationNormalized;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: ModrinthServer;
|
||||
backupInProgress?: BackupInProgressReason;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
data-pyro-server-stats
|
||||
style="font-variant-numeric: tabular-nums"
|
||||
class="flex select-none flex-col items-center gap-6 md:flex-row"
|
||||
:class="{ 'pointer-events-none': loading }"
|
||||
:aria-hidden="loading"
|
||||
>
|
||||
<div
|
||||
v-for="(metric, index) in metrics"
|
||||
@@ -18,7 +20,7 @@
|
||||
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
|
||||
{{ metric.title }}
|
||||
<IssuesIcon
|
||||
v-if="metric.warning"
|
||||
v-if="metric.warning && !loading"
|
||||
v-tooltip="metric.warning"
|
||||
class="size-5"
|
||||
:style="{ color: 'var(--color-orange)' }"
|
||||
@@ -28,51 +30,76 @@
|
||||
<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.showGraph"
|
||||
type="area"
|
||||
height="142"
|
||||
: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>
|
||||
<component
|
||||
:is="metric.icon"
|
||||
class="absolute right-10 top-10 z-10 size-8"
|
||||
style="width: 2rem; height: 2rem"
|
||||
/>
|
||||
|
||||
<div class="chart-space absolute bottom-0 left-0 right-0">
|
||||
<ClientOnly>
|
||||
<VueApexCharts
|
||||
v-if="metric.showGraph && !loading"
|
||||
type="area"
|
||||
height="142"
|
||||
:options="getChartOptions(metric.warning, index)"
|
||||
:series="[{ name: metric.title, data: metric.data }]"
|
||||
class="chart"
|
||||
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink
|
||||
:to="`/servers/manage/${serverId}/files`"
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
|
||||
<component
|
||||
:is="loading ? 'div' : 'NuxtLink'"
|
||||
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
|
||||
{{ formatBytes(stats.storage_usage_bytes) }}
|
||||
{{ loading ? "0 B" : formatBytes(stats.storage_usage_bytes) }}
|
||||
</h2>
|
||||
</div>
|
||||
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
</NuxtLink>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, shallowRef } from "vue";
|
||||
import { FolderOpenIcon, CPUIcon, DBIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { FolderOpenIcon, CPUIcon, DatabaseIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import type { Stats } from "~/types/servers";
|
||||
import type { Stats } from "@modrinth/utils";
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id;
|
||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||
|
||||
const chartsReady = ref(new Set<number>());
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
ramAsNumber: false,
|
||||
});
|
||||
|
||||
const props = defineProps<{ data: Stats }>();
|
||||
const props = withDefaults(defineProps<{ data?: Stats; loading?: boolean }>(), {
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const stats = shallowRef(props.data.current);
|
||||
const stats = shallowRef(
|
||||
props.data?.current || {
|
||||
cpu_percent: 0,
|
||||
ram_usage_bytes: 0,
|
||||
ram_total_bytes: 1, // Avoid division by zero
|
||||
storage_usage_bytes: 0,
|
||||
},
|
||||
);
|
||||
|
||||
const onChartReady = (index: number) => {
|
||||
chartsReady.value.add(index);
|
||||
};
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
@@ -94,6 +121,29 @@ const updateGraphData = (arr: number[], newValue: number) => {
|
||||
};
|
||||
|
||||
const metrics = computed(() => {
|
||||
if (props.loading) {
|
||||
return [
|
||||
{
|
||||
title: "CPU usage",
|
||||
value: "0.00%",
|
||||
max: "100%",
|
||||
icon: CPUIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: false,
|
||||
warning: null,
|
||||
},
|
||||
{
|
||||
title: "Memory usage",
|
||||
value: "0.00%",
|
||||
max: "100%",
|
||||
icon: DatabaseIcon,
|
||||
data: ramData.value,
|
||||
showGraph: false,
|
||||
warning: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const ramPercent = Math.min(
|
||||
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
|
||||
100,
|
||||
@@ -119,7 +169,7 @@ const metrics = computed(() => {
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.toFixed(2)}%`,
|
||||
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
|
||||
icon: DBIcon,
|
||||
icon: DatabaseIcon,
|
||||
data: ramData.value,
|
||||
showGraph: true,
|
||||
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
|
||||
@@ -127,7 +177,7 @@ const metrics = computed(() => {
|
||||
];
|
||||
});
|
||||
|
||||
const getChartOptions = (hasWarning: string | null) => ({
|
||||
const getChartOptions = (hasWarning: string | null, index: number) => ({
|
||||
chart: {
|
||||
type: "area",
|
||||
animations: { enabled: false },
|
||||
@@ -139,6 +189,10 @@ const getChartOptions = (hasWarning: string | null) => ({
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
events: {
|
||||
mounted: () => onChartReady(index),
|
||||
updated: () => onChartReady(index),
|
||||
},
|
||||
},
|
||||
stroke: { curve: "smooth", width: 3 },
|
||||
fill: {
|
||||
@@ -172,24 +226,26 @@ const getChartOptions = (hasWarning: string | null) => ({
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.data.current,
|
||||
() => props.data?.current,
|
||||
(newStats) => {
|
||||
stats.value = newStats;
|
||||
if (newStats) {
|
||||
stats.value = newStats;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart {
|
||||
animation: fadeIn 0.2s ease-out 0.2s forwards;
|
||||
.chart-space {
|
||||
height: 142px;
|
||||
width: calc(100% + 48px);
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
width: calc(100% + 48px) !important;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
.chart {
|
||||
width: 100% !important;
|
||||
height: 142px !important;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -224,7 +224,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderIcon } from "@modrinth/assets";
|
||||
import type { Loaders } from "~/types/servers";
|
||||
import type { Loaders } from "@modrinth/utils";
|
||||
|
||||
defineProps<{
|
||||
loader: Loaders;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { RightArrowIcon, SparklesIcon, UnknownIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, ServersSpecs } from "@modrinth/ui";
|
||||
import type { MessageDescriptor } from "@vintl/vintl";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const { formatMessage, locale } = useVIntl();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "select" | "scroll-to-faq"): void;
|
||||
@@ -18,8 +18,8 @@ const plans: Record<
|
||||
accentText: string;
|
||||
accentBg: string;
|
||||
name: MessageDescriptor;
|
||||
symbol: MessageDescriptor;
|
||||
description: MessageDescriptor;
|
||||
mostPopular: boolean;
|
||||
}
|
||||
> = {
|
||||
small: {
|
||||
@@ -30,15 +30,11 @@ const plans: Record<
|
||||
id: "servers.plan.small.name",
|
||||
defaultMessage: "Small",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.small.symbol",
|
||||
defaultMessage: "S",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.small.description",
|
||||
defaultMessage:
|
||||
"Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.",
|
||||
defaultMessage: "Perfect for 1–5 friends with a few light mods.",
|
||||
}),
|
||||
mostPopular: false,
|
||||
},
|
||||
medium: {
|
||||
buttonColor: "green",
|
||||
@@ -48,14 +44,11 @@ const plans: Record<
|
||||
id: "servers.plan.medium.name",
|
||||
defaultMessage: "Medium",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.medium.symbol",
|
||||
defaultMessage: "M",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.medium.description",
|
||||
defaultMessage: "Great for modded multiplayer and small communities.",
|
||||
defaultMessage: "Great for 6–15 players and multiple mods.",
|
||||
}),
|
||||
mostPopular: true,
|
||||
},
|
||||
large: {
|
||||
buttonColor: "purple",
|
||||
@@ -65,14 +58,11 @@ const plans: Record<
|
||||
id: "servers.plan.large.name",
|
||||
defaultMessage: "Large",
|
||||
}),
|
||||
symbol: defineMessage({
|
||||
id: "servers.plan.large.symbol",
|
||||
defaultMessage: "L",
|
||||
}),
|
||||
description: defineMessage({
|
||||
id: "servers.plan.large.description",
|
||||
defaultMessage: "Ideal for larger communities, modpacks, and heavy modding.",
|
||||
defaultMessage: "Ideal for 15–25 players, modpacks, or heavy modding.",
|
||||
}),
|
||||
mostPopular: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -83,42 +73,30 @@ const props = defineProps<{
|
||||
storage: number;
|
||||
cpus: number;
|
||||
price: number;
|
||||
interval: "monthly" | "quarterly" | "yearly";
|
||||
currency: string;
|
||||
isUsa: boolean;
|
||||
}>();
|
||||
|
||||
const outOfStock = computed(() => {
|
||||
return !props.capacity || props.capacity === 0;
|
||||
});
|
||||
|
||||
const lowStock = computed(() => {
|
||||
return !props.capacity || props.capacity < 8;
|
||||
});
|
||||
|
||||
const formattedRam = computed(() => {
|
||||
return props.ram / 1024;
|
||||
});
|
||||
|
||||
const formattedStorage = computed(() => {
|
||||
return props.storage / 1024;
|
||||
});
|
||||
|
||||
const sharedCpus = computed(() => {
|
||||
return props.cpus / 2;
|
||||
const billingMonths = computed(() => {
|
||||
if (props.interval === "yearly") {
|
||||
return 12;
|
||||
} else if (props.interval === "quarterly") {
|
||||
return 3;
|
||||
}
|
||||
return 1;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="lowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl p-4 text-center font-bold"
|
||||
:class="outOfStock ? 'bg-bg-red' : 'bg-bg-orange'"
|
||||
>
|
||||
<template v-if="outOfStock"> Out of stock! </template>
|
||||
<template v-else> Only {{ capacity }} left in stock! </template>
|
||||
</div>
|
||||
<li class="relative flex w-full flex-col justify-between">
|
||||
<div
|
||||
:style="
|
||||
plan === 'medium'
|
||||
plans[plan].mostPopular
|
||||
? {
|
||||
background: `radial-gradient(
|
||||
86.12% 101.64% at 95.97% 94.07%,
|
||||
@@ -131,55 +109,41 @@ const sharedCpus = computed(() => {
|
||||
: undefined
|
||||
"
|
||||
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||
:class="{ '!rounded-t-none': lowStock }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-row items-center justify-between">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-row flex-wrap items-center gap-3">
|
||||
<h1 class="m-0">{{ formatMessage(plans[plan].name) }}</h1>
|
||||
<div
|
||||
class="grid size-8 place-content-center rounded-full text-xs font-bold"
|
||||
:class="`${plans[plan].accentBg} ${plans[plan].accentText}`"
|
||||
v-if="plans[plan].mostPopular"
|
||||
class="rounded-full bg-brand-highlight px-2 py-1 text-xs font-bold text-brand"
|
||||
>
|
||||
{{ formatMessage(plans[plan].symbol) }}
|
||||
Most popular
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">{{ formatMessage(plans[plan].description) }}</p>
|
||||
<div
|
||||
class="flex flex-row flex-wrap items-center gap-2 text-nowrap text-secondary xl:justify-between"
|
||||
>
|
||||
<p class="m-0">{{ formattedRam }} GB RAM</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">{{ formattedStorage }} GB SSD</p>
|
||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||
<p class="m-0">{{ sharedCpus }} Shared CPUs</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-secondary">
|
||||
<SparklesIcon /> Bursts up to {{ cpus }} CPUs
|
||||
<nuxt-link
|
||||
v-tooltip="
|
||||
`CPU bursting allows your server to temporarily use additional threads to help mitigate TPS spikes. Click for more info.`
|
||||
"
|
||||
to="/servers#cpu-burst"
|
||||
@click="() => emit('scroll-to-faq')"
|
||||
>
|
||||
<UnknownIcon class="h-4 w-4 text-secondary opacity-80" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<span class="m-0 text-2xl font-bold text-contrast">
|
||||
${{ price / 100 }}<span class="text-lg font-semibold text-secondary">/month</span>
|
||||
{{ formatPrice(locale, price / billingMonths, currency, true) }}
|
||||
{{ isUsa ? "" : currency }}
|
||||
<span class="text-lg font-semibold text-secondary">
|
||||
/ month<template v-if="interval !== 'monthly'">, billed {{ interval }}</template>
|
||||
</span>
|
||||
</span>
|
||||
<p class="m-0 max-w-[18rem]">{{ formatMessage(plans[plan].description) }}</p>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
:color="plans[plan].buttonColor"
|
||||
:type="plan === 'medium' ? 'standard' : 'highlight-colored-text'"
|
||||
:type="plans[plan].mostPopular ? 'standard' : 'highlight-colored-text'"
|
||||
size="large"
|
||||
>
|
||||
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
|
||||
<button v-else @click="() => emit('select')">
|
||||
Get Started
|
||||
<RightArrowIcon class="shrink-0" />
|
||||
</button>
|
||||
<button v-else @click="() => emit('select')">Select plan</button>
|
||||
</ButtonStyled>
|
||||
<ServersSpecs
|
||||
:ram="ram"
|
||||
:storage="storage"
|
||||
:cpus="cpus"
|
||||
:bursting-link="'/servers#cpu-burst'"
|
||||
@click-bursting-link="() => emit('scroll-to-faq')"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Accordion, ButtonStyled, NewModal, ServerNotice, TagItem } from "@modri
|
||||
import { PlusIcon, XIcon } from "@modrinth/assets";
|
||||
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import { ref } from "vue";
|
||||
import { usePyroFetch } from "~/composables/pyroFetch.ts";
|
||||
import { useServersFetch } from "~/composables/servers/servers-fetch.ts";
|
||||
|
||||
const app = useNuxtApp() as unknown as { $notify: any };
|
||||
|
||||
@@ -23,7 +23,7 @@ const assignedNodes = computed(() => assigned.value.filter((n) => n.kind === "no
|
||||
const inputField = ref("");
|
||||
|
||||
async function refresh() {
|
||||
await usePyroFetch("notices").then((res) => {
|
||||
await useServersFetch("notices").then((res) => {
|
||||
const notices = res as ServerNoticeType[];
|
||||
assigned.value = notices.find((n) => n.id === notice.value?.id)?.assigned ?? [];
|
||||
});
|
||||
@@ -33,9 +33,12 @@ async function assign(server: boolean = true) {
|
||||
const input = inputField.value.trim();
|
||||
|
||||
if (input !== "" && notice.value) {
|
||||
await usePyroFetch(`notices/${notice.value.id}/assign?${server ? "server" : "node"}=${input}`, {
|
||||
method: "PUT",
|
||||
}).catch((err) => {
|
||||
await useServersFetch(
|
||||
`notices/${notice.value.id}/assign?${server ? "server" : "node"}=${input}`,
|
||||
{
|
||||
method: "PUT",
|
||||
},
|
||||
).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error assigning notice",
|
||||
@@ -75,9 +78,12 @@ async function unassignDetect() {
|
||||
|
||||
async function unassign(id: string, server: boolean = true) {
|
||||
if (notice.value) {
|
||||
await usePyroFetch(`notices/${notice.value.id}/unassign?${server ? "server" : "node"}=${id}`, {
|
||||
method: "PUT",
|
||||
}).catch((err) => {
|
||||
await useServersFetch(
|
||||
`notices/${notice.value.id}/unassign?${server ? "server" : "node"}=${id}`,
|
||||
{
|
||||
method: "PUT",
|
||||
},
|
||||
).catch((err) => {
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error unassigning notice",
|
||||
|
||||
@@ -2,13 +2,8 @@
|
||||
import dayjs from "dayjs";
|
||||
import { ButtonStyled, commonMessages, CopyCode, ServerNotice, TagItem } from "@modrinth/ui";
|
||||
import { EditIcon, SettingsIcon, TrashIcon } from "@modrinth/assets";
|
||||
import { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import { useRelativeTime } from "@modrinth/ui";
|
||||
import {
|
||||
DISMISSABLE,
|
||||
getDismissableMetadata,
|
||||
NOTICE_LEVELS,
|
||||
} from "@modrinth/ui/src/utils/notices.ts";
|
||||
import type { ServerNotice as ServerNoticeType } from "@modrinth/utils";
|
||||
import { useRelativeTime, getDismissableMetadata, NOTICE_LEVELS } from "@modrinth/ui";
|
||||
import { useVIntl } from "@vintl/vintl";
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
|
||||
@@ -214,7 +214,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { OverflowMenu, MarkdownEditor } from "@modrinth/ui";
|
||||
import { CopyCode, OverflowMenu, MarkdownEditor } from "@modrinth/ui";
|
||||
import {
|
||||
DropdownIcon,
|
||||
ReplyIcon,
|
||||
@@ -226,7 +226,6 @@ import {
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||
import ThreadMessage from "~/components/ui/thread/ThreadMessage.vue";
|
||||
import { isStaff } from "~/helpers/users.js";
|
||||
import { isApproved, isRejected } from "~/helpers/projects.js";
|
||||
|
||||
@@ -103,10 +103,8 @@ import {
|
||||
ModrinthIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { AutoLink, OverflowMenu, useRelativeTime } from "@modrinth/ui";
|
||||
import { AutoLink, Avatar, Badge, OverflowMenu, useRelativeTime } from "@modrinth/ui";
|
||||
import { renderString } from "@modrinth/utils";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import { isStaff } from "~/helpers/users.js";
|
||||
|
||||
const props = defineProps({
|
||||
|
||||
Reference in New Issue
Block a user