forked from didirus/AstralRinth
Merge commit '90def724c28a2eacf173f4987e195dc14908f25d' into feature-clean
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
61
apps/frontend/src/pages/admin/user_email.vue
Normal file
61
apps/frontend/src/pages/admin/user_email.vue
Normal 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>
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user