Merge commit '90def724c28a2eacf173f4987e195dc14908f25d' into feature-clean

This commit is contained in:
2025-02-25 22:45:08 +03:00
83 changed files with 3417 additions and 1901 deletions

View File

@@ -460,6 +460,10 @@
class="new-page sidebar"
:class="{
'alt-layout': cosmetics.leftContentLayout,
'ultimate-sidebar':
showModerationChecklist &&
!collapsedModerationChecklist &&
!flags.alwaysShowChecklistAsPopup,
}"
>
<div class="normal-page__header relative my-4">
@@ -674,7 +678,7 @@
:auth="auth"
:tags="tags"
/>
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="mb-4">
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="my-4">
{{ project.title }} has been archived. {{ project.title }} will not receive any further
updates unless the author decides to unarchive the project.
</MessageBanner>
@@ -805,13 +809,18 @@
@delete-version="deleteVersion"
/>
</div>
<div class="normal-page__ultimate-sidebar">
<ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project"
:future-projects="futureProjects"
:reset-project="resetProject"
:collapsed="collapsedModerationChecklist"
@exit="showModerationChecklist = false"
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
/>
</div>
</div>
<ModerationChecklist
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
:project="project"
:future-projects="futureProjects"
:reset-project="resetProject"
/>
</div>
</template>
<script setup>
@@ -1431,6 +1440,7 @@ async function copyId() {
const collapsedChecklist = ref(false);
const showModerationChecklist = ref(false);
const collapsedModerationChecklist = ref(false);
const futureProjects = ref([]);
if (import.meta.client && history && history.state && history.state.showChecklist) {
showModerationChecklist.value = true;

View File

@@ -8,21 +8,25 @@
<span class="label__subdescription">
The description must clearly and honestly describe the purpose and function of the
project. See section 2.1 of the
<nuxt-link to="/legal/rules" class="text-link" target="_blank">Content Rules</nuxt-link>
<nuxt-link class="text-link" target="_blank" to="/legal/rules">Content Rules</nuxt-link>
for the full requirements.
</span>
</span>
</div>
<MarkdownEditor
v-model="description"
:disabled="
!currentMember ||
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !==
TeamMemberPermission.EDIT_BODY
"
:on-image-upload="onUploadHandler"
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
/>
<div class="input-group markdown-disclaimer">
<button
type="button"
class="iconified-button brand-button"
:disabled="!hasChanges"
class="iconified-button brand-button"
type="button"
@click="saveChanges()"
>
<SaveIcon />
@@ -33,91 +37,50 @@
</div>
</template>
<script>
<script lang="ts" setup>
import { SaveIcon } from "@modrinth/assets";
import { MarkdownEditor } from "@modrinth/ui";
import Chips from "~/components/ui/Chips.vue";
import SaveIcon from "~/assets/images/utils/save.svg?component";
import { renderHighlightedString } from "~/helpers/highlight.js";
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
import { computed, ref } from "vue";
import { useImageUpload } from "~/composables/image-upload.ts";
export default defineNuxtComponent({
components: {
Chips,
SaveIcon,
MarkdownEditor,
},
props: {
project: {
type: Object,
default() {
return {};
},
},
allMembers: {
type: Array,
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 {
description: this.project.body,
bodyViewMode: "source",
};
},
computed: {
patchData() {
const data = {};
const props = defineProps<{
project: Project;
allMembers: TeamMember[];
currentMember: TeamMember | undefined;
patchProject: (payload: object, quiet?: boolean) => object;
}>();
if (this.description !== this.project.body) {
data.body = this.description;
}
const description = ref(props.project.body);
return data;
},
hasChanges() {
return Object.keys(this.patchData).length > 0;
},
},
created() {
this.EDIT_BODY = 1 << 3;
},
methods: {
renderHighlightedString,
saveChanges() {
if (this.hasChanges) {
this.patchProject(this.patchData);
}
},
async onUploadHandler(file) {
const response = await useImageUpload(file, {
context: "project",
projectID: this.project.id,
});
return response.url;
},
},
const patchRequestPayload = computed(() => {
const payload: {
body?: string;
} = {};
if (description.value !== props.project.body) {
payload.body = description.value;
}
return payload;
});
const hasChanges = computed(() => {
return Object.keys(patchRequestPayload.value).length > 0;
});
function saveChanges() {
props.patchProject(patchRequestPayload.value);
}
async function onUploadHandler(file: File) {
const response = await useImageUpload(file, {
context: "project",
projectID: props.project.id,
});
return response.url;
}
</script>
<style scoped>

View File

@@ -640,7 +640,6 @@ import Badge from "~/components/ui/Badge.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CopyCode from "~/components/ui/CopyCode.vue";
import Categories from "~/components/ui/search/Categories.vue";
import Chips from "~/components/ui/Chips.vue";
import Checkbox from "~/components/ui/Checkbox.vue";
import FileInput from "~/components/ui/FileInput.vue";
@@ -663,6 +662,7 @@ import Modal from "~/components/ui/Modal.vue";
import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component";
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
export default defineNuxtComponent({
components: {
MarkdownEditor,
@@ -670,7 +670,6 @@ export default defineNuxtComponent({
FileInput,
Checkbox,
ChevronRightIcon,
Chips,
Categories,
DownloadIcon,
EditIcon,

View File

@@ -40,12 +40,7 @@
</span>
<span> Whether or not the subscription should be unprovisioned on refund. </span>
</label>
<Toggle
id="unprovision"
:model-value="unprovision"
:checked="unprovision"
@update:model-value="() => (unprovision = !unprovision)"
/>
<Toggle id="unprovision" v-model="unprovision" />
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
@@ -114,7 +109,7 @@
</div>
</template>
<script setup>
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
import { Badge, ButtonStyled, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
import { formatPrice } from "@modrinth/utils";
import { CheckIcon, XIcon } from "@modrinth/assets";
import { products } from "~/generated/state.json";

View File

@@ -0,0 +1,61 @@
<template>
<div class="normal-page no-sidebar">
<h1>User account request</h1>
<div class="normal-page__content">
<div class="card flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="name">
<span class="text-lg font-semibold text-contrast">
User email
<span class="text-brand-red">*</span>
</span>
</label>
<input
id="name"
v-model="userEmail"
type="email"
maxlength="64"
:placeholder="`Enter user email...`"
autocomplete="off"
/>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button @click="getUserFromEmail">
<MailIcon aria-hidden="true" />
Get user account
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from "@modrinth/ui";
import { MailIcon } from "@modrinth/assets";
const userEmail = ref("");
async function getUserFromEmail() {
startLoading();
try {
const result = await useBaseFetch(`user_email?email=${encodeURIComponent(userEmail.value)}`, {
method: "GET",
apiVersion: 3,
});
await navigateTo(`/user/${result.username}`);
} catch (err) {
console.error(err);
addNotification({
group: "main",
title: "An error occurred",
text: err.data.description,
type: "error",
});
}
stopLoading();
}
</script>

View File

@@ -365,29 +365,29 @@
<script setup>
import {
BoxIcon,
CalendarIcon,
EditIcon,
XIcon,
SaveIcon,
UploadIcon,
TrashIcon,
LinkIcon,
LockIcon,
GridIcon,
ImageIcon,
ListIcon,
UpdatedIcon,
LibraryIcon,
BoxIcon,
LinkIcon,
ListIcon,
LockIcon,
SaveIcon,
TrashIcon,
UpdatedIcon,
UploadIcon,
XIcon,
} from "@modrinth/assets";
import {
PopoutMenu,
FileInput,
DropdownSelect,
Avatar,
Button,
commonMessages,
ConfirmModal,
DropdownSelect,
FileInput,
PopoutMenu,
} from "@modrinth/ui";
import { isAdmin } from "@modrinth/utils";
@@ -651,7 +651,7 @@ async function saveChanges() {
method: "PATCH",
body: {
name: name.value,
description: summary.value,
description: summary.value || null,
status: visibility.value,
new_projects: newProjectIds,
},

View File

@@ -49,7 +49,9 @@
</div>
</nuxt-link>
<nuxt-link
v-for="collection in orderedCollections"
v-for="collection in orderedCollections.sort(
(a, b) => new Date(b.created) - new Date(a.created),
)"
:key="collection.id"
:to="`/collection/${collection.id}`"
class="universal-card recessed collection"

View File

@@ -50,7 +50,7 @@
</div>
</template>
<script setup>
import { Button } from "@modrinth/ui";
import { Button, Chips } from "@modrinth/ui";
import { HistoryIcon } from "@modrinth/assets";
import {
fetchExtraNotificationData,
@@ -58,7 +58,6 @@ import {
markAsRead,
} from "~/helpers/notifications.js";
import NotificationItem from "~/components/ui/NotificationItem.vue";
import Chips from "~/components/ui/Chips.vue";
import CheckCheckIcon from "~/assets/images/utils/check-check.svg?component";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import Pagination from "~/components/ui/Pagination.vue";

View File

@@ -1,39 +1,95 @@
<template>
<div>
<div class="experimental-styles-within">
<section class="universal-card">
<h2 class="text-2xl">Revenue</h2>
<div v-if="userBalance.available >= minWithdraw">
<p>
You have
<strong>{{ $formatMoney(userBalance.available) }}</strong>
available to withdraw. <strong>{{ $formatMoney(userBalance.pending) }}</strong> of your
balance is <nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
</p>
<div class="grid-display">
<div class="grid-display__item">
<div class="label">Available now</div>
<div class="value">
{{ $formatMoney(userBalance.available) }}
</div>
</div>
<div class="grid-display__item">
<div class="label">
Total pending
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</div>
<div class="value">
{{ $formatMoney(userBalance.pending) }}
</div>
</div>
<div class="grid-display__item">
<h3 class="label m-0">
Available soon
<nuxt-link
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
class="align-middle text-link"
to="/legal/cmp-info#pending"
>
<UnknownIcon />
</nuxt-link>
</h3>
<ul class="m-0 list-none p-0">
<li
v-for="date in availableSoonDateKeys"
:key="date"
class="flex items-center justify-between border-0 border-solid border-b-divider p-0 [&:not(:last-child)]:mb-1 [&:not(:last-child)]:border-b-[1px] [&:not(:last-child)]:pb-1"
>
<span
v-tooltip="
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1
? `Revenue period is ongoing. \nThis amount is not yet finalized.`
: null
"
:class="{
'cursor-help':
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1,
}"
class="inline-flex items-center gap-1 font-bold"
>
{{ $formatMoney(availableSoonDates[date]) }}
<template
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
>
<InProgressIcon />
</template>
</span>
<span class="text-sm text-secondary">
{{ formatDate(dayjs(date)) }}
</span>
</li>
</ul>
</div>
</div>
<p v-else>
You have made
<strong>{{ $formatMoney(userBalance.available) }}</strong
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
<strong>{{ $formatMoney(userBalance.pending) }}</strong> of your balance is
<nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
</p>
<div class="input-group mt-4">
<nuxt-link
v-if="userBalance.available >= minWithdraw"
class="iconified-button brand-button"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
<nuxt-link
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
class="iconified-button brand-button"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
</span>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon /> View transfer history
<HistoryIcon />
View transfer history
</NuxtLink>
</div>
<p>
<p class="text-sm text-secondary">
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
information on how the rewards system works, see our information page
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>.
<nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
</p>
</section>
<section class="universal-card">
@@ -46,12 +102,13 @@
{{ auth.user.payout_data.paypal_address }}
</p>
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
<XIcon /> Disconnect account
<XIcon />
Disconnect account
</button>
</template>
<template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
<a class="btn mt-4" :href="`${getAuthUrl('paypal')}&token=${auth.token}`">
<a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
<PayPalIcon />
Sign in with PayPal
</a>
@@ -60,7 +117,8 @@
<p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>.
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
.
</p>
<h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
@@ -68,18 +126,32 @@
<input
id="venmo"
v-model="auth.user.payout_data.venmo_handle"
autocomplete="off"
class="mt-4"
type="search"
name="search"
placeholder="@example"
autocomplete="off"
type="search"
/>
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button>
<button class="btn btn-secondary" @click="updateVenmo">
<SaveIcon />
Save information
</button>
</section>
</div>
</template>
<script setup>
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from "@modrinth/assets";
import {
HistoryIcon,
InProgressIcon,
PayPalIcon,
SaveIcon,
TransferIcon,
UnknownIcon,
XIcon,
} from "@modrinth/assets";
import { formatDate } from "@modrinth/utils";
import dayjs from "dayjs";
import { computed } from "vue";
const auth = await useAuth();
const minWithdraw = ref(0.01);
@@ -88,6 +160,33 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
);
const deadlineEnding = computed(() => {
let deadline = dayjs().subtract(2, "month").endOf("month").add(60, "days");
if (deadline.isBefore(dayjs().startOf("day"))) {
deadline = dayjs().subtract(1, "month").endOf("month").add(60, "days");
}
return deadline;
});
const availableSoonDates = computed(() => {
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
const dates = Object.keys(userBalance.value.dates)
.filter((date) => {
const dateObj = dayjs(date);
return (
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, "month"))
);
})
.sort((a, b) => dayjs(a).diff(dayjs(b)));
return dates.reduce((acc, date) => {
acc[date] = userBalance.value.dates[date];
return acc;
}, {});
});
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value));
async function updateVenmo() {
startLoading();
try {
@@ -118,4 +217,16 @@ strong {
color: var(--color-text-dark);
font-weight: 500;
}
.disabled-cursor-wrapper {
cursor: not-allowed;
}
.disabled-link {
pointer-events: none;
}
.grid-display {
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
}
</style>

View File

@@ -1,7 +1,7 @@
<template>
<div class="markdown-body">
<h1>Rewards Program Information</h1>
<p><em>Last modified: Sep 12, 2024</em></p>
<p><em>Last modified: Feb 20, 2025</em></p>
<p>
This page was created for transparency for how the rewards program works on Modrinth. Feel
free to join our Discord or email
@@ -82,42 +82,41 @@
<p>
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
revenue is immediately available to withdraw. We pay creators as soon as we receive the money
from our ad providers, which is 60 days after the last day of each month. This table outlines
some example dates of how NET 60 payments are made:
from our ad providers, which is 60 days after the last day of each month.
</p>
<p>
To understand when revenue becomes available, you can use this calculator to estimate when
revenue earned on a specific date will be available for withdrawal. Please be advised that all
dates within this calculator are represented at 00:00 UTC.
</p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Payment available date</th>
</tr>
</thead>
<tbody>
<tr>
<td>January 1st</td>
<td>March 31st</td>
</tr>
<tr>
<td>January 15th</td>
<td>March 31st</td>
</tr>
<tr>
<td>March 3rd</td>
<td>May 30th</td>
</tr>
<tr>
<td>June 30th</td>
<td>August 29th</td>
</tr>
<tr>
<td>July 14th</td>
<td>September 29th</td>
</tr>
<tr>
<td>October 12th</td>
<td>December 30th</td>
</tr>
</tbody>
<tr>
<th>Timeline</th>
<th>Date</th>
</tr>
<tr>
<td>Revenue earned on</td>
<td>
<input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<noscript
>(JavaScript must be enabled for the date picker to function, example date: 2024-07-15)
</noscript>
</td>
</tr>
<tr>
<td>End of the month</td>
<td>{{ formatDate(endOfMonthDate) }}</td>
</tr>
<tr>
<td>NET 60 policy applied</td>
<td>+ 60 days</td>
</tr>
<tr class="final-result">
<td>Available for withdrawal</td>
<td>{{ formatDate(withdrawalDate) }}</td>
</tr>
</table>
<h3>How do I know Modrinth is being transparent about revenue?</h3>
<p>
@@ -127,12 +126,40 @@
revenue distribution system</a
>. We also have an
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
to query exact daily revenue for the site.
to query exact daily revenue for the site - so far, Modrinth has generated
<strong>{{ formatMoney(platformRevenue) }}</strong> in revenue.
</p>
<table>
<thead>
<tr>
<th>Date</th>
<th>Revenue</th>
<th>Creator Revenue (75%)</th>
<th>Modrinth's Cut (25%)</th>
</tr>
</thead>
<tbody>
<tr v-for="item in platformRevenueData" :key="item.time">
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
<td>{{ formatMoney(item.revenue) }}</td>
<td>{{ formatMoney(item.creator_revenue) }}</td>
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
</tr>
</tbody>
</table>
<small
>Modrinth's total revenue in the previous 5 days, for the entire dataset, use the
aforementioned
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>.</small
>
</div>
</template>
<script setup>
<script lang="ts" setup>
import dayjs from "dayjs";
import { computed, ref } from "vue";
import { formatDate, formatMoney } from "@modrinth/utils";
const description =
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
@@ -142,4 +169,18 @@ useSeoMeta({
ogTitle: "Rewards Program Information",
ogDescription: description,
});
const rawSelectedDate = ref(dayjs().format("YYYY-MM-DD"));
const selectedDate = computed(() => dayjs(rawSelectedDate.value));
const endOfMonthDate = computed(() => selectedDate.value.endOf("month"));
const withdrawalDate = computed(() => endOfMonthDate.value.add(60, "days"));
const { data: transparencyInformation } = await useAsyncData("payout/platform_revenue", () =>
useBaseFetch("payout/platform_revenue", {
apiVersion: 3,
}),
);
const platformRevenue = transparencyInformation.value.all_time;
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
</script>

View File

@@ -101,7 +101,7 @@
</section>
</template>
<script setup>
import Chips from "~/components/ui/Chips.vue";
import { Chips } from "@modrinth/ui";
import Avatar from "~/components/ui/Avatar.vue";
import UnknownIcon from "~/assets/images/utils/unknown.svg?component";
import EyeIcon from "~/assets/images/utils/eye.svg?component";

View File

@@ -258,7 +258,8 @@
<button
v-if="
result.installed ||
server.content.data.find((x) => x.project_id === result.project_id) ||
(server?.content?.data &&
server.content.data.find((x) => x.project_id === result.project_id)) ||
server.general?.project?.id === result.project_id
"
disabled
@@ -376,7 +377,9 @@ async function updateServerContext() {
if (!auth.value.user) {
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
} else if (route.query.sid !== null) {
server.value = await usePyroServer(route.query.sid, ["general", "content"]);
server.value = await usePyroServer(route.query.sid, ["general", "content"], {
waitForModules: true,
});
}
}
@@ -495,8 +498,8 @@ async function serverInstall(project) {
) ?? versions[0];
if (projectType.value.id === "modpack") {
await server.value.general?.reinstall(
route.query.sid,
await server.value.general.reinstall(
server.value.serverId,
false,
project.project_id,
version.id,
@@ -504,7 +507,7 @@ async function serverInstall(project) {
eraseDataOnInstall.value,
);
project.installed = true;
navigateTo(`/servers/manage/${route.query.sid}/options/loader`);
navigateTo(`/servers/manage/${server.value.serverId}/options/loader`);
} else if (projectType.value.id === "mod") {
await server.value.content.install("mod", version.project_id, version.id);
await server.value.refresh(["content"]);

View File

@@ -456,9 +456,10 @@
Where are Modrinth Servers located? Can I choose a region?
</summary>
<p class="m-0 !leading-[190%]">
Currently, Modrinth Servers are located in New York, Los Angeles, Seattle, and
Miami. More regions are coming soon! Your server's location is currently chosen
algorithmically, but you will be able to choose a region in the future.
Currently, Modrinth Servers are located throughout the United States in New York,
Los Angelas, Dallas, Miami, and Spokane. More regions are coming soon! Your server's
location is currently chosen algorithmically, but you will be able to choose a
region in the future.
</p>
</details>
@@ -539,9 +540,9 @@
<p
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
>
With strategically placed servers in New York, Los Angeles, Seattle, and Miami, we
ensure low latency connections for players across North America. Each location is
equipped with high-performance hardware and DDoS protection.
With strategically placed servers in New York, California, Texas, Florida, and
Washington, we ensure low latency connections for players across North America.
Each location is equipped with high-performance hardware and DDoS protection.
</p>
</div>
@@ -640,15 +641,15 @@
</h2>
</div>
<ButtonStyled color="blue" size="large">
<NuxtLink
<a
v-if="!loggedOut && isSmallAtCapacity"
:to="outOfStockUrl"
:href="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-blue !font-medium !text-blue"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
</NuxtLink>
</a>
<button
v-else
class="!bg-highlight-blue !font-medium !text-blue"
@@ -703,15 +704,15 @@
</h2>
</div>
<ButtonStyled color="brand" size="large">
<NuxtLink
<a
v-if="!loggedOut && isMediumAtCapacity"
:to="outOfStockUrl"
:href="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-green !font-medium !text-green"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
</NuxtLink>
</a>
<button
v-else
class="!bg-highlight-green !font-medium !text-green"
@@ -757,15 +758,15 @@
</h2>
</div>
<ButtonStyled color="brand" size="large">
<NuxtLink
<a
v-if="!loggedOut && isLargeAtCapacity"
:to="outOfStockUrl"
:href="outOfStockUrl"
target="_blank"
class="flex items-center gap-2 !bg-highlight-purple !font-medium !text-purple"
>
Out of Stock
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
</NuxtLink>
</a>
<button
v-else
class="!bg-highlight-purple !font-medium !text-purple"
@@ -871,7 +872,7 @@ const deletingSpeed = 25;
const pauseTime = 2000;
const loggedOut = computed(() => !auth.value.user);
const outOfStockUrl = "https://support.modrinth.com";
const outOfStockUrl = "https://discord.modrinth.com";
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
try {

View File

@@ -10,10 +10,10 @@
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Upgrading</h1>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
</div>
<p class="text-lg text-secondary">
Your server's hardware is currently being upgraded and will be back online shortly.
Your server's hardware is currently being upgraded and will be back online shortly!
</p>
</div>
</div>
@@ -47,17 +47,18 @@
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<LockIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Suspended</h1>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
</div>
<p class="text-lg text-secondary">
{{
serverData.suspension_reason
? `Your server has been suspended: ${serverData.suspension_reason}`
: "Your server has been suspended."
serverData.suspension_reason === "cancelled"
? "Your subscription has been cancelled."
: serverData.suspension_reason
? `Your server has been suspended: ${serverData.suspension_reason}`
: "Your server has been suspended."
}}
<br />
This is most likely due to a billing issue. Please check your billing information and
contact Modrinth support if you believe this is an error.
Contact Modrinth support if you believe this is an error.
</p>
</div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
@@ -66,7 +67,10 @@
</div>
</div>
<div
v-else-if="server.error && server.error.message.includes('Forbidden')"
v-else-if="
server.general?.error?.error.statusCode === 403 ||
server.general?.error?.error.statusCode === 404
"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -82,14 +86,15 @@
this is an error, please contact Modrinth support.
</p>
</div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="server.error && server.error.message.includes('Service Unavailable')"
v-else-if="server.general?.error?.error.statusCode === 503"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -141,7 +146,7 @@
</div>
</div>
<div
v-else-if="server.error"
v-else-if="server.general?.error"
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
@@ -164,7 +169,7 @@
temporary network issue. You'll be reconnected automatically.
</p>
</div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled
:disabled="formattedTime !== '00'"
size="large"
@@ -228,7 +233,7 @@
:show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds"
:linked="true"
class="flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
@@ -363,7 +368,6 @@
</div>
</div>
</div>
<NuxtPage
:route="route"
:is-connected="isConnected"
@@ -425,21 +429,25 @@ const createdAt = ref(
const route = useNativeRoute();
const router = useRouter();
const serverId = route.params.id as string;
const server = await usePyroServer(serverId, [
"general",
"content",
"backups",
"network",
"startup",
"ws",
"fs",
]);
const server = await usePyroServer(serverId, ["general", "ws"]);
const loadModulesPromise = Promise.resolve().then(() => {
if (server.general?.status === "suspended") {
return;
}
return server.loadModules(["content", "backups", "network", "startup", "fs"]);
});
provide("modulesLoaded", loadModulesPromise);
watch(
() => server.error,
(newError) => {
() => [server.general?.error, server.ws?.error],
([generalError, wsError]) => {
if (server.general?.status === "suspended") return;
if (newError && !newError.message.includes("Forbidden")) {
const error = generalError?.error || wsError?.error;
if (error && error.statusCode !== 403) {
startPolling();
}
},
@@ -450,11 +458,9 @@ const errorMessage = ref("An unexpected error occurred.");
const errorLog = ref("");
const errorLogFile = ref("");
const serverData = computed(() => server.general);
const error = ref<Error | null>(null);
const isConnected = ref(false);
const isWSAuthIncorrect = ref(false);
const pyroConsole = usePyroConsole();
console.log("||||||||||||||||||||||| console", pyroConsole.output);
const cpuData = ref<number[]>([]);
const ramData = ref<number[]>([]);
const isActioning = ref(false);
@@ -465,6 +471,7 @@ const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>();
const uptimeSeconds = ref(0);
const firstConnect = ref(true);
const copied = ref(false);
const error = ref<Error | null>(null);
const initialConsoleMessage = [
" __________________________________________________",
@@ -665,6 +672,26 @@ const newLoader = ref<string | null>(null);
const newLoaderVersion = ref<string | null>(null);
const newMCVersion = ref<string | null>(null);
const onReinstall = (potentialArgs: any) => {
if (!serverData.value) return;
serverData.value.status = "installing";
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader;
}
if (potentialArgs?.lVersion) {
newLoaderVersion.value = potentialArgs.lVersion;
}
if (potentialArgs?.mVersion) {
newMCVersion.value = potentialArgs.mVersion;
}
error.value = null;
errorTitle.value = "Error";
errorMessage.value = "An unexpected error occurred.";
};
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
switch (data.result) {
case "ok": {
@@ -738,26 +765,6 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
}
};
const onReinstall = (potentialArgs: any) => {
if (!serverData.value) return;
serverData.value.status = "installing";
if (potentialArgs?.loader) {
newLoader.value = potentialArgs.loader;
}
if (potentialArgs?.lVersion) {
newLoaderVersion.value = potentialArgs.lVersion;
}
if (potentialArgs?.mVersion) {
newMCVersion.value = potentialArgs.mVersion;
}
error.value = null;
errorTitle.value = "Error";
errorMessage.value = "An unexpected error occurred.";
};
const updateStats = (currentStats: Stats["current"]) => {
isConnected.value = true;
stats.value = {
@@ -924,6 +931,10 @@ const cleanup = () => {
onMounted(() => {
isMounted.value = true;
if (server.general?.status === "suspended") {
isLoading.value = false;
return;
}
if (server.error) {
if (!server.error.message.includes("Forbidden")) {
startPolling();
@@ -991,7 +1002,7 @@ definePageMeta({
});
</script>
<style scoped>
<style>
@keyframes server-action-buttons-anim {
0% {
opacity: 0;

View File

@@ -1,6 +1,30 @@
<template>
<div class="contents">
<div v-if="data" class="contents">
<div
v-if="server.backups?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's backups. Here's what went wrong:
</p>
<p>
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="contents">
<LazyUiServersBackupCreateModal
ref="createBackupModal"
:server="server"
@@ -241,6 +265,7 @@ import {
BoxIcon,
LockIcon,
LockOpenIcon,
IssuesIcon,
} from "@modrinth/assets";
import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers";
@@ -297,33 +322,37 @@ const showbackupSettingsModal = () => {
backupSettingsModal.value?.show();
};
const handleBackupCreated = (payload: { success: boolean; message: string }) => {
const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupRenamed = (payload: { success: boolean; message: string }) => {
const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupRestored = (payload: { success: boolean; message: string }) => {
const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else {
addNotification({ type: "error", text: payload.message });
}
};
const handleBackupDeleted = (payload: { success: boolean; message: string }) => {
const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
if (payload.success) {
addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else {
addNotification({ type: "error", text: payload.message });
}
@@ -387,8 +416,8 @@ onMounted(() => {
}
if (hasOngoingBackups) {
refreshInterval.value = setInterval(() => {
props.server.refresh(["backups"]);
refreshInterval.value = setInterval(async () => {
await props.server.refresh(["backups"]);
}, 10000);
}
});

View File

@@ -10,7 +10,30 @@
@change-version="changeModVersion($event)"
/>
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div
v-if="server.content?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
<span class="break-all font-mono">{{ JSON.stringify(server.content.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col">
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
@@ -322,6 +345,7 @@ import {
WrenchIcon,
ListIcon,
FileIcon,
IssuesIcon,
} from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted, onUnmounted } from "vue";

View File

@@ -189,6 +189,8 @@ const props = defineProps<{
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>();
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const route = useRoute();
const router = useRouter();
@@ -245,6 +247,8 @@ useHead({
});
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
await modulesLoaded;
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
try {
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
@@ -719,7 +723,22 @@ const editFile = async (item: { name: string; type: string; path: string }) => {
}
};
const initializeFileEdit = async () => {
if (!route.query.editing || !props.server.fs) return;
const filePath = route.query.editing as string;
await editFile({
name: filePath.split("/").pop() || "",
type: "file",
path: filePath,
});
};
onMounted(async () => {
await modulesLoaded;
await initializeFileEdit();
await import("ace-builds");
await import("ace-builds/src-noconflict/mode-json");
await import("ace-builds/src-noconflict/mode-yaml");

View File

@@ -169,7 +169,7 @@
</div>
</div>
</div>
<div v-else-if="!isConnected && !isWsAuthIncorrect" />
<UiServersOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
<h2>Could not connect to the server.</h2>
<p>
@@ -244,19 +244,31 @@ interface ErrorData {
const inspectingError = ref<ErrorData | null>(null);
const inspectError = async () => {
const log = await props.server.fs?.downloadFile("logs/latest.log");
// @ts-ignore
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
content: log,
}),
})) as ErrorData;
try {
const log = await props.server.fs?.downloadFile("logs/latest.log");
if (!log) return;
inspectingError.value = analysis;
// @ts-ignore
const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
content: log,
}),
});
// @ts-ignore
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
inspectingError.value = response as ErrorData;
} else {
inspectingError.value = null;
}
} catch (error) {
console.error("Failed to analyze logs:", error);
inspectingError.value = null;
}
};
const clearError = () => {
@@ -266,7 +278,7 @@ const clearError = () => {
watch(
() => props.serverPowerState,
(newVal) => {
if (newVal === "crashed") {
if (newVal === "crashed" && !props.powerStateDetails?.oom_killed) {
inspectError();
} else {
clearError();
@@ -274,7 +286,7 @@ watch(
},
);
if (props.serverPowerState === "crashed") {
if (props.serverPowerState === "crashed" && !props.powerStateDetails?.oom_killed) {
inspectError();
}

View File

@@ -5,7 +5,7 @@
<div class="card flex flex-col gap-4">
<label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server name</span>
<span> Change your server's name. This name is only visible on Modrinth.</span>
<span> This name is only visible on Modrinth.</span>
</label>
<div class="flex flex-col gap-2">
<input
@@ -64,10 +64,7 @@
<div class="card flex flex-col gap-4">
<label for="server-icon-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server icon</span>
<span>
Change your server's icon. Changes will be visible on the Minecraft server list and on
Modrinth.
</span>
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
</label>
<div class="flex gap-4">
<div
@@ -91,20 +88,7 @@
>
<EditIcon class="h-8 w-8 text-contrast" />
</div>
<img
v-if="icon"
no-shadow
alt="Server Icon"
class="h-[6rem] w-[6rem] rounded-xl"
:src="icon"
/>
<img
v-else
no-shadow
alt="Server Icon"
class="h-[6rem] w-[6rem] rounded-xl"
src="~/assets/images/servers/minecraft_server_icon.png"
/>
<UiServersServerIcon :image="icon" />
</div>
<ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
@@ -234,67 +218,106 @@ const resetGeneral = () => {
const uploadFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0];
// down scale the image to 64x64
if (!file) {
addNotification({
group: "serverOptions",
type: "error",
title: "No file selected",
text: "Please select a file to upload.",
});
return;
}
const scaledFile = await new Promise<File>((resolve, reject) => {
if (!file) {
addNotification({
group: "serverOptions",
type: "error",
title: "No file selected",
text: "Please select a file to upload.",
});
reject(new Error("No file selected"));
return;
}
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
// turn the downscaled image back to a png file
canvas.toBlob((blob) => {
if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" });
resolve(data);
resolve(new File([blob], "server-icon.png", { type: "image/png" }));
} else {
reject(new Error("Canvas toBlob failed"));
}
}, "image/png");
URL.revokeObjectURL(img.src);
};
img.onerror = reject;
img.src = URL.createObjectURL(file);
});
if (!file) return;
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
}
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
await props.server.fs?.uploadFile("/server-icon-original.png", file);
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
title: "Server icon updated",
text: "Your server icon was successfully changed.",
});
try {
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
}
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
await props.server.fs?.uploadFile("/server-icon-original.png", file);
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
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");
useState(`server-icon-${props.server.serverId}`).value = dataURL;
if (data.value) data.value.image = dataURL;
resolve();
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
addNotification({
group: "serverOptions",
type: "success",
title: "Server icon updated",
text: "Your server icon was successfully changed.",
});
} catch (error) {
console.error("Error uploading icon:", error);
addNotification({
group: "serverOptions",
type: "error",
title: "Upload failed",
text: "Failed to upload server icon.",
});
}
};
const resetIcon = async () => {
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
await new Promise((resolve) => setTimeout(resolve, 2000));
await reloadNuxtApp();
addNotification({
group: "serverOptions",
type: "success",
title: "Server icon reset",
text: "Your server icon was successfully reset.",
});
try {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
useState(`server-icon-${props.server.serverId}`).value = undefined;
if (data.value) data.value.image = undefined;
await props.server.refresh(["general"]);
addNotification({
group: "serverOptions",
type: "success",
title: "Server icon reset",
text: "Your server icon was successfully reset.",
});
} catch (error) {
console.error("Error resetting icon:", error);
addNotification({
group: "serverOptions",
type: "error",
title: "Reset failed",
text: "Failed to reset server icon.",
});
}
}
};

View File

@@ -59,7 +59,29 @@
/>
<div class="relative h-full w-full overflow-y-auto">
<div v-if="data" class="flex h-full w-full flex-col justify-between gap-4">
<div
v-if="server.network?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's network settings. Here's what we know:
<span class="break-all font-mono">{{ JSON.stringify(server.network.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
<div class="flex h-full flex-col">
<!-- Subdomain section -->
<div class="card flex flex-col gap-4">
@@ -155,7 +177,7 @@
</span>
</div>
<ButtonStyled type="standard" color="brand" @click="showNewAllocationModal">
<ButtonStyled type="standard" @click="showNewAllocationModal">
<button class="!w-full sm:!w-auto">
<PlusIcon />
<span>New allocation</span>
@@ -247,6 +269,7 @@ import {
SaveIcon,
InfoIcon,
UploadIcon,
IssuesIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue";
@@ -286,12 +309,11 @@ const addNewAllocation = async () => {
try {
await props.server.network?.reserveAllocation(newAllocationName.value);
await props.server.refresh(["network"]);
newAllocationModal.value?.hide();
newAllocationName.value = "";
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
@@ -332,8 +354,8 @@ const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return;
await props.server.network?.deleteAllocation(allocationToDelete.value);
await props.server.refresh(["network"]);
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",
@@ -349,12 +371,11 @@ const editAllocation = async () => {
try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
await props.server.refresh(["network"]);
editAllocationModal.value?.hide();
newAllocationName.value = "";
await props.server.refresh();
addNotification({
group: "serverOptions",
type: "success",

View File

@@ -1,7 +1,27 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div v-if="server.fs?.error" class="flex w-full flex-col items-center justify-center gap-4 p-4">
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load properties</h1>
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{ JSON.stringify(server.fs.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-if="propsData && status === 'success'"
v-else-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<div class="card flex flex-col gap-4">
@@ -118,8 +138,8 @@
</template>
<script setup lang="ts">
import { ref, watch, computed } from "vue";
import { EyeIcon, SearchIcon } from "@modrinth/assets";
import { ref, watch, computed, inject } from "vue";
import { EyeIcon, SearchIcon, IssuesIcon } from "@modrinth/assets";
import Fuse from "fuse.js";
import type { Server } from "~/composables/pyroServers";
@@ -134,7 +154,9 @@ const isUpdating = ref(false);
const searchInput = ref("");
const data = computed(() => props.server.general);
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
await modulesLoaded;
const rawProps = await props.server.fs?.downloadFile("server.properties");
if (!rawProps) return null;

View File

@@ -1,7 +1,33 @@
<template>
<div class="relative h-full w-full">
<div v-if="data" class="flex h-full w-full flex-col gap-4">
<div class="rounded-2xl border-solid border-orange bg-bg-orange p-4 text-contrast">
<div
v-if="server.startup?.error"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load startup settings</h1>
</div>
<p class="text-lg text-secondary">
We couldn't load your server's startup settings. Here's what we know:
</p>
<p>
<span class="break-all font-mono">{{ JSON.stringify(server.startup.error) }}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else-if="data" class="flex h-full w-full flex-col gap-4">
<div
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
>
These settings are for advanced users. Changing them can break your server.
</div>
@@ -84,7 +110,7 @@
</template>
<script setup lang="ts">
import { UpdatedIcon } from "@modrinth/assets";
import { UpdatedIcon, IssuesIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui";
import type { Server } from "~/composables/pyroServers";
@@ -109,13 +135,41 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" },
];
const invocation = ref(startupSettings.value?.invocation);
const jdkVersion = ref(
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "",
const invocation = ref("");
const jdkVersion = ref("");
const jdkBuild = ref("");
const originalInvocation = ref("");
const originalJdkVersion = ref("");
const originalJdkBuild = ref("");
watch(
startupSettings,
(newSettings) => {
if (newSettings) {
invocation.value = newSettings.invocation;
originalInvocation.value = newSettings.invocation;
const jdkVersionLabel =
jdkVersionMap.find((v) => v.value === newSettings.jdk_version)?.label || "";
jdkVersion.value = jdkVersionLabel;
originalJdkVersion.value = jdkVersionLabel;
const jdkBuildLabel = jdkBuildMap.find((v) => v.value === newSettings.jdk_build)?.label || "";
jdkBuild.value = jdkBuildLabel;
originalJdkBuild.value = jdkBuildLabel;
}
},
{ immediate: true },
);
const jdkBuild = ref(
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "",
const hasUnsavedChanges = computed(
() =>
invocation.value !== originalInvocation.value ||
jdkVersion.value !== originalJdkVersion.value ||
jdkBuild.value !== originalJdkBuild.value,
);
const isUpdating = ref(false);
const compatibleJavaVersions = computed(() => {
@@ -139,15 +193,6 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
});
const hasUnsavedChanges = computed(
() =>
invocation.value !== startupSettings.value?.invocation ||
jdkVersion.value !==
(jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "") ||
jdkBuild.value !==
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build || "")?.label,
);
const saveStartup = async () => {
try {
isUpdating.value = true;
@@ -155,14 +200,25 @@ const saveStartup = async () => {
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value;
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value;
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any);
await new Promise((resolve) => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 10));
await props.server.refresh(["startup"]);
if (props.server.startup) {
invocation.value = props.server.startup.invocation;
jdkVersion.value =
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || "";
jdkBuild.value =
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || "";
}
addNotification({
group: "serverOptions",
type: "success",
title: "Server settings updated",
text: "Your server settings were successfully changed.",
});
await props.server.refresh();
} catch (error) {
console.error(error);
addNotification({
@@ -177,15 +233,13 @@ const saveStartup = async () => {
};
const resetStartup = () => {
invocation.value = startupSettings.value?.invocation;
jdkVersion.value =
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "";
jdkBuild.value =
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "";
invocation.value = originalInvocation.value;
jdkVersion.value = originalJdkVersion.value;
jdkBuild.value = originalJdkBuild.value;
};
const resetToDefault = () => {
invocation.value = startupSettings.value?.original_invocation;
invocation.value = startupSettings.value?.original_invocation ?? "";
};
</script>

View File

@@ -1,5 +1,5 @@
<template>
<section class="universal-card">
<section class="universal-card experimental-styles-within">
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
<div class="universal-card recessed">
@@ -88,67 +88,69 @@
v-if="midasCharge && midasCharge.status === 'failed'"
class="ml-auto flex flex-row-reverse items-center gap-2"
>
<ButtonStyled v-if="midasCharge && midasCharge.status === 'failed'">
<button
@click="
() => {
$refs.midasPurchaseModal.show();
}
"
>
<UpdatedIcon />
Update method
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<OverflowMenu
:dropdown-id="`${baseId}-cancel-midas`"
:options="[
{
id: 'cancel',
action: () => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
},
},
]"
>
<MoreVerticalIcon />
<template #cancel><XIcon /> Cancel</template>
</OverflowMenu>
</ButtonStyled>
</div>
<ButtonStyled v-else-if="midasCharge && midasCharge.status !== 'cancelled'">
<button
v-if="midasCharge && midasCharge.status === 'failed'"
class="iconified-button raised-button"
class="ml-auto"
@click="
() => {
purchaseModalStep = 0;
$refs.purchaseModal.show();
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
}
"
>
<UpdatedIcon />
Update method
<XIcon /> Cancel
</button>
<OverflowMenu
class="btn icon-only transparent"
:options="[
{
id: 'cancel',
action: () => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
},
},
]"
>
<MoreVerticalIcon />
<template #cancel><XIcon /> Cancel</template>
</OverflowMenu>
</div>
<button
v-else-if="midasCharge && midasCharge.status !== 'cancelled'"
class="iconified-button raised-button !ml-auto"
@click="
() => {
cancelSubscriptionId = midasSubscription.id;
$refs.modalCancel.show();
}
"
>
<XIcon /> Cancel
</button>
<button
</ButtonStyled>
<ButtonStyled
v-else-if="midasCharge && midasCharge.status === 'cancelled'"
class="btn btn-purple btn-large ml-auto"
@click="cancelSubscription(midasSubscription.id, false)"
color="purple"
>
<RightArrowIcon /> Resubscribe
</button>
<button
v-else
class="btn btn-purple btn-large ml-auto"
@click="
() => {
purchaseModalStep = 0;
$refs.purchaseModal.show();
}
"
>
<RightArrowIcon />
Subscribe
</button>
<button class="ml-auto" @click="cancelSubscription(midasSubscription.id, false)">
Resubscribe <RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else color="purple" size="large">
<button
class="ml-auto"
@click="
() => {
$refs.midasPurchaseModal.show();
}
"
>
Subscribe <RightArrowIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
@@ -282,25 +284,37 @@
getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed'
"
type="standard"
@click="showPyroCancelModal(subscription.id)"
>
<button class="text-contrast">
<button @click="showPyroCancelModal(subscription.id)">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled
v-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed'
"
color="green"
color-fill="text"
>
<button @click="showPyroUpgradeModal(subscription)">
<ArrowBigUpDashIcon />
Upgrade
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="
getPyroCharge(subscription) &&
(getPyroCharge(subscription).status === 'cancelled' ||
getPyroCharge(subscription).status === 'failed')
"
type="standard"
color="green"
@click="resubscribePyro(subscription.id)"
>
<button class="text-contrast">Resubscribe</button>
<button @click="resubscribePyro(subscription.id)">
Resubscribe <RightArrowIcon />
</button>
</ButtonStyled>
</div>
</div>
@@ -312,7 +326,7 @@
</div>
</section>
<section class="universal-card">
<section class="universal-card experimental-styles-within">
<ConfirmModal
ref="modal_confirm"
:title="formatMessage(deleteModalMessages.title)"
@@ -321,7 +335,7 @@
@proceed="removePaymentMethod(removePaymentMethodIndex)"
/>
<PurchaseModal
ref="purchaseModal"
ref="midasPurchaseModal"
:product="midasProduct"
:country="country"
:publishable-key="config.public.stripePublishableKey"
@@ -342,6 +356,38 @@
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`"
/>
<PurchaseModal
ref="pyroPurchaseModal"
:product="upgradeProducts"
:country="country"
custom-server
:existing-subscription="currentSubscription"
:existing-plan="currentProduct"
:publishable-key="config.public.stripePublishableKey"
:send-billing-request="
async (body) =>
await useBaseFetch(`billing/subscription/${currentSubscription.id}`, {
internal: true,
method: `PATCH`,
body: body,
})
"
:renewal-date="currentSubRenewalDate"
:on-error="
(err) =>
data.$notify({
group: 'main',
title: 'An error occurred',
type: 'error',
text: err.message ?? (err.data ? err.data.description : err),
})
"
:fetch-capacity-statuses="fetchCapacityStatuses"
:customer="customer"
:payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/servers/manage`"
:server-name="`${auth?.user?.username}'s server`"
/>
<NewModal ref="addPaymentMethodModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">
@@ -359,15 +405,19 @@
<div id="address-element"></div>
<div id="payment-element" class="mt-4"></div>
</div>
<div v-show="loadingPaymentMethodModal === 2" class="input-group push-right mt-auto pt-4">
<button class="btn" @click="$refs.addPaymentMethodModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
<button class="btn btn-primary" :disabled="loadingAddMethod" @click="submit">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
<div v-show="loadingPaymentMethodModal === 2" class="input-group mt-auto pt-4">
<ButtonStyled color="brand">
<button :disabled="loadingAddMethod" @click="submit">
<PlusIcon />
{{ formatMessage(messages.paymentMethodAdd) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="$refs.addPaymentMethodModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
@@ -442,6 +492,7 @@
</div>
</div>
<OverflowMenu
:dropdown-id="`${baseId}-payment-method-overflow-${index}`"
class="btn icon-only transparent"
:options="
[
@@ -493,6 +544,7 @@ import {
} from "@modrinth/ui";
import {
PlusIcon,
ArrowBigUpDashIcon,
XIcon,
CardIcon,
MoreVerticalIcon,
@@ -515,6 +567,10 @@ definePageMeta({
middleware: "auth",
});
const app = useNuxtApp();
const auth = await useAuth();
const baseId = useId();
useHead({
script: [
{
@@ -704,7 +760,7 @@ const pyroSubscriptions = computed(() => {
});
});
const purchaseModal = ref();
const midasPurchaseModal = ref();
const country = useUserCountry();
const price = computed(() =>
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
@@ -896,6 +952,78 @@ const showPyroCancelModal = (subscriptionId) => {
}
};
const pyroPurchaseModal = ref();
const currentSubscription = ref(null);
const currentProduct = ref(null);
const upgradeProducts = ref([]);
upgradeProducts.value.metadata = { type: "pyro" };
const currentSubRenewalDate = ref();
const showPyroUpgradeModal = async (subscription) => {
currentSubscription.value = subscription;
currentSubRenewalDate.value = getPyroCharge(subscription).due;
currentProduct.value = getPyroProduct(subscription);
upgradeProducts.value = products.filter(
(p) =>
p.metadata.type === "pyro" &&
(!currentProduct.value || p.metadata.ram > currentProduct.value.metadata.ram),
);
upgradeProducts.value.metadata = { type: "pyro" };
await nextTick();
if (!currentProduct.value) {
console.error("Could not find product for current subscription");
data.$notify({
group: "main",
title: "An error occurred",
text: "Could not find product for current subscription",
type: "error",
});
return;
}
if (!pyroPurchaseModal.value) {
console.error("pyroPurchaseModal ref is undefined");
return;
}
pyroPurchaseModal.value.show();
};
async function fetchCapacityStatuses(serverId, product) {
if (product) {
try {
return {
custom: await usePyroFetch(`servers/${serverId}/upgrade-stock`, {
method: "POST",
body: {
cpu: product.metadata.cpu,
memory_mb: product.metadata.ram,
swap_mb: product.metadata.swap,
storage_mb: product.metadata.storage,
},
}),
};
} catch (error) {
console.error("Error checking server capacities:", error);
app.$notify({
group: "main",
title: "Error checking server capacities",
text: error,
type: "error",
});
return {
custom: { available: 0 },
small: { available: 0 },
medium: { available: 0 },
large: { available: 0 },
};
}
}
}
const resubscribePyro = async (subscriptionId) => {
try {
await useBaseFetch(`billing/subscription/${subscriptionId}`, {

View File

@@ -223,7 +223,9 @@
</div>
<div v-if="['collections'].includes(route.params.projectType)" class="collections-grid">
<nuxt-link
v-for="collection in collections"
v-for="collection in collections.sort(
(a, b) => new Date(b.created) - new Date(a.created),
)"
:key="collection.id"
:to="`/collection/${collection.id}`"
class="card collection-item"
@@ -242,7 +244,12 @@
{{ collection.description }}
</div>
<div class="stat-bar">
<div class="stats"><BoxIcon /> {{ collection.projects?.length || 0 }} projects</div>
<div class="stats">
<BoxIcon />
{{
`${$formatNumber(collection.projects?.length || 0, false)} project${(collection.projects?.length || 0) !== 1 ? "s" : ""}`
}}
</div>
<div class="stats">
<template v-if="collection.status === 'listed'">
<WorldIcon />
@@ -638,12 +645,13 @@ export default defineNuxtComponent({
grid-template-columns: repeat(1, 1fr);
}
gap: var(--gap-lg);
gap: var(--gap-md);
.collection-item {
display: flex;
flex-direction: column;
gap: var(--gap-md);
margin-bottom: 0px;
}
.description {
@@ -692,7 +700,7 @@ export default defineNuxtComponent({
.title {
color: var(--color-contrast);
font-weight: 600;
font-weight: 700;
font-size: var(--font-size-lg);
margin: 0;
}