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

@@ -1,7 +1,16 @@
<script setup>
import { XIcon, HammerIcon, LogInIcon, UpdatedIcon } from '@modrinth/assets'
import {
CheckIcon,
DropdownIcon,
XIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
CopyIcon,
} from '@modrinth/assets'
import { ChatIcon } from '@/assets/icons'
import { ref } from 'vue'
import { ButtonStyled, Collapsible } from '@modrinth/ui'
import { ref, computed } from 'vue'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js'
@@ -13,6 +22,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const errorModal = ref()
const error = ref()
const closable = ref(true)
const errorCollapsed = ref(false)
const title = ref('An error occurred')
const errorType = ref('unknown')
@@ -118,6 +128,26 @@ async function repairInstance() {
}
loadingRepair.value = false
}
const hasDebugInfo = computed(
() =>
errorType.value === 'directory_move' ||
errorType.value === 'minecraft_auth' ||
errorType.value === 'state_init' ||
errorType.value === 'no_loader_version',
)
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
const copied = ref(false)
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<template>
@@ -244,16 +274,9 @@ async function repairInstance() {
</div>
</template>
<template v-else>
{{ error.message ?? error }}
{{ debugInfo }}
</template>
<template
v-if="
errorType === 'directory_move' ||
errorType === 'minecraft_auth' ||
errorType === 'state_init' ||
errorType === 'no_loader_version'
"
>
<template v-if="hasDebugInfo">
<hr />
<p>
If nothing is working and you need help, visit
@@ -261,16 +284,39 @@ async function repairInstance() {
and start a chat using the widget in the bottom right and we will be more than happy to
assist! Make sure to provide the following debug information to the agent:
</p>
<details>
<summary>Debug information</summary>
{{ error.message ?? error }}
</details>
</template>
</div>
<div class="input-group push-right">
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button>
<div class="flex items-center gap-2">
<ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
</ButtonStyled>
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()"><XIcon /> Close</button>
</ButtonStyled>
<ButtonStyled v-if="hasDebugInfo">
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
<template v-else> <CopyIcon /> Copy debug info </template>
</button>
</ButtonStyled>
</div>
<template v-if="hasDebugInfo">
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
@click="errorCollapsed = !errorCollapsed"
>
<span class="text-contrast font-extrabold m-0">Debug information:</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }"
/>
</button>
<Collapsible :collapsed="errorCollapsed">
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
</Collapsible>
</div>
</template>
</div>
</ModalWrapper>
</template>

View File

@@ -5,7 +5,7 @@ import { handleError } from '@/store/notifications'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { get } from '@/helpers/settings'
import { edit } from '@/helpers/profile'
import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { formatMessage } = useVIntl()
@@ -114,7 +114,6 @@ const messages = defineMessages({
<Toggle
id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:checked="fullscreenSetting"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {

View File

@@ -1,8 +1,8 @@
<script setup lang="ts">
import { Toggle, ThemeSelector, TeleportDropdownMenu } from '@modrinth/ui'
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
import { useTheming } from '@/store/state'
import { get, set } from '@/helpers/settings'
import { watch, ref } from 'vue'
import { ref, watch } from 'vue'
import { getOS } from '@/helpers/utils'
const themeStore = useTheming()
@@ -46,7 +46,6 @@ watch(
<Toggle
id="advanced-rendering"
:model-value="themeStore.advancedRendering"
:checked="themeStore.advancedRendering"
@update:model-value="
(e) => {
themeStore.advancedRendering = e
@@ -61,16 +60,7 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
</div>
<Toggle
id="native-decorations"
:model-value="settings.native_decorations"
:checked="settings.native_decorations"
@update:model-value="
(e) => {
settings.native_decorations = e
}
"
/>
<Toggle id="native-decorations" v-model="settings.native_decorations" />
</div>
<div class="mt-4 flex items-center justify-between">
@@ -78,16 +68,7 @@ watch(
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
</div>
<Toggle
id="minimize-launcher"
:model-value="settings.hide_on_process_start"
:checked="settings.hide_on_process_start"
@update:model-value="
(e) => {
settings.hide_on_process_start = e
}
"
/>
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
</div>
<div class="mt-4 flex items-center justify-between">
@@ -111,7 +92,6 @@ watch(
<Toggle
id="toggle-sidebar"
:model-value="settings.toggle_sidebar"
:checked="settings.toggle_sidebar"
@update:model-value="
(e) => {
settings.toggle_sidebar = e

View File

@@ -57,16 +57,7 @@ watch(
</p>
</div>
<Toggle
id="fullscreen"
:model-value="settings.force_fullscreen"
:checked="settings.force_fullscreen"
@update:model-value="
(e) => {
settings.force_fullscreen = e
}
"
/>
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
</div>
<div class="flex items-center justify-between gap-4">

View File

@@ -37,7 +37,6 @@ watch(
<Toggle
id="advanced-rendering"
:model-value="getStoreValue(option)"
:checked="getStoreValue(option)"
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
/>
</div>

View File

@@ -30,17 +30,8 @@ watch(
option, you opt out and ads will no longer be shown based on your interests.
</p>
</div>
<Toggle
id="personalized-ads"
:model-value="settings.personalized_ads"
:checked="settings.personalized_ads"
:disabled="!settings.personalized_ads"
@update:model-value="
(e) => {
settings.personalized_ads = e
}
"
/>
<!-- AstralRinth disabled element by default -->
<Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" />
</div>
<div class="mt-4 flex items-center justify-between gap-4">
@@ -52,17 +43,8 @@ watch(
longer be collected.
</p>
</div>
<Toggle
id="opt-out-analytics"
:model-value="settings.telemetry"
:checked="settings.telemetry"
:disabled="!settings.telemetry"
@update:model-value="
(e) => {
settings.telemetry = e
}
"
/>
<!-- AstralRinth disabled element by default -->
<Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" />
</div>
<div class="mt-4 flex items-center justify-between gap-4">
@@ -77,10 +59,6 @@ watch(
as those added by mods. (app restart required to take effect)
</p>
</div>
<Toggle
id="disable-discord-rpc"
v-model="settings.discord_rpc"
:checked="settings.discord_rpc"
/>
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
</div>
</template>

View File

@@ -179,7 +179,6 @@
<Toggle
class="!mx-2"
:model-value="!item.data.disabled"
:checked="!item.data.disabled"
@update:model-value="toggleDisableMod(item.data)"
/>
<ButtonStyled type="transparent" circular>

View File

@@ -44,7 +44,7 @@
]
},
"productName": "AstralRinth App",
"version": "0.9.301",
"version": "0.9.302",
"mainBinaryName": "AstralRinth App",
"identifier": "AstralRinthApp",
"plugins": {

View File

@@ -133,6 +133,19 @@
"sidebar"
/ 100%;
.normal-page__ultimate-sidebar {
grid-area: ultimate-sidebar;
position: fixed;
bottom: 1rem;
right: 1rem;
z-index: 100;
max-width: calc(100% - 2rem);
> div {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
}
}
@media screen and (min-width: 1024px) {
&.sidebar {
grid-template:
@@ -156,6 +169,45 @@
}
}
@media screen and (min-width: 1400px) {
&.ultimate-sidebar {
max-width: calc(80rem + 0.75rem + 600px);
grid-template:
"header header ultimate-sidebar" auto
"content sidebar ultimate-sidebar" auto
"content dummy ultimate-sidebar" 1fr
/ 1fr 18.75rem auto;
.normal-page__header {
max-width: 80rem;
}
.normal-page__ultimate-sidebar {
position: sticky;
top: 4.5rem;
bottom: unset;
right: unset;
z-index: unset;
align-self: start;
display: flex;
height: calc(100vh - 4.5rem * 2);
> div {
box-shadow: none;
}
}
&.alt-layout {
grid-template:
"ultimate-sidebar header header" auto
"ultimate-sidebar sidebar content" auto
"ultimate-sidebar dummy content" 1fr
/ auto 18.75rem 1fr;
}
}
}
.normal-page__sidebar {
grid-area: sidebar;
}

View File

@@ -19,10 +19,7 @@
</div>
<div class="flex flex-col gap-2">
<label for="additional-information" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Summary
<span class="text-brand-red">*</span>
</span>
<span class="text-lg font-semibold text-contrast"> Summary </span>
<span>A sentence or two that describes your collection.</span>
</label>
<div class="textarea-wrapper">
@@ -52,8 +49,8 @@
</NewModal>
</template>
<script setup>
import { XIcon, PlusIcon } from "@modrinth/assets";
import { NewModal, ButtonStyled } from "@modrinth/ui";
import { PlusIcon, XIcon } from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
const router = useNativeRouter();
@@ -78,7 +75,7 @@ async function create() {
method: "POST",
body: {
name: name.value.trim(),
description: description.value.trim(),
description: description.value.trim() || undefined,
projects: props.projectIds,
},
apiVersion: 3,

View File

@@ -1,329 +1,366 @@
<template>
<div class="card moderation-checklist">
<h1>Moderation checklist</h1>
<div v-if="done">
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
<div
class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
:class="collapsed ? `sm:max-w-[300px]` : 'sm:max-w-[600px]'"
>
<div class="flex grow-0 items-center gap-2">
<h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast">
<ScaleIcon class="text-orange" /> Moderation
</h1>
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
<button v-tooltip="`Exit moderation`" @click="exitModeration">
<CrossIcon />
</button>
</ButtonStyled>
<ButtonStyled circular>
<button v-tooltip="collapsed ? `Expand` : `Collapse`" @click="emit('toggleCollapsed')">
<DropdownIcon class="transition-transform" :class="{ 'rotate-180': collapsed }" />
</button>
</ButtonStyled>
</div>
<div v-else-if="generatedMessage">
<p>
Enter your moderation message here. Remember to check the Moderation tab to answer any
questions an author might have!
</p>
<div class="markdown-editor-spacing">
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
<Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
<div class="my-4 h-[1px] w-full bg-divider" />
<div v-if="done">
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
</div>
</div>
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
<h2 v-if="modPackData">
Modpack permissions
<template v-if="modPackIndex + 1 <= modPackData.length">
({{ modPackIndex + 1 }} / {{ modPackData.length }})
</template>
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
<div v-else-if="generatedMessage">
<p>
Enter your moderation message here. Remember to check the Moderation tab to answer any
questions an author might have!
</p>
<div class="markdown-editor-spacing">
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
</div>
</div>
<div v-else-if="!modPackData[modPackIndex]">
<p>All permission checks complete!</p>
<div class="input-group modpack-buttons">
<button class="btn" @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
Modpack permissions
<template v-if="modPackIndex + 1 <= modPackData.length">
({{ modPackIndex + 1 }} / {{ modPackData.length }})
</template>
</h2>
<div v-if="!modPackData">Loading data...</div>
<div v-else-if="modPackData.length === 0">
<p>All permissions obtained. You may skip this step!</p>
</div>
<div v-else-if="!modPackData[modPackIndex]">
<p>All permission checks complete!</p>
<div class="input-group modpack-buttons">
<ButtonStyled>
<button @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
</div>
</div>
<div v-else>
<div v-if="modPackData[modPackIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
<div
v-if="modPackData[modPackIndex].status !== 'unidentified'"
class="flex flex-col gap-1"
>
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="modPackData[modPackIndex].proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="modPackData[modPackIndex].url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="modPackData[modPackIndex].title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
/>
</div>
</div>
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
:href="modPackData[modPackIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[modPackIndex].url }}</a
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
"
>
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in filePermissionTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].approved === option.id,
}"
@click="modPackData[modPackIndex].approved = option.id"
>
{{ option.name }}
</button>
</div>
</div>
<div class="mt-4 flex gap-2">
<ButtonStyled>
<button :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
</ButtonStyled>
<ButtonStyled color="blue">
<button :disabled="!modPackData[modPackIndex].status" @click="modPackIndex += 1">
<RightArrowIcon aria-hidden="true" />
Next project
</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-else>
<div v-if="modPackData[modPackIndex].type === 'unknown'">
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
<template v-if="modPackData[modPackIndex].status !== 'unidentified'">
<div class="universal-labels"></div>
<label for="proof">
<span class="label__title">Proof</span>
</label>
<input
id="proof"
v-model="modPackData[modPackIndex].proof"
type="text"
autocomplete="off"
placeholder="Enter proof of status..."
/>
<label for="link">
<span class="label__title">Link</span>
</label>
<input
id="link"
v-model="modPackData[modPackIndex].url"
type="text"
autocomplete="off"
placeholder="Enter link of project..."
/>
<label for="title">
<span class="label__title">Title</span>
</label>
<input
id="title"
v-model="modPackData[modPackIndex].title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
/>
<h2 class="m-0 mb-2 text-lg font-extrabold">{{ steps[currentStepIndex].question }}</h2>
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
<strong>Guidance:</strong>
<ul class="mb-3 mt-2 leading-tight">
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
{{ rule }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
>
<strong>Reject things like:</strong>
<ul class="mb-3 mt-2 leading-tight">
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
{{ example }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
>
<strong>Exceptions:</strong>
<ul class="mb-3 mt-2 leading-tight">
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
{{ exception }}
</li>
</ul>
</template>
<p v-if="steps[currentStepIndex].id === 'title'">
<strong>Title:</strong> {{ project.title }}
</p>
<p v-if="steps[currentStepIndex].id === 'slug'">
<strong>Slug:</strong> {{ project.slug }}
</p>
<p v-if="steps[currentStepIndex].id === 'summary'">
<strong>Summary:</strong> {{ project.description }}
</p>
<p v-if="steps[currentStepIndex].id === 'links'">
<template v-if="project.issues_url">
<strong>Issues: </strong>
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
</template>
</div>
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
<p>
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
:href="modPackData[modPackIndex].url"
target="_blank"
class="text-link"
>{{ modPackData[modPackIndex].url }}</a
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in fileApprovalTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].status === option.id,
}"
@click="modPackData[modPackIndex].status = option.id"
>
{{ option.name }}
</button>
</div>
<template v-if="project.source_url">
<strong>Source: </strong>
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
</template>
<template v-if="project.wiki_url">
<strong>Wiki: </strong>
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
</template>
<template v-if="project.discord_url">
<strong>Discord: </strong>
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
<br />
</template>
<template v-for="(donation, index) in project.donation_urls" :key="index">
<strong>{{ donation.platform }}: </strong>
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
<br />
</template>
</p>
<p v-if="steps[currentStepIndex].id === 'categories'">
<strong>Categories:</strong>
<Categories
:categories="project.categories.concat(project.additional_categories)"
:type="project.actualProjectType"
class="categories"
/>
</p>
<p v-if="steps[currentStepIndex].id === 'side-types'">
<strong>Client side:</strong> {{ project.client_side }} <br />
<strong>Server side:</strong> {{ project.server_side }}
</p>
<div class="options input-group">
<button
v-for="(option, index) in steps[currentStepIndex].options"
:key="index"
class="btn"
:class="{
'option-selected':
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
}"
@click="toggleOption(steps[currentStepIndex].id, option)"
>
{{ option.name }}
</button>
</div>
<div
v-if="
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].length > 0
"
class="inputs universal-labels"
>
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
Does this project provide identification and permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
Does this project provide attribution for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<p v-else>
Does this project provide proof of permission for
<strong>{{ modPackData[modPackIndex].file_name }}</strong
>?
</p>
<div class="input-group">
<button
v-for="(option, index) in filePermissionTypes"
:key="index"
class="btn"
:class="{
'option-selected': modPackData[modPackIndex].approved === option.id,
}"
@click="modPackData[modPackIndex].approved = option.id"
>
{{ option.name }}
<div
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
(x) => x.fillers && x.fillers.length > 0,
)"
:key="index"
>
<div v-for="(filler, idx) in option.fillers" :key="idx">
<label :for="filler.id">
<span class="label__title">
{{ filler.question }}
<span v-if="filler.required" class="required">*</span>
</span>
</label>
<div v-if="filler.large" class="markdown-editor-spacing">
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
</div>
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
</div>
</div>
</div>
</div>
<div class="mt-auto">
<div
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
>
<div class="flex items-center gap-2">
<ButtonStyled v-if="!done">
<button aria-label="Skip" @click="goToNextProject">
<ExitIcon aria-hidden="true" />
<template v-if="futureProjects.length > 0">Skip</template>
<template v-else>Exit</template>
</button>
</ButtonStyled>
<ButtonStyled v-if="currentStepIndex > 0">
<button @click="previousPage() && !done">
<LeftArrowIcon aria-hidden="true" /> Previous
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-2">
<ButtonStyled v-if="currentStepIndex < steps.length - 1 && !done" color="brand">
<button @click="nextPage()"><RightArrowIcon aria-hidden="true" /> Next</button>
</ButtonStyled>
<ButtonStyled v-else-if="!generatedMessage" color="brand">
<button :disabled="loadingMessage" @click="generateMessage">
<UpdatedIcon aria-hidden="true" /> Generate message
</button>
</ButtonStyled>
<template v-if="generatedMessage && !done">
<ButtonStyled color="green">
<button @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" /> Approve
</button>
</ButtonStyled>
<div class="joined-buttons">
<ButtonStyled color="red">
<button @click="sendMessage('rejected')">
<CrossIcon aria-hidden="true" /> Reject
</button>
</ButtonStyled>
<ButtonStyled color="red">
<OverflowMenu
class="btn-dropdown-animation"
:options="[
{
id: 'withhold',
color: 'danger',
action: () => sendMessage('withheld'),
hoverFilled: true,
},
]"
>
<DropdownIcon style="rotate: 180deg" />
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
Next project
</button>
</div>
</div>
<div class="input-group modpack-buttons">
<button class="btn" :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
<LeftArrowIcon aria-hidden="true" />
Previous
</button>
<button
class="btn btn-blue"
:disabled="!modPackData[modPackIndex].status"
@click="modPackIndex += 1"
>
<RightArrowIcon aria-hidden="true" />
Next project
</button>
</div>
</div>
</div>
<div v-else>
<h2>{{ steps[currentStepIndex].question }}</h2>
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
<strong>Rules guidance:</strong>
<ul>
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
{{ rule }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
>
<strong>Examples of what to reject:</strong>
<ul>
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
{{ example }}
</li>
</ul>
</template>
<template
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
>
<strong>Exceptions:</strong>
<ul>
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
{{ exception }}
</li>
</ul>
</template>
<p v-if="steps[currentStepIndex].id === 'title'">
<strong>Title:</strong> {{ project.title }}
</p>
<p v-if="steps[currentStepIndex].id === 'slug'"><strong>Slug:</strong> {{ project.slug }}</p>
<p v-if="steps[currentStepIndex].id === 'summary'">
<strong>Summary:</strong> {{ project.description }}
</p>
<p v-if="steps[currentStepIndex].id === 'links'">
<template v-if="project.issues_url">
<strong>Issues: </strong>
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
</template>
<template v-if="project.source_url">
<strong>Source: </strong>
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
</template>
<template v-if="project.wiki_url">
<strong>Wiki: </strong>
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
</template>
<template v-if="project.discord_url">
<strong>Discord: </strong>
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
<br />
</template>
<template v-for="(donation, index) in project.donation_urls" :key="index">
<strong>{{ donation.platform }}: </strong>
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
<br />
</template>
</p>
<p v-if="steps[currentStepIndex].id === 'categories'">
<strong>Categories:</strong>
<Categories
:categories="project.categories.concat(project.additional_categories)"
:type="project.actualProjectType"
class="categories"
/>
</p>
<p v-if="steps[currentStepIndex].id === 'side-types'">
<strong>Client side:</strong> {{ project.client_side }} <br />
<strong>Server side:</strong> {{ project.server_side }}
</p>
<div class="options input-group">
<button
v-for="(option, index) in steps[currentStepIndex].options"
:key="index"
class="btn"
:class="{
'option-selected':
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
}"
@click="toggleOption(steps[currentStepIndex].id, option)"
>
{{ option.name }}
</button>
</div>
<div
v-if="
selectedOptions[steps[currentStepIndex].id] &&
selectedOptions[steps[currentStepIndex].id].length > 0
"
class="inputs universal-labels"
>
<div
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
(x) => x.fillers && x.fillers.length > 0,
)"
:key="index"
>
<div v-for="(filler, idx) in option.fillers" :key="idx">
<label :for="filler.id">
<span class="label__title">
{{ filler.question }}
<span v-if="filler.required" class="required">*</span>
</span>
</label>
<div v-if="filler.large" class="markdown-editor-spacing">
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
</div>
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
</div>
</div>
</div>
</div>
<div class="input-group modpack-buttons">
<button v-if="!done" class="btn skip-btn" aria-label="Skip" @click="goToNextProject">
<ExitIcon aria-hidden="true" />
<template v-if="futureProjects.length > 0">Skip</template>
<template v-else>Exit</template>
</button>
<button v-if="currentStepIndex > 0" class="btn" @click="previousPage() && !done">
<LeftArrowIcon aria-hidden="true" /> Previous
</button>
<button
v-if="currentStepIndex < steps.length - 1 && !done"
class="btn btn-primary"
@click="nextPage()"
>
<RightArrowIcon aria-hidden="true" /> Next
</button>
<button
v-else-if="!generatedMessage"
class="btn btn-primary"
:disabled="loadingMessage"
@click="generateMessage"
>
<UpdatedIcon aria-hidden="true" /> Generate message
</button>
<template v-if="generatedMessage && !done">
<button class="btn btn-green" @click="sendMessage(project.requested_status ?? 'approved')">
<CheckIcon aria-hidden="true" /> Approve
</button>
<div class="joined-buttons">
<button class="btn btn-danger" @click="sendMessage('rejected')">
<CrossIcon aria-hidden="true" /> Reject
</button>
<OverflowMenu
class="btn btn-danger btn-dropdown-animation icon-only"
:options="[
{
id: 'withhold',
color: 'danger',
action: () => sendMessage('withheld'),
hoverFilled: true,
},
]"
>
<DropdownIcon style="rotate: 180deg" />
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
</OverflowMenu>
</div>
</template>
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
Next project
</button>
</div>
</Collapsible>
</div>
</template>
@@ -337,8 +374,9 @@ import {
XIcon as CrossIcon,
EyeOffIcon,
ExitIcon,
ScaleIcon,
} from "@modrinth/assets";
import { MarkdownEditor, OverflowMenu } from "@modrinth/ui";
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
import Categories from "~/components/ui/search/Categories.vue";
const props = defineProps({
@@ -355,8 +393,14 @@ const props = defineProps({
required: true,
default: () => {},
},
collapsed: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(["exit", "toggleCollapsed"]);
const steps = computed(() =>
[
{
@@ -1008,6 +1052,20 @@ async function sendMessage(status) {
const router = useNativeRouter();
async function exitModeration() {
await router.push({
name: "type-id",
params: {
type: "project",
id: props.project.id,
},
state: {
showChecklist: false,
},
});
emit("exit");
}
async function goToNextProject() {
const project = props.futureProjects[0];
@@ -1031,23 +1089,8 @@ async function goToNextProject() {
<style scoped lang="scss">
.moderation-checklist {
position: sticky;
bottom: 0;
left: 100vw;
z-index: 100;
border: 1px solid var(--color-bg-inverted);
width: 600px;
.skip-btn {
margin-right: auto;
}
.next-project {
margin-left: auto;
}
.modpack-buttons {
margin-top: 1rem;
@media (prefers-reduced-motion) {
transition: none !important;
}
.option-selected {

View File

@@ -1,5 +1,4 @@
<template>
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
<ReportInfo
v-for="report in reports.filter(
(x) =>
@@ -17,7 +16,6 @@
<p v-if="reports.length === 0">You don't have any active reports.</p>
</template>
<script setup>
import Chips from "~/components/ui/Chips.vue";
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
import { addReportMessage } from "~/helpers/threads.js";

View File

@@ -106,6 +106,7 @@ const fetchSettings = async () => {
initialSettings.value = settings as { interval: number; enabled: boolean };
autoBackupEnabled.value = settings?.enabled ?? false;
autoBackupInterval.value = settings?.interval || 6;
return true;
} catch (error) {
console.error("Error fetching backup settings:", error);
addNotification({
@@ -114,6 +115,7 @@ const fetchSettings = async () => {
text: "Failed to load backup settings",
type: "error",
});
return false;
} finally {
isLoadingSettings.value = false;
}
@@ -155,8 +157,10 @@ const saveSettings = async () => {
defineExpose({
show: async () => {
await fetchSettings();
modal.value?.show();
const success = await fetchSettings();
if (success) {
modal.value?.show();
}
},
});
</script>

View File

@@ -54,7 +54,8 @@ const locations = ref([
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
{ name: "Seattle", lat: 47.608013, lng: -122.3321, active: true, clicked: false },
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false },
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false },
// Future Locations
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false },
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false },

View File

@@ -0,0 +1,80 @@
<template>
<div
aria-hidden="true"
style="font-variant-numeric: tabular-nums"
class="pointer-events-none h-full w-full select-none"
>
<div class="flex flex-col gap-6">
<div class="flex flex-row items-center gap-6">
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">CPU usage</h3>
</div>
<CPUIcon class="absolute right-10 top-10" />
</div>
<div
class="relative max-h-[156px] min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0.00%</h2>
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 100%</h3>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Memory usage</h3>
</div>
<DBIcon class="absolute right-10 top-10" />
</div>
<div
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8 transition-transform duration-100 hover:scale-105 active:scale-100"
>
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
</div>
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
</div>
</div>
<div
class="relative flex h-full w-full flex-col gap-3 overflow-hidden rounded-2xl bg-bg-raised p-8"
>
<div class="experimental-styles-within flex flex-row items-center">
<div class="flex flex-row items-center gap-4">
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
</div>
</div>
<div class="relative w-full">
<input type="text" placeholder="Search logs" class="h-12 !w-full !pl-10 !pr-48" />
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
</div>
<div
class="console relative h-full min-h-[516px] w-full overflow-hidden rounded-xl bg-bg text-sm"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } from "@modrinth/assets";
</script>
<style scoped>
html.light-mode .console {
background: var(--color-bg);
}
html.dark-mode .console {
background: black;
}
html.oled-mode .console {
background: black;
}
</style>

View File

@@ -1,23 +1,25 @@
<template>
<div class="contents">
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal">
<NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
<div class="flex flex-col gap-4 md:w-[400px]">
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p>
<p class="m-0">
Are you sure you want to <span class="lowercase">{{ confirmActionText }}</span> the
server?
</p>
<UiCheckbox
v-model="powerDontAskAgainCheckbox"
v-model="dontAskAgain"
label="Don't ask me again"
class="text-sm"
:disabled="!currentPendingAction"
:disabled="!powerAction"
/>
<div class="flex flex-row gap-4">
<ButtonStyled type="standard" color="brand" @click="confirmAction">
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
<button>
<CheckIcon class="h-5 w-5" />
{{ currentPendingActionFriendly }} server
{{ confirmActionText }} server
</button>
</ButtonStyled>
<ButtonStyled @click="closePowerModal">
<ButtonStyled @click="resetPowerAction">
<button>
<XIcon class="h-5 w-5" />
Cancel
@@ -29,7 +31,7 @@
<NewModal
ref="detailsModal"
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`"
:header="`All of ${serverName || 'Server'} info`"
@close="closeDetailsModal"
>
<UiServersServerInfoLabels
@@ -51,75 +53,74 @@
<UiServersPanelSpinner class="size-5" /> Installing...
</button>
</ButtonStyled>
<div v-else class="contents">
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent">
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer">
<button :disabled="!canTakeAction" @click="initiateAction('stop')">
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>{{ stopButtonText }}</span>
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
</div>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand">
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction">
<div v-if="isStartingOrRestarting" class="grid place-content-center">
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
<div v-if="isTransitionState" class="grid place-content-center">
<UiServersIconsLoadingIcon />
</div>
<div v-else class="contents">
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" />
</div>
<span>
{{ actionButtonText }}
</span>
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
</div>
<!-- Dropdown options -->
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu
:options="[
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
{ id: 'allServers', action: () => router.push('/servers/manage') },
{ id: 'details', action: () => showDetailsModal() },
]"
>
<MoreVerticalIcon aria-hidden="true" />
<template #kill>
<SlashIcon class="h-5 w-5" />
<span>Kill server</span>
</template>
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
</template>
<template #details>
<InfoIcon class="h-5 w-5" />
<span>Details</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<UiServersTeleportOverflowMenu :options="[...menuOptions]">
<MoreVerticalIcon aria-hidden="true" />
<template #kill>
<SlashIcon class="h-5 w-5" />
<span>Kill server</span>
</template>
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
</template>
<template #details>
<InfoIcon class="h-5 w-5" />
<span>Details</span>
</template>
</UiServersTeleportOverflowMenu>
</ButtonStyled>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from "vue";
import { ref, computed } from "vue";
import {
PlayIcon,
UpdatedIcon,
StopCircleIcon,
SlashIcon,
MoreVerticalIcon,
XIcon,
CheckIcon,
ServerIcon,
InfoIcon,
MoreVerticalIcon,
} from "@modrinth/assets";
import { ButtonStyled, NewModal } from "@modrinth/ui";
import { useRouter } from "vue-router";
import { useStorage } from "@vueuse/core";
type ServerAction = "start" | "stop" | "restart" | "kill";
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
interface PowerAction {
action: ServerAction;
nextState: ServerState;
}
const props = defineProps<{
isOnline: boolean;
isActioning: boolean;
@@ -130,183 +131,142 @@ const props = defineProps<{
uptimeSeconds: number;
}>();
const emit = defineEmits<{
(e: "action", action: ServerAction): void;
}>();
const router = useRouter();
const serverId = router.currentRoute.value.params.id;
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
powerDontAskAgain: false,
});
const emit = defineEmits<{
(e: "action", action: "start" | "restart" | "stop" | "kill"): void;
}>();
const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
const powerAction = ref<PowerAction | null>(null);
const dontAskAgain = ref(false);
const startingDelay = ref(false);
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
const ServerState = {
Stopped: "Stopped",
Starting: "Starting",
Running: "Running",
Stopping: "Stopping",
Restarting: "Restarting",
} as const;
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
const currentPendingAction = ref<string | null>(null);
const currentPendingState = ref<ServerStateType | null>(null);
const powerDontAskAgainCheckbox = ref(false);
const currentState = ref<ServerStateType>(
props.isOnline ? ServerState.Running : ServerState.Stopped,
);
const isStartingDelay = ref(false);
const showStopButton = computed(
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
);
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
const canTakeAction = computed(
() =>
!props.isActioning &&
!isStartingDelay.value &&
currentState.value !== ServerState.Starting &&
currentState.value !== ServerState.Stopping,
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
);
const isStartingOrRestarting = computed(
() =>
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
const isRunning = computed(() => serverState.value === "running");
const isTransitionState = computed(() =>
["starting", "stopping", "restarting"].includes(serverState.value),
);
const isStoppingState = computed(() => serverState.value === "stopping");
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
const isStopping = computed(() => currentState.value === ServerState.Stopping);
const actionButtonText = computed(() => {
switch (currentState.value) {
case ServerState.Starting:
return "Starting...";
case ServerState.Restarting:
return "Restarting...";
case ServerState.Running:
return "Restart";
case ServerState.Stopping:
return "Stopping...";
default:
return "Start";
}
const primaryActionText = computed(() => {
const states: Record<ServerState, string> = {
starting: "Starting...",
restarting: "Restarting...",
running: "Restart",
stopping: "Stopping...",
stopped: "Start",
};
return states[serverState.value];
});
const currentPendingActionFriendly = computed(() => {
switch (currentPendingAction.value) {
case "start":
return "Start";
case "restart":
return "Restart";
case "stop":
return "Stop";
case "kill":
return "Kill";
default:
return null;
}
const confirmActionText = computed(() => {
if (!powerAction.value) return "";
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
});
const stopButtonText = computed(() =>
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop",
);
const menuOptions = computed(() => [
...(props.isInstalling
? []
: [
{
id: "kill",
label: "Kill server",
icon: SlashIcon,
action: () => initiateAction("kill"),
},
]),
{
id: "allServers",
label: "All servers",
icon: ServerIcon,
action: () => router.push("/servers/manage"),
},
{
id: "details",
label: "Details",
icon: InfoIcon,
action: () => detailsModal.value?.show(),
},
]);
const createPendingAction = () => {
function initiateAction(action: ServerAction) {
if (!canTakeAction.value) return;
if (currentState.value === ServerState.Running) {
currentPendingAction.value = "restart";
currentPendingState.value = ServerState.Restarting;
showPowerModal();
} else {
runAction("start", ServerState.Starting);
const stateMap: Record<ServerAction, ServerState> = {
start: "starting",
stop: "stopping",
restart: "restarting",
kill: "stopping",
};
if (action === "start") {
emit("action", action);
serverState.value = stateMap[action];
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
return;
}
};
const handleAction = () => {
createPendingAction();
};
powerAction.value = { action, nextState: stateMap[action] };
const showPowerModal = () => {
if (userPreferences.value.powerDontAskAgain) {
runAction(
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
executePowerAction();
} else {
confirmActionModal.value?.show();
}
};
}
const confirmAction = () => {
if (powerDontAskAgainCheckbox.value) {
function handlePrimaryAction() {
initiateAction(isRunning.value ? "restart" : "start");
}
function executePowerAction() {
if (!powerAction.value) return;
const { action, nextState } = powerAction.value;
emit("action", action);
serverState.value = nextState;
if (dontAskAgain.value) {
userPreferences.value.powerDontAskAgain = true;
}
runAction(
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
currentPendingState.value!,
);
closePowerModal();
};
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
emit("action", action);
currentState.value = serverState;
if (action === "start") {
isStartingDelay.value = true;
setTimeout(() => {
isStartingDelay.value = false;
}, 5000);
startingDelay.value = true;
setTimeout(() => (startingDelay.value = false), 5000);
}
};
const stopServer = () => {
if (!canTakeAction.value) return;
currentPendingAction.value = "stop";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
resetPowerAction();
}
const killServer = () => {
currentPendingAction.value = "kill";
currentPendingState.value = ServerState.Stopping;
showPowerModal();
};
const closePowerModal = () => {
function resetPowerAction() {
confirmActionModal.value?.hide();
currentPendingAction.value = null;
powerDontAskAgainCheckbox.value = false;
};
powerAction.value = null;
dontAskAgain.value = false;
}
const closeDetailsModal = () => {
function closeDetailsModal() {
detailsModal.value?.hide();
};
const showDetailsModal = () => {
detailsModal.value?.show();
};
}
watch(
() => props.isOnline,
(newValue) => {
if (newValue) {
currentState.value = ServerState.Running;
} else {
currentState.value = ServerState.Stopped;
}
},
(online) => (serverState.value = online ? "running" : "stopped"),
);
watch(
() => router.currentRoute.value.fullPath,
() => {
closeDetailsModal();
},
() => closeDetailsModal(),
);
</script>

View File

@@ -1,66 +1,72 @@
<template>
<div
:aria-label="`Server is ${getStatusText}`"
:aria-label="`Server is ${getStatusText(state)}`"
class="relative inline-flex select-none items-center"
@mouseenter="isExpanded = true"
@mouseleave="isExpanded = false"
>
<div
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`"
:class="[
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
getStatusClass(state).main,
]"
>
<div
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`"
:class="[
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
getStatusClass(state).bg,
]"
></div>
</div>
<div
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0'
}`"
:class="[
'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
getStatusClass(state).bg,
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
]"
>
<div class="h-3 w-3 rounded-full"></div>
<span
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out"
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`"
:class="[
'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
]"
>
{{ getStatusText }}
{{ getStatusText(state) }}
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
import { ref } from "vue";
import type { ServerState } from "~/types/servers";
const props = defineProps<{
const STATUS_CLASSES = {
running: { main: "bg-brand", bg: "bg-bg-green" },
stopped: { main: "", bg: "" },
crashed: { main: "bg-brand-red", bg: "bg-bg-red" },
unknown: { main: "", bg: "" },
} as const;
const STATUS_TEXTS = {
running: "Running",
stopped: "",
crashed: "Crashed",
unknown: "Unknown",
} as const;
defineProps<{
state: ServerState;
}>();
const isExpanded = ref(false);
const getStatusClass = computed(() => {
switch (props.state) {
case "running":
return { main: "bg-brand", bg: "bg-bg-green" };
case "stopped":
return { main: "", bg: "" };
case "crashed":
return { main: "bg-brand-red", bg: "bg-bg-red" };
default:
return { main: "", bg: "" };
}
});
function getStatusClass(state: ServerState) {
return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
}
const getStatusText = computed(() => {
switch (props.state) {
case "running":
return "Running";
case "stopped":
return "";
case "crashed":
return "Crashed";
default:
return "Unknown";
}
});
function getStatusText(state: ServerState) {
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown;
}
</script>

View File

@@ -260,7 +260,25 @@
</div>
<NewModal ref="viewLogModal" class="z-[9999]" header="Viewing selected logs">
<div class="text-contrast">
<pre class="select-text overflow-x-auto whitespace-pre font-mono">{{ selectedLog }}</pre>
<pre
class="select-text overflow-x-auto whitespace-pre rounded-lg bg-bg font-mono"
v-html="processedLogWithLinks"
></pre>
<div v-if="detectedLinks.length" class="border-contrast/20 mt-4 border-t pt-4">
<h2>Detected Links</h2>
<ul class="flex flex-col gap-2">
<li v-for="(link, index) in detectedLinks" :key="index">
<a
:href="link"
target="_blank"
rel="noopener noreferrer"
class="text-sm text-blue hover:underline"
>
{{ link }}
</a>
</li>
</ul>
</div>
</div>
</NewModal>
</div>
@@ -272,6 +290,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useDebounceFn } from "@vueuse/core";
import { NewModal } from "@modrinth/ui";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import DOMPurify from "dompurify";
import { usePyroConsole } from "~/store/console.ts";
const { $cosmetics } = useNuxtApp();
@@ -984,6 +1003,38 @@ const jumpToLine = (line: string, event?: MouseEvent) => {
});
};
const sanitizeUrl = (url: string): string => {
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
return "#";
}
return parsed.toString();
} catch {
return "#";
}
};
const detectedLinks = computed(() => {
const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g;
const matches = [...selectedLog.value.matchAll(urlRegex)].map((match) => match[0]);
return matches.filter((url) => sanitizeUrl(url) !== "#");
});
const processedLogWithLinks = computed(() => {
const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g;
const sanitizedLog = DOMPurify.sanitize(selectedLog.value, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: [],
});
return sanitizedLog.replace(urlRegex, (url) => {
const safeUrl = sanitizeUrl(url);
if (safeUrl === "#") return url;
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow" class="text-blue hover:underline">${url}</a>`;
});
});
watch(
() => pyroConsole.filteredOutput.value,
() => {

View File

@@ -337,7 +337,7 @@ watch(
selectedLoaderVersions,
(newVersions) => {
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
selectedLoaderVersion.value = String(newVersions[0]); // Ensure string type
selectedLoaderVersion.value = String(newVersions[0]);
}
},
{ immediate: true },
@@ -516,8 +516,6 @@ const handleReinstall = async () => {
const onShow = () => {
selectedMCVersion.value = props.server.general?.mc_version || "";
selectedLoaderVersion.value = "";
hardReset.value = false;
};
const onHide = () => {
@@ -528,13 +526,15 @@ const onHide = () => {
loadingServerCheck.value = false;
isLoading.value = false;
selectedMCVersion.value = "";
selectedLoaderVersion.value = "";
serverCheckError.value = "";
paperVersions.value = {};
purpurVersions.value = {};
};
const show = (loader: Loaders) => {
if (selectedLoader.value !== loader) {
selectedLoaderVersion.value = "";
}
selectedLoader.value = loader;
selectedMCVersion.value = props.server.general?.mc_version || "";
versionSelectModal.value?.show();

View File

@@ -69,11 +69,13 @@
</div>
<div
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
>
<UiServersIconsPanelErrorIcon class="!size-5" />
Your server has been suspended due to a billing issue. Please visit your billing settings or
contact Modrinth Support for more information.
<div class="flex flex-row gap-2">
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
update your billing information or contact Modrinth Support for more information.
</div>
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
</NuxtLink>
</template>

View File

@@ -2,7 +2,7 @@
<div
v-if="uptimeSeconds || uptimeSeconds !== 0"
v-tooltip="`Online for ${verboseUptime}`"
class="flex min-w-0 flex-row items-center gap-4"
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-4"
data-pyro-uptime
>
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>

View File

@@ -20,7 +20,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
// Developer flags
developerMode: false,
showVersionFilesInTable: false,
// showAdsWithPlus: false,
// showAdsWithPlus: false,
alwaysShowChecklistAsPopup: true,
// Feature toggles
projectTypesPrimaryNav: false,

View File

@@ -10,19 +10,111 @@ interface PyroFetchOptions {
url?: string;
token?: string;
};
retry?: boolean;
retry?: number | boolean;
}
async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> {
class PyroServerError extends Error {
public readonly errors: Map<string, Error> = new Map();
public readonly timestamp: number = Date.now();
constructor(message?: string) {
super(message || "Multiple errors occurred");
this.name = "PyroServerError";
}
addError(module: string, error: Error) {
this.errors.set(module, error);
this.message = this.buildErrorMessage();
}
hasErrors() {
return this.errors.size > 0;
}
private buildErrorMessage(): string {
return Array.from(this.errors.entries())
.map(([_module, error]) => error.message)
.join("\n");
}
}
export class PyroServersFetchError extends Error {
constructor(
message: string,
public readonly statusCode?: number,
public readonly originalError?: Error,
public readonly module?: string,
) {
let errorMessage = message;
let method = "GET";
let path = "";
if (originalError instanceof FetchError) {
const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/);
if (matches) {
method = matches[1];
path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, "");
}
const statusMessage = (() => {
if (!statusCode) return "Unknown Error";
switch (statusCode) {
case 400:
return "Bad Request";
case 401:
return "Unauthorized";
case 403:
return "Forbidden";
case 404:
return "Not Found";
case 408:
return "Request Timeout";
case 429:
return "Too Many Requests";
case 500:
return "Internal Server Error";
case 502:
return "Bad Gateway";
case 503:
return "Service Unavailable";
case 504:
return "Gateway Timeout";
default:
return `HTTP ${statusCode}`;
}
})();
errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ""}`;
} else {
errorMessage = `${message}${statusCode ? ` (${statusCode})` : ""}${module ? ` in ${module}` : ""}`;
}
super(errorMessage);
this.name = "PyroServersFetchError";
}
}
async function PyroFetch<T>(
path: string,
options: PyroFetchOptions = {},
module?: string,
): Promise<T> {
const config = useRuntimeConfig();
const auth = await useAuth();
const authToken = auth.value?.token;
if (!authToken) {
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
}
const { method = "GET", contentType = "application/json", body, version = 0, override } = options;
const {
method = "GET",
contentType = "application/json",
body,
version = 0,
override,
retry = method === "GET" ? 3 : 0,
} = options;
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
/\/$/,
@@ -30,9 +122,11 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
);
if (!base) {
throw new PyroFetchError(
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
10001,
throw new PyroServersFetchError(
"Configuration error: Missing PYRO_BASE_URL",
500,
undefined,
module,
);
}
@@ -40,9 +134,7 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
? `https://${override.url}/${path.replace(/^\//, "")}`
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
type HeadersRecord = Record<string, string>;
const headers: HeadersRecord = {
const headers: Record<string, string> = {
Authorization: `Bearer ${override?.token ?? authToken}`,
"Access-Control-Allow-Headers": "Authorization",
"User-Agent": "Pyro/1.0 (https://pyro.host)",
@@ -57,43 +149,47 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
headers.Origin = window.location.origin;
}
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
timeout: 10000,
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
});
return response;
} catch (error) {
console.error("[PyroServers/PyroFetch]:", error);
if (error instanceof FetchError) {
const statusCode = error.response?.status;
const statusText = error.response?.statusText || "[no status text available]";
const errorMessages: { [key: number]: string } = {
400: "Bad Request",
401: "Unauthorized",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
429: "Too Many Requests",
500: "Internal Server Error",
502: "Bad Gateway",
503: "Service Unavailable",
};
const message =
statusCode && statusCode in errorMessages
? errorMessages[statusCode]
: `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`;
throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error);
let attempts = 0;
const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1;
let lastError: Error | null = null;
while (attempts < maxAttempts) {
try {
const response = await $fetch<T>(fullUrl, {
method,
headers,
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
timeout: 10000,
});
return response;
} catch (error) {
lastError = error as Error;
attempts++;
if (error instanceof FetchError) {
const statusCode = error.response?.status;
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
if (!isRetryable || attempts >= maxAttempts) {
throw new PyroServersFetchError(error.message, statusCode, error, module);
}
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw new PyroServersFetchError(
"Unexpected error during fetch operation",
undefined,
error as Error,
module,
);
}
throw new PyroFetchError(
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.",
undefined,
error as Error,
);
}
throw lastError || new Error("Maximum retry attempts reached");
}
const internalServerRefrence = ref<any>(null);
@@ -271,100 +367,96 @@ const constructServerProperties = (properties: any): string => {
};
const processImage = async (iconUrl: string | undefined) => {
const image = ref<string | null>(null);
const sharedImage = useState<string | undefined>(
`server-icon-${internalServerRefrence.value.serverId}`,
() => undefined,
);
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
try {
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
retry: false,
});
if (fileData instanceof Blob) {
if (import.meta.client) {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.src = URL.createObjectURL(fileData);
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
internalServerRefrence.value.general.image = dataURL;
image.value = dataURL;
sharedImage.value = dataURL; // Store in useState
resolve();
};
});
}
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
sharedImage.value = undefined;
} else {
console.error(error);
}
if (sharedImage.value) {
return sharedImage.value;
}
if (image.value === null && iconUrl) {
console.log("iconUrl", iconUrl);
try {
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
try {
const response = await fetch(iconUrl);
const file = await response.blob();
const originalfile = new File([file], "server-icon-original.png", {
type: "image/png",
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
override: auth,
retry: false,
});
if (import.meta.client) {
const scaledFile = await new Promise<File>((resolve, reject) => {
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);
canvas.toBlob((blob) => {
if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" });
resolve(data);
} else {
reject(new Error("Canvas toBlob failed"));
}
}, "image/png");
};
img.onerror = reject;
});
if (scaledFile) {
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalfile,
override: auth,
if (fileData instanceof Blob) {
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 512;
canvas.height = 512;
ctx?.drawImage(img, 0, 0, 512, 512);
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(fileData);
});
return dataURL;
}
}
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 404) {
console.log("[PYROSERVERS] No server icon found");
} else {
console.error(error);
if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
try {
const response = await fetch(iconUrl);
if (!response.ok) throw new Error("Failed to fetch icon");
const file = await response.blob();
const originalFile = new File([file], "server-icon-original.png", { type: "image/png" });
if (import.meta.client) {
const dataURL = await new Promise<string>((resolve) => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const img = new Image();
img.onload = () => {
canvas.width = 64;
canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64);
canvas.toBlob(async (blob) => {
if (blob) {
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: scaledFile,
override: auth,
});
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
method: "POST",
contentType: "application/octet-stream",
body: originalFile,
override: auth,
});
}
}, "image/png");
const dataURL = canvas.toDataURL("image/png");
sharedImage.value = dataURL;
resolve(dataURL);
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(file);
});
return dataURL;
}
} catch (error) {
console.error("Failed to process external icon:", error);
}
}
}
} catch (error) {
console.error("Failed to process server icon:", error);
}
return image.value;
sharedImage.value = undefined;
return undefined;
};
// ------------------ GENERAL ------------------ //
@@ -564,10 +656,14 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s
const createBackup = async (backupName: string) => {
try {
const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, {
method: "POST",
body: { name: backupName },
})) as { id: string };
const response = await PyroFetch<{ id: string }>(
`servers/${internalServerRefrence.value.serverId}/backups`,
{
method: "POST",
body: { name: backupName },
},
);
await internalServerRefrence.value.refresh(["backups"]);
return response.id;
} catch (error) {
console.error("Error creating backup:", error);
@@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => {
method: "POST",
body: { name: newName },
});
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error renaming backup:", error);
throw error;
@@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => {
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
method: "DELETE",
});
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error deleting backup:", error);
throw error;
@@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => {
method: "POST",
},
);
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error restoring backup:", error);
throw error;
@@ -644,12 +743,10 @@ const getAutoBackup = async () => {
const lockBackup = async (backupId: string) => {
try {
return await PyroFetch(
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`,
{
method: "POST",
},
);
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
method: "POST",
});
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error locking backup:", error);
throw error;
@@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => {
const unlockBackup = async (backupId: string) => {
try {
return await PyroFetch(
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`,
{
method: "POST",
},
);
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
method: "POST",
});
await internalServerRefrence.value.refresh(["backups"]);
} catch (error) {
console.error("Error locking backup:", error);
console.error("Error unlocking backup:", error);
throw error;
}
};
@@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
try {
return await requestFn();
} catch (error) {
if (error instanceof PyroFetchError && error.statusCode === 401) {
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
await internalServerRefrence.value.refresh(["fs"]);
return await requestFn();
}
@@ -947,17 +1042,18 @@ const modules: any = {
general: {
get: async (serverId: string) => {
try {
const data = await PyroFetch<General>(`servers/${serverId}`);
// TODO: temp hack to fix hydration error
const data = await PyroFetch<General>(`servers/${serverId}`, {}, "general");
if (data.upstream?.project_id) {
const res = await $fetch(
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
);
data.project = res as Project;
}
if (import.meta.client) {
data.image = (await processImage(data.project?.icon_url)) ?? undefined;
}
const motd = await getMotd();
if (motd === "A Minecraft Server") {
await setMotd(
@@ -967,8 +1063,19 @@ const modules: any = {
data.motd = motd;
return data;
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
status: "error",
server_id: serverId,
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
updateName,
@@ -982,16 +1089,23 @@ const modules: any = {
content: {
get: async (serverId: string) => {
try {
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`);
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`, {}, "content");
return {
data:
internalServerRefrence.value.error === undefined
? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? ""))
: [],
data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")),
};
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
data: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
install: installContent,
@@ -1001,10 +1115,22 @@ const modules: any = {
backups: {
get: async (serverId: string) => {
try {
return { data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`) };
return {
data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`, {}, "backups"),
};
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
data: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
create: createBackup,
@@ -1020,10 +1146,26 @@ const modules: any = {
network: {
get: async (serverId: string) => {
try {
return { allocations: await PyroFetch<Allocation[]>(`servers/${serverId}/allocations`) };
return {
allocations: await PyroFetch<Allocation[]>(
`servers/${serverId}/allocations`,
{},
"network",
),
};
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
allocations: [],
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
reserveAllocation,
@@ -1035,10 +1177,19 @@ const modules: any = {
startup: {
get: async (serverId: string) => {
try {
return await PyroFetch<Startup>(`servers/${serverId}/startup`);
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
update: updateStartupSettings,
@@ -1046,20 +1197,39 @@ const modules: any = {
ws: {
get: async (serverId: string) => {
try {
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`);
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
},
fs: {
get: async (serverId: string) => {
try {
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`) };
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
} catch (error) {
internalServerRefrence.value.setError(error);
return undefined;
const fetchError =
error instanceof PyroServersFetchError
? error
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
return {
auth: undefined,
error: {
error: fetchError,
timestamp: Date.now(),
},
};
}
},
listDirContents,
@@ -1367,12 +1537,44 @@ type FSFunctions = {
downloadFile: (path: string, raw?: boolean) => Promise<any>;
};
type GeneralModule = General & GeneralFunctions;
type ContentModule = { data: Mod[] } & ContentFunctions;
type BackupsModule = { data: Backup[] } & BackupFunctions;
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
type StartupModule = Startup & StartupFunctions;
export type FSModule = { auth: JWTAuth } & FSFunctions;
type ModuleError = {
error: PyroServersFetchError;
timestamp: number;
};
type GeneralModule = General &
GeneralFunctions & {
error?: ModuleError;
};
type ContentModule = {
data: Mod[];
error?: ModuleError;
} & ContentFunctions;
type BackupsModule = {
data: Backup[];
error?: ModuleError;
} & BackupFunctions;
type NetworkModule = {
allocations: Allocation[];
error?: ModuleError;
} & NetworkFunctions;
type StartupModule = Startup &
StartupFunctions & {
error?: ModuleError;
};
type WSModule = JWTAuth & {
error?: ModuleError;
};
type FSModule = {
auth: JWTAuth;
error?: ModuleError;
} & FSFunctions;
type ModulesMap = {
general: GeneralModule;
@@ -1380,7 +1582,7 @@ type ModulesMap = {
backups: BackupsModule;
network: NetworkModule;
startup: StartupModule;
ws: JWTAuth;
ws: WSModule;
fs: FSModule;
};
@@ -1401,6 +1603,7 @@ export type Server<T extends avaliableModules> = {
preserveInstallState?: boolean;
},
) => Promise<void>;
loadModules: (modulesToLoad: avaliableModules) => Promise<void>;
setError: (error: Error) => void;
error?: Error;
serverId: string;
@@ -1419,58 +1622,92 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable
return;
}
const modulesToRefresh = refreshModules || includedModules;
const promises: Promise<void>[] = [];
const modulesToRefresh = [...new Set(refreshModules || includedModules)];
const serverError = new PyroServerError();
const uniqueModules = [...new Set(modulesToRefresh)];
const modulePromises = modulesToRefresh.map(async (module) => {
try {
const mods = modules[module];
if (!mods?.get) return;
for (const module of uniqueModules) {
const mods = modules[module];
if (mods.get) {
promises.push(
(async () => {
const data = await mods.get(serverId);
if (data) {
if (module === "general" && options?.preserveConnection) {
const updatedData = {
...server[module],
...data,
};
if (server[module]?.image) {
updatedData.image = server[module].image;
}
if (server[module]?.motd) {
updatedData.motd = server[module].motd;
}
if (options.preserveInstallState && server[module]?.status === "installing") {
updatedData.status = "installing";
}
server[module] = updatedData;
} else {
server[module] = { ...server[module], ...data };
}
}
})(),
);
const data = await mods.get(serverId);
if (!data) return;
if (module === "general" && options?.preserveConnection) {
server[module] = {
...server[module],
...data,
image: server[module]?.image || data.image,
motd: server[module]?.motd || data.motd,
status:
options.preserveInstallState && server[module]?.status === "installing"
? "installing"
: data.status,
};
} else {
server[module] = { ...server[module], ...data };
}
} catch (error) {
console.error(`Failed to refresh module ${module}:`, error);
if (error instanceof Error) {
serverError.addError(module, error);
}
}
});
await Promise.allSettled(modulePromises);
if (serverError.hasErrors()) {
if (server.error && server.error instanceof PyroServerError) {
serverError.errors.forEach((error, module) => {
(server.error as PyroServerError).addError(module, error);
});
} else {
server.setError(serverError);
}
}
},
loadModules: async (modulesToLoad: avaliableModules) => {
const newModules = modulesToLoad.filter((module) => !server[module]);
if (newModules.length === 0) return;
await Promise.all(promises);
newModules.forEach((module) => {
server[module] = modules[module];
});
await server.refresh(newModules);
},
setError: (error: Error) => {
server.error = error;
if (!server.error) {
server.error = error;
} else if (error instanceof PyroServerError) {
if (!(server.error instanceof PyroServerError)) {
const newError = new PyroServerError();
newError.addError("previous", server.error);
server.error = newError;
}
error.errors.forEach((err, module) => {
(server.error as PyroServerError).addError(module, err);
});
}
},
serverId,
});
for (const module of includedModules) {
const mods = modules[module];
server[module] = mods;
}
const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module));
const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module));
initialModules.forEach((module) => {
server[module] = modules[module];
});
internalServerRefrence.value = server;
await server.refresh(initialModules);
await server.refresh();
if (deferredModules.length > 0) {
await server.loadModules(deferredModules);
}
return server as Server<typeof includedModules>;
};

View File

@@ -294,12 +294,19 @@
<template #moderation> <ModerationIcon aria-hidden="true" /> Moderation </template>
<template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template>
</OverflowMenu>
<ButtonStyled v-else color="brand">
<nuxt-link to="/auth/sign-in">
<LogInIcon aria-hidden="true" />
Sign in
</nuxt-link>
</ButtonStyled>
<template v-else>
<ButtonStyled color="brand">
<nuxt-link to="/auth/sign-in">
<LogInIcon aria-hidden="true" />
Sign in
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link v-tooltip="'Settings'" to="/settings">
<SettingsIcon aria-label="Settings" />
</nuxt-link>
</ButtonStyled>
</template>
</div>
</header>
<header class="mobile-navigation mobile-only">
@@ -466,102 +473,95 @@
</button>
</div>
</header>
<main>
<main class="min-h-[calc(100vh-4.5rem-310.59px)]">
<ModalCreation v-if="auth.user" ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" />
<OrganizationCreateModal ref="modal_organization_creation" />
<slot id="main" />
</main>
<footer>
<div class="logo-info" role="region" aria-label="Modrinth information">
<BrandTextLogo
aria-hidden="true"
class="text-logo button-base mx-auto mb-4 lg:mx-0"
@click="developerModeIncrement()"
/>
<p class="mb-4">
<IntlFormatted :message-id="footerMessages.openSource">
<template #github-link="{ children }">
<a
:target="$external()"
href="https://github.com/modrinth"
class="text-link"
rel="noopener"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<p class="mb-4">
{{ config.public.branch }}@<a
:target="$external()"
:href="
'https://github.com/' +
config.public.owner +
'/' +
config.public.slug +
'/tree/' +
config.public.hash
"
class="text-link"
rel="noopener"
>{{ config.public.hash.substring(0, 7) }}</a
<footer
class="footer-brand-background experimental-styles-within mt-6 border-0 border-t-[1px] border-solid"
>
<div class="mx-auto flex max-w-screen-xl flex-col gap-6 p-6 pb-12 sm:px-12 md:py-12">
<div
class="grid grid-cols-1 gap-4 text-primary md:grid-cols-[1fr_2fr] lg:grid-cols-[auto_auto_auto_auto_auto]"
>
<div
class="flex flex-col items-center gap-3 md:items-start"
role="region"
aria-label="Modrinth information"
>
</p>
<p>© Rinth, Inc.</p>
</div>
<div class="links links-1" role="region" aria-label="Legal">
<h4 aria-hidden="true">{{ formatMessage(footerMessages.companyTitle) }}</h4>
<nuxt-link to="/legal/terms"> {{ formatMessage(footerMessages.terms) }}</nuxt-link>
<nuxt-link to="/legal/privacy"> {{ formatMessage(footerMessages.privacy) }}</nuxt-link>
<nuxt-link to="/legal/rules"> {{ formatMessage(footerMessages.rules) }}</nuxt-link>
<a :target="$external()" href="https://careers.modrinth.com">
{{ formatMessage(footerMessages.careers) }}
<span v-if="false" class="count-bubble">0</span>
</a>
</div>
<div class="links links-2" role="region" aria-label="Resources">
<h4 aria-hidden="true">{{ formatMessage(footerMessages.resourcesTitle) }}</h4>
<a :target="$external()" href="https://support.modrinth.com">
{{ formatMessage(footerMessages.support) }}
</a>
<a :target="$external()" href="https://blog.modrinth.com">
{{ formatMessage(footerMessages.blog) }}
</a>
<a :target="$external()" href="https://docs.modrinth.com">
{{ formatMessage(footerMessages.docs) }}
</a>
<a :target="$external()" href="https://status.modrinth.com">
{{ formatMessage(footerMessages.status) }}
</a>
</div>
<div class="links links-3" role="region" aria-label="Interact">
<h4 aria-hidden="true">{{ formatMessage(footerMessages.interactTitle) }}</h4>
<a rel="noopener" :target="$external()" href="https://discord.modrinth.com"> Discord </a>
<a rel="noopener" :target="$external()" href="https://x.com/modrinth"> X (Twitter) </a>
<a rel="noopener" :target="$external()" href="https://floss.social/@modrinth"> Mastodon </a>
<a rel="noopener" :target="$external()" href="https://crowdin.com/project/modrinth">
Crowdin
</a>
</div>
<div class="buttons">
<nuxt-link class="btn btn-outline btn-primary" to="/app">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.getModrinthApp) }}
</nuxt-link>
<button class="iconified-button raised-button" @click="changeTheme">
<MoonIcon v-if="$theme.active === 'light'" aria-hidden="true" />
<SunIcon v-else aria-hidden="true" />
{{ formatMessage(messages.changeTheme) }}
</button>
<nuxt-link class="iconified-button raised-button" to="/settings">
<SettingsIcon aria-hidden="true" />
{{ formatMessage(commonMessages.settingsLabel) }}
</nuxt-link>
</div>
<div class="not-affiliated-notice">
{{ formatMessage(footerMessages.legalDisclaimer) }}
<BrandTextLogo
aria-hidden="true"
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
@click="developerModeIncrement()"
/>
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
<ButtonStyled
v-for="(social, index) in socialLinks"
:key="`footer-social-${index}`"
circular
type="transparent"
>
<a
v-tooltip="social.label"
:href="social.href"
target="_blank"
:rel="`noopener${social.rel ? ` ${social.rel}` : ''}`"
>
<component :is="social.icon" class="h-5 w-5" />
</a>
</ButtonStyled>
</div>
<div class="mt-auto flex flex-wrap justify-center gap-3 md:flex-col">
<p class="m-0">
<IntlFormatted :message-id="footerMessages.openSource">
<template #github-link="{ children }">
<a
href="https://github.com/modrinth/code"
class="text-brand hover:underline"
target="_blank"
rel="noopener"
>
<component :is="() => children" />
</a>
</template>
</IntlFormatted>
</p>
<p class="m-0">© 2025 Rinth, Inc.</p>
</div>
</div>
<div class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:contents">
<div
v-for="group in footerLinks"
:key="group.label"
class="flex flex-col items-center gap-3 sm:items-start"
>
<h3 class="m-0 text-base text-contrast">{{ group.label }}</h3>
<template v-for="item in group.links" :key="item.label">
<nuxt-link
v-if="item.href.startsWith('/')"
:to="item.href"
class="w-fit hover:underline"
>
{{ item.label }}
</nuxt-link>
<a
v-else
:href="item.href"
class="w-fit hover:underline"
target="_blank"
rel="noopener"
>
{{ item.label }}
</a>
</template>
</div>
</div>
</div>
<div class="flex justify-center text-center text-xs font-medium text-secondary opacity-50">
{{ formatMessage(footerMessages.legalDisclaimer) }}
</div>
</div>
</footer>
</div>
@@ -599,6 +599,12 @@ import {
GlassesIcon,
PaintBrushIcon,
PackageOpenIcon,
DiscordIcon,
BlueskyIcon,
TumblrIcon,
TwitterIcon,
MastodonIcon,
GitHubIcon,
XIcon as CrossIcon,
ScaleIcon as ModerationIcon,
BellIcon as NotificationIcon,
@@ -708,50 +714,6 @@ const footerMessages = defineMessages({
id: "layout.footer.open-source",
defaultMessage: "Modrinth is <github-link>open source</github-link>.",
},
companyTitle: {
id: "layout.footer.company.title",
defaultMessage: "Company",
},
terms: {
id: "layout.footer.company.terms",
defaultMessage: "Terms",
},
privacy: {
id: "layout.footer.company.privacy",
defaultMessage: "Privacy",
},
rules: {
id: "layout.footer.company.rules",
defaultMessage: "Rules",
},
careers: {
id: "layout.footer.company.careers",
defaultMessage: "Careers",
},
resourcesTitle: {
id: "layout.footer.resources.title",
defaultMessage: "Resources",
},
support: {
id: "layout.footer.resources.support",
defaultMessage: "Support",
},
blog: {
id: "layout.footer.resources.blog",
defaultMessage: "Blog",
},
docs: {
id: "layout.footer.resources.docs",
defaultMessage: "Docs",
},
status: {
id: "layout.footer.resources.status",
defaultMessage: "Status",
},
interactTitle: {
id: "layout.footer.interact.title",
defaultMessage: "Interact",
},
legalDisclaimer: {
id: "layout.footer.legal-disclaimer",
defaultMessage:
@@ -1023,6 +985,194 @@ const { cycle: changeTheme } = useTheme();
function hideStagingBanner() {
cosmetics.value.hideStagingBanner = true;
}
const socialLinks = [
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.discord", defaultMessage: "Discord" }),
),
href: "https://discord.modrinth.com",
icon: DiscordIcon,
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.bluesky", defaultMessage: "Bluesky" }),
),
href: "https://bsky.app/profile/modrinth.com",
icon: BlueskyIcon,
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.mastodon", defaultMessage: "Mastodon" }),
),
href: "https://floss.social/@modrinth",
icon: MastodonIcon,
rel: "me",
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
),
href: "https://tumblr.com/modrinth",
icon: TumblrIcon,
},
{
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
href: "https://x.com/modrinth",
icon: TwitterIcon,
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
),
href: "https://github.com/modrinth",
icon: GitHubIcon,
},
];
const footerLinks = [
{
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
links: [
{
href: "https://blog.modrinth.com",
label: formatMessage(
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
),
},
{
href: "/news/changelog",
label: formatMessage(
defineMessage({ id: "layout.footer.about.changelog", defaultMessage: "Changelog" }),
),
},
{
href: "https://status.modrinth.com",
label: formatMessage(
defineMessage({ id: "layout.footer.about.status", defaultMessage: "Status" }),
),
},
{
href: "https://careers.modrinth.com",
label: formatMessage(
defineMessage({ id: "layout.footer.about.careers", defaultMessage: "Careers" }),
),
},
{
href: "/legal/cmp-info",
label: formatMessage(
defineMessage({
id: "layout.footer.about.rewards-program",
defaultMessage: "Rewards Program",
}),
),
},
],
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.products", defaultMessage: "Products" }),
),
links: [
{
href: "/plus",
label: formatMessage(
defineMessage({ id: "layout.footer.products.plus", defaultMessage: "Modrinth+" }),
),
},
{
href: "/app",
label: formatMessage(
defineMessage({ id: "layout.footer.products.app", defaultMessage: "Modrinth App" }),
),
},
{
href: "/servers",
label: formatMessage(
defineMessage({
id: "layout.footer.products.servers",
defaultMessage: "Modrinth Servers",
}),
),
},
],
},
{
label: formatMessage(
defineMessage({ id: "layout.footer.resources", defaultMessage: "Resources" }),
),
links: [
{
href: "https://support.modrinth.com",
label: formatMessage(
defineMessage({
id: "layout.footer.resources.help-center",
defaultMessage: "Help Center",
}),
),
},
{
href: "https://crowdin.com/project/modrinth",
label: formatMessage(
defineMessage({ id: "layout.footer.resources.translate", defaultMessage: "Translate" }),
),
},
{
href: "https://github.com/modrinth/code/issues",
label: formatMessage(
defineMessage({
id: "layout.footer.resources.report-issues",
defaultMessage: "Report issues",
}),
),
},
{
href: "https://docs.modrinth.com/api/",
label: formatMessage(
defineMessage({
id: "layout.footer.resources.api-docs",
defaultMessage: "API documentation",
}),
),
},
],
},
{
label: formatMessage(defineMessage({ id: "layout.footer.legal", defaultMessage: "Legal" })),
links: [
{
href: "/legal/rules",
label: formatMessage(
defineMessage({ id: "layout.footer.legal.rules", defaultMessage: "Content Rules" }),
),
},
{
href: "/legal/terms",
label: formatMessage(
defineMessage({ id: "layout.footer.legal.terms-of-use", defaultMessage: "Terms of Use" }),
),
},
{
href: "/legal/privacy",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.privacy-policy",
defaultMessage: "Privacy Policy",
}),
),
},
{
href: "/legal/security",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.security-notice",
defaultMessage: "Security Notice",
}),
),
},
],
},
];
</script>
<style lang="scss">
@@ -1037,127 +1187,9 @@ function hideStagingBanner() {
min-height: calc(100vh - var(--spacing-card-bg));
}
@media screen and (max-width: 750px) {
margin-bottom: calc(var(--size-mobile-navbar-height) + 2rem);
}
main {
grid-area: main;
}
footer {
margin: 6rem 0 2rem 0;
text-align: center;
display: grid;
grid-template:
"logo-info logo-info logo-info" auto
"links-1 links-2 links-3" auto
"buttons buttons buttons" auto
"notice notice notice" auto
/ 1fr 1fr 1fr;
max-width: 1280px;
.logo-info {
margin-left: auto;
margin-right: auto;
max-width: 15rem;
margin-bottom: 1rem;
grid-area: logo-info;
.text-logo {
width: 10rem;
height: auto;
}
}
.links {
display: flex;
flex-direction: column;
margin-bottom: 1rem;
h4 {
color: var(--color-text-dark);
margin: 0 0 1rem 0;
}
a {
margin: 0 0 1rem 0;
}
&.links-1 {
grid-area: links-1;
}
&.links-2 {
grid-area: links-2;
}
&.links-3 {
grid-area: links-3;
}
.count-bubble {
font-size: 1rem;
border-radius: 5rem;
background: var(--color-brand);
color: var(--color-text-inverted);
padding: 0 0.35rem;
margin-left: 0.25rem;
}
}
.buttons {
margin-left: auto;
margin-right: auto;
grid-area: buttons;
button,
a {
margin-bottom: 0.5rem;
margin-left: auto;
margin-right: auto;
}
}
.not-affiliated-notice {
grid-area: notice;
font-size: var(--font-size-xs);
text-align: center;
font-weight: 500;
margin-top: var(--spacing-card-md);
}
@media screen and (min-width: 1024px) {
display: grid;
margin-inline: auto;
grid-template:
"logo-info links-1 links-2 links-3 buttons" auto
"notice notice notice notice notice" auto;
text-align: unset;
.logo-info {
margin-right: 4rem;
}
.links {
margin-right: 4rem;
}
.buttons {
width: unset;
margin-left: 0;
button,
a {
margin-right: unset;
}
}
.not-affiliated-notice {
margin-top: 0;
}
}
}
}
@media (min-width: 1024px) {
@@ -1445,5 +1477,10 @@ function hideStagingBanner() {
display: flex;
}
}
.footer-brand-background {
background: var(--brand-gradient-strong-bg);
border-color: var(--brand-gradient-border);
}
</style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style>

View File

@@ -287,45 +287,90 @@
"layout.banner.verify-email.title": {
"message": "For security purposes, please verify your email address on Modrinth."
},
"layout.footer.company.careers": {
"layout.footer.about": {
"message": "About"
},
"layout.footer.about.blog": {
"message": "Blog"
},
"layout.footer.about.careers": {
"message": "Careers"
},
"layout.footer.company.privacy": {
"message": "Privacy"
"layout.footer.about.changelog": {
"message": "Changelog"
},
"layout.footer.company.rules": {
"message": "Rules"
"layout.footer.about.rewards-program": {
"message": "Rewards Program"
},
"layout.footer.company.terms": {
"message": "Terms"
"layout.footer.about.status": {
"message": "Status"
},
"layout.footer.company.title": {
"message": "Company"
},
"layout.footer.interact.title": {
"message": "Interact"
"layout.footer.legal": {
"message": "Legal"
},
"layout.footer.legal-disclaimer": {
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
},
"layout.footer.legal.privacy-policy": {
"message": "Privacy Policy"
},
"layout.footer.legal.rules": {
"message": "Content Rules"
},
"layout.footer.legal.security-notice": {
"message": "Security Notice"
},
"layout.footer.legal.terms-of-use": {
"message": "Terms of Use"
},
"layout.footer.open-source": {
"message": "Modrinth is <github-link>open source</github-link>."
},
"layout.footer.resources.blog": {
"message": "Blog"
"layout.footer.products": {
"message": "Products"
},
"layout.footer.resources.docs": {
"message": "Docs"
"layout.footer.products.app": {
"message": "Modrinth App"
},
"layout.footer.resources.status": {
"message": "Status"
"layout.footer.products.plus": {
"message": "Modrinth+"
},
"layout.footer.resources.support": {
"message": "Support"
"layout.footer.products.servers": {
"message": "Modrinth Servers"
},
"layout.footer.resources.title": {
"layout.footer.resources": {
"message": "Resources"
},
"layout.footer.resources.api-docs": {
"message": "API documentation"
},
"layout.footer.resources.help-center": {
"message": "Help Center"
},
"layout.footer.resources.report-issues": {
"message": "Report issues"
},
"layout.footer.resources.translate": {
"message": "Translate"
},
"layout.footer.social.bluesky": {
"message": "Bluesky"
},
"layout.footer.social.discord": {
"message": "Discord"
},
"layout.footer.social.github": {
"message": "GitHub"
},
"layout.footer.social.mastodon": {
"message": "Mastodon"
},
"layout.footer.social.tumblr": {
"message": "Tumblr"
},
"layout.footer.social.x": {
"message": "X"
},
"layout.menu-toggle.action": {
"message": "Toggle menu"
},

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;
}

View File

@@ -1,7 +1,11 @@
import dayjs from "dayjs";
import quarterOfYear from "dayjs/plugin/quarterOfYear";
import advanced from "dayjs/plugin/advancedFormat";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(quarterOfYear);
dayjs.extend(advanced);
dayjs.extend(relativeTime);
export default defineNuxtPlugin(() => {
return {

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT SUM(amount)\n FROM payouts_values\n WHERE user_id = $1 AND date_available > NOW()\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "sum",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "0379424a41b12db94c7734086fca5b96c8cdfe0a9f9c00e5c67e6b95a33c8c6b"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT SUM(amount)\n FROM payouts_values\n WHERE user_id = $1 AND date_available <= NOW()\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "sum",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
null
]
},
"hash": "0a31f7b04f4b68c556bdbfe373ef7945741f915d4ae657363fe67db46e8bd4cf"
}

View File

@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT date_available, SUM(amount) sum\n FROM payouts_values\n WHERE user_id = $1\n GROUP BY date_available\n ORDER BY date_available DESC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "date_available",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "sum",
"type_info": "Numeric"
}
],
"parameters": {
"Left": [
"Int8"
]
},
"nullable": [
false,
null
]
},
"hash": "58fbda9daed27c5c466849a944ab3df193679cef17e8588ac3be79978b60bdab"
}

View File

@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT id FROM users\n WHERE LOWER(email) = LOWER($1)\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "id",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false
]
},
"hash": "68619337ef34b588af21a40e5a60b54ce3a1dad45fb50bbc24a3ea34d2506578"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net\n ",
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type\n ",
"describe": {
"columns": [],
"parameters": {
@@ -24,5 +24,5 @@
},
"nullable": []
},
"hash": "933606e1ee3cd9a33e57eaf507ee8b7f966e8d3de5aaafadfe7ae30c12c925d2"
"hash": "693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59"
}

View File

@@ -115,7 +115,11 @@ impl ChargeItem {
payment_platform = EXCLUDED.payment_platform,
payment_platform_id = EXCLUDED.payment_platform_id,
parent_charge_id = EXCLUDED.parent_charge_id,
net = EXCLUDED.net
net = EXCLUDED.net,
price_id = EXCLUDED.price_id,
amount = EXCLUDED.amount,
currency_code = EXCLUDED.currency_code,
charge_type = EXCLUDED.charge_type
"#,
self.id.0,
self.user_id.0,

View File

@@ -176,7 +176,7 @@ pub struct Charge {
pub net: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ChargeType {
OneTime,

View File

@@ -411,11 +411,7 @@ pub async fn edit_subscription(
}
let interval = open_charge.due - Utc::now();
let duration = PriceDuration::iterator()
.min_by_key(|x| {
(x.duration().num_seconds() - interval.num_seconds()).abs()
})
.unwrap_or(PriceDuration::Monthly);
let duration = PriceDuration::Monthly;
let current_amount = match &current_price.prices {
Price::OneTime { price } => *price,
@@ -461,23 +457,6 @@ pub async fn edit_subscription(
}
let charge_id = generate_charge_id(&mut transaction).await?;
let mut charge = ChargeItem {
id: charge_id,
user_id: user.id.into(),
price_id: product_price.id,
amount: proration as i64,
currency_code: current_price.currency_code.clone(),
status: ChargeStatus::Processing,
due: Utc::now(),
last_attempt: None,
type_: ChargeType::Proration,
subscription_id: Some(subscription.id),
subscription_interval: Some(duration),
payment_platform: PaymentPlatform::Stripe,
payment_platform_id: None,
parent_charge_id: None,
net: None,
};
let customer_id = get_or_create_customer(
user.id,
@@ -504,6 +483,30 @@ pub async fn edit_subscription(
"modrinth_user_id".to_string(),
to_base62(user.id.0),
);
metadata.insert(
"modrinth_charge_id".to_string(),
to_base62(charge_id.0 as u64),
);
metadata.insert(
"modrinth_subscription_id".to_string(),
to_base62(subscription.id.0 as u64),
);
metadata.insert(
"modrinth_price_id".to_string(),
to_base62(product_price.id.0 as u64),
);
metadata.insert(
"modrinth_subscription_interval".to_string(),
open_charge
.subscription_interval
.unwrap_or(PriceDuration::Monthly)
.as_str()
.to_string(),
);
metadata.insert(
"modrinth_charge_type".to_string(),
ChargeType::Proration.as_str().to_string(),
);
intent.customer = Some(customer_id);
intent.metadata = Some(metadata);
@@ -529,9 +532,6 @@ pub async fn edit_subscription(
stripe::PaymentIntent::create(&stripe_client, intent)
.await?;
charge.payment_platform_id = Some(intent.id.to_string());
charge.upsert(&mut transaction).await?;
Some((proration, 0, intent))
}
} else {
@@ -938,6 +938,7 @@ pub async fn active_servers(
struct ActiveServer {
pub user_id: crate::models::ids::UserId,
pub server_id: String,
pub price_id: crate::models::ids::ProductPriceId,
pub interval: PriceDuration,
}
@@ -948,6 +949,7 @@ pub async fn active_servers(
SubscriptionMetadata::Pyro { id } => ActiveServer {
user_id: x.user_id.into(),
server_id: id.clone(),
price_id: x.price_id.into(),
interval: x.interval,
},
})
@@ -1139,7 +1141,7 @@ pub async fn initiate_payment(
let country = user_country.as_deref().unwrap_or("US");
let recommended_currency_code = infer_currency_code(country);
let (price, currency_code, interval, price_id, charge_id) =
let (price, currency_code, interval, price_id, charge_id, charge_type) =
match payment_request.charge {
ChargeRequestType::Existing { id } => {
let charge =
@@ -1160,6 +1162,7 @@ pub async fn initiate_payment(
charge.subscription_interval,
charge.price_id,
Some(id),
charge.type_,
)
}
ChargeRequestType::New {
@@ -1256,6 +1259,11 @@ pub async fn initiate_payment(
interval,
price_item.id,
None,
if let Price::Recurring { .. } = price_item.prices {
ChargeType::Subscription
} else {
ChargeType::OneTime
},
)
}
};
@@ -1314,6 +1322,11 @@ pub async fn initiate_payment(
);
}
metadata.insert(
"modrinth_charge_type".to_string(),
charge_type.as_str().to_string(),
);
if let Some(charge_id) = charge_id {
metadata.insert(
"modrinth_charge_id".to_string(),
@@ -1399,10 +1412,15 @@ pub async fn stripe_webhook(
pub user_subscription_item:
Option<user_subscription_item::UserSubscriptionItem>,
pub payment_metadata: Option<PaymentRequestMetadata>,
#[allow(dead_code)]
pub charge_type: ChargeType,
}
#[allow(clippy::too_many_arguments)]
async fn get_payment_intent_metadata(
payment_intent_id: PaymentIntentId,
amount: i64,
currency: String,
metadata: HashMap<String, String>,
pool: &PgPool,
redis: &RedisPool,
@@ -1445,6 +1463,15 @@ pub async fn stripe_webhook(
break 'metadata;
};
let charge_type = if let Some(charge_type) = metadata
.get("modrinth_charge_type")
.map(|x| ChargeType::from_string(x))
{
charge_type
} else {
break 'metadata;
};
let (charge, price, product, subscription) = if let Some(
mut charge,
) =
@@ -1549,8 +1576,8 @@ pub async fn stripe_webhook(
break 'metadata;
};
let (amount, subscription) = match &price.prices {
Price::OneTime { price } => (*price, None),
let subscription = match &price.prices {
Price::OneTime { .. } => None,
Price::Recurring { intervals } => {
let interval = if let Some(interval) = metadata
.get("modrinth_subscription_interval")
@@ -1561,7 +1588,7 @@ pub async fn stripe_webhook(
break 'metadata;
};
if let Some(price) = intervals.get(&interval) {
if intervals.get(&interval).is_some() {
let subscription_id = if let Some(subscription_id) = metadata
.get("modrinth_subscription_id")
.and_then(|x| parse_base62(x).ok())
@@ -1573,21 +1600,29 @@ pub async fn stripe_webhook(
break 'metadata;
};
let subscription = user_subscription_item::UserSubscriptionItem {
id: subscription_id,
user_id,
price_id,
interval,
created: Utc::now(),
status: SubscriptionStatus::Unprovisioned,
metadata: None,
let subscription = if let Some(mut subscription) = user_subscription_item::UserSubscriptionItem::get(subscription_id, pool).await? {
subscription.status = SubscriptionStatus::Unprovisioned;
subscription.price_id = price_id;
subscription.interval = interval;
subscription
} else {
user_subscription_item::UserSubscriptionItem {
id: subscription_id,
user_id,
price_id,
interval,
created: Utc::now(),
status: SubscriptionStatus::Unprovisioned,
metadata: None,
}
};
if charge_status != ChargeStatus::Failed {
subscription.upsert(transaction).await?;
}
(*price, Some(subscription))
Some(subscription)
} else {
break 'metadata;
}
@@ -1598,16 +1633,12 @@ pub async fn stripe_webhook(
id: charge_id,
user_id,
price_id,
amount: amount as i64,
currency_code: price.currency_code.clone(),
amount,
currency_code: currency,
status: charge_status,
due: Utc::now(),
last_attempt: Some(Utc::now()),
type_: if subscription.is_some() {
ChargeType::Subscription
} else {
ChargeType::OneTime
},
type_: charge_type,
subscription_id: subscription.as_ref().map(|x| x.id),
subscription_interval: subscription
.as_ref()
@@ -1634,6 +1665,7 @@ pub async fn stripe_webhook(
charge_item: charge,
user_subscription_item: subscription,
payment_metadata,
charge_type,
});
}
@@ -1651,6 +1683,8 @@ pub async fn stripe_webhook(
let mut metadata = get_payment_intent_metadata(
payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata,
&pool,
&redis,
@@ -1899,6 +1933,8 @@ pub async fn stripe_webhook(
let mut transaction = pool.begin().await?;
get_payment_intent_metadata(
payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata,
&pool,
&redis,
@@ -1917,6 +1953,8 @@ pub async fn stripe_webhook(
let metadata = get_payment_intent_metadata(
payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata,
&pool,
&redis,
@@ -2320,6 +2358,10 @@ pub async fn task(
"modrinth_charge_id".to_string(),
to_base62(charge.id.0 as u64),
);
metadata.insert(
"modrinth_charge_type".to_string(),
charge.type_.as_str().to_string(),
);
intent.metadata = Some(metadata);
intent.customer = Some(customer.id);

View File

@@ -9,7 +9,7 @@ use crate::queue::payouts::{make_aditude_request, PayoutsQueue};
use crate::queue::session::AuthQueue;
use crate::routes::ApiError;
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
use chrono::{Datelike, Duration, TimeZone, Utc, Weekday};
use chrono::{DateTime, Datelike, Duration, TimeZone, Utc, Weekday};
use hex::ToHex;
use hmac::{Hmac, Mac, NewMac};
use reqwest::Method;
@@ -763,6 +763,7 @@ pub async fn payment_methods(
pub struct UserBalance {
pub available: Decimal,
pub pending: Decimal,
pub dates: HashMap<DateTime<Utc>, Decimal>,
}
#[get("balance")]
@@ -791,27 +792,27 @@ async fn get_user_balance(
user_id: crate::database::models::ids::UserId,
pool: &PgPool,
) -> Result<UserBalance, sqlx::Error> {
let available = sqlx::query!(
let payouts = sqlx::query!(
"
SELECT SUM(amount)
SELECT date_available, SUM(amount) sum
FROM payouts_values
WHERE user_id = $1 AND date_available <= NOW()
WHERE user_id = $1
GROUP BY date_available
ORDER BY date_available DESC
",
user_id.0
)
.fetch_optional(pool)
.fetch_all(pool)
.await?;
let pending = sqlx::query!(
"
SELECT SUM(amount)
FROM payouts_values
WHERE user_id = $1 AND date_available > NOW()
",
user_id.0
)
.fetch_optional(pool)
.await?;
let available = payouts
.iter()
.filter(|x| x.date_available <= Utc::now())
.fold(Decimal::ZERO, |acc, x| acc + x.sum.unwrap_or(Decimal::ZERO));
let pending = payouts
.iter()
.filter(|x| x.date_available > Utc::now())
.fold(Decimal::ZERO, |acc, x| acc + x.sum.unwrap_or(Decimal::ZERO));
let withdrawn = sqlx::query!(
"
@@ -824,12 +825,6 @@ async fn get_user_balance(
.fetch_optional(pool)
.await?;
let available = available
.map(|x| x.sum.unwrap_or(Decimal::ZERO))
.unwrap_or(Decimal::ZERO);
let pending = pending
.map(|x| x.sum.unwrap_or(Decimal::ZERO))
.unwrap_or(Decimal::ZERO);
let (withdrawn, fees) = withdrawn
.map(|x| {
(
@@ -844,6 +839,10 @@ async fn get_user_balance(
- withdrawn.round_dp(16)
- fees.round_dp(16),
pending,
dates: payouts
.iter()
.map(|x| (x.date_available, x.sum.unwrap_or(Decimal::ZERO)))
.collect(),
})
}

View File

@@ -28,6 +28,7 @@ use crate::{
pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("user", web::get().to(user_auth_get));
cfg.route("users", web::get().to(users_get));
cfg.route("user_email", web::get().to(admin_user_email));
cfg.service(
web::scope("user")
@@ -44,6 +45,62 @@ pub fn config(cfg: &mut web::ServiceConfig) {
);
}
#[derive(Deserialize)]
pub struct UserEmailQuery {
pub email: String,
}
pub async fn admin_user_email(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
email: web::Query<UserEmailQuery>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
)
.await
.map(|x| x.1)?;
if !user.role.is_admin() {
return Err(ApiError::CustomAuthentication(
"You do not have permission to get a user from their email!"
.to_string(),
));
}
let user_id = sqlx::query!(
"
SELECT id FROM users
WHERE LOWER(email) = LOWER($1)
",
email.email
)
.fetch_optional(&**pool)
.await?
.map(|x| x.id)
.ok_or_else(|| {
ApiError::InvalidInput(
"The email provided is not associated with a user!".to_string(),
)
})?;
let user =
User::get_id(crate::database::models::UserId(user_id), &**pool, &redis)
.await?;
if let Some(user) = user {
Ok(HttpResponse::Ok().json(user))
} else {
Err(ApiError::NotFound)
}
}
pub async fn projects_list(
req: HttpRequest,
info: web::Path<(String,)>,