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> <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 { 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 { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { handleError } from '@/store/notifications.js' import { handleError } from '@/store/notifications.js'
import { handleSevereError } from '@/store/error.js' import { handleSevereError } from '@/store/error.js'
@@ -13,6 +22,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const errorModal = ref() const errorModal = ref()
const error = ref() const error = ref()
const closable = ref(true) const closable = ref(true)
const errorCollapsed = ref(false)
const title = ref('An error occurred') const title = ref('An error occurred')
const errorType = ref('unknown') const errorType = ref('unknown')
@@ -118,6 +128,26 @@ async function repairInstance() {
} }
loadingRepair.value = false 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> </script>
<template> <template>
@@ -244,16 +274,9 @@ async function repairInstance() {
</div> </div>
</template> </template>
<template v-else> <template v-else>
{{ error.message ?? error }} {{ debugInfo }}
</template> </template>
<template <template v-if="hasDebugInfo">
v-if="
errorType === 'directory_move' ||
errorType === 'minecraft_auth' ||
errorType === 'state_init' ||
errorType === 'no_loader_version'
"
>
<hr /> <hr />
<p> <p>
If nothing is working and you need help, visit 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 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: assist! Make sure to provide the following debug information to the agent:
</p> </p>
<details>
<summary>Debug information</summary>
{{ error.message ?? error }}
</details>
</template> </template>
</div> </div>
<div class="input-group push-right"> <div class="flex items-center gap-2">
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a> <ButtonStyled>
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button> <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> </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> </div>
</ModalWrapper> </ModalWrapper>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -133,6 +133,19 @@
"sidebar" "sidebar"
/ 100%; / 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) { @media screen and (min-width: 1024px) {
&.sidebar { &.sidebar {
grid-template: 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 { .normal-page__sidebar {
grid-area: sidebar; grid-area: sidebar;
} }

View File

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

View File

@@ -1,329 +1,366 @@
<template> <template>
<div class="card moderation-checklist"> <div
<h1>Moderation checklist</h1> 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"
<div v-if="done"> :class="collapsed ? `sm:max-w-[300px]` : 'sm:max-w-[600px]'"
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p> >
<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>
<div v-else-if="generatedMessage"> <Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
<p> <div class="my-4 h-[1px] w-full bg-divider" />
Enter your moderation message here. Remember to check the Moderation tab to answer any <div v-if="done">
questions an author might have! <p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
</p>
<div class="markdown-editor-spacing">
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
</div> </div>
</div> <div v-else-if="generatedMessage">
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'"> <p>
<h2 v-if="modPackData"> Enter your moderation message here. Remember to check the Moderation tab to answer any
Modpack permissions questions an author might have!
<template v-if="modPackIndex + 1 <= modPackData.length"> </p>
({{ modPackIndex + 1 }} / {{ modPackData.length }}) <div class="markdown-editor-spacing">
</template> <MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
</h2> </div>
<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>
<div v-else-if="!modPackData[modPackIndex]"> <div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
<p>All permission checks complete!</p> <h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
<div class="input-group modpack-buttons"> Modpack permissions
<button class="btn" @click="modPackIndex -= 1"> <template v-if="modPackIndex + 1 <= modPackData.length">
<LeftArrowIcon aria-hidden="true" /> ({{ modPackIndex + 1 }} / {{ modPackData.length }})
Previous </template>
</button> </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> </div>
<div v-else> <div v-else>
<div v-if="modPackData[modPackIndex].type === 'unknown'"> <h2 class="m-0 mb-2 text-lg font-extrabold">{{ steps[currentStepIndex].question }}</h2>
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p> <template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
<div class="input-group"> <strong>Guidance:</strong>
<button <ul class="mb-3 mt-2 leading-tight">
v-for="(option, index) in fileApprovalTypes" <li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
:key="index" {{ rule }}
class="btn" </li>
:class="{ </ul>
'option-selected': modPackData[modPackIndex].status === option.id, </template>
}" <template
@click="modPackData[modPackIndex].status = option.id" v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
> >
{{ option.name }} <strong>Reject things like:</strong>
</button> <ul class="mb-3 mt-2 leading-tight">
</div> <li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
<template v-if="modPackData[modPackIndex].status !== 'unidentified'"> {{ example }}
<div class="universal-labels"></div> </li>
<label for="proof"> </ul>
<span class="label__title">Proof</span> </template>
</label> <template
<input v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
id="proof" >
v-model="modPackData[modPackIndex].proof" <strong>Exceptions:</strong>
type="text" <ul class="mb-3 mt-2 leading-tight">
autocomplete="off" <li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
placeholder="Enter proof of status..." {{ exception }}
/> </li>
<label for="link"> </ul>
<span class="label__title">Link</span> </template>
</label> <p v-if="steps[currentStepIndex].id === 'title'">
<input <strong>Title:</strong> {{ project.title }}
id="link" </p>
v-model="modPackData[modPackIndex].url" <p v-if="steps[currentStepIndex].id === 'slug'">
type="text" <strong>Slug:</strong> {{ project.slug }}
autocomplete="off" </p>
placeholder="Enter link of project..." <p v-if="steps[currentStepIndex].id === 'summary'">
/> <strong>Summary:</strong> {{ project.description }}
<label for="title"> </p>
<span class="label__title">Title</span> <p v-if="steps[currentStepIndex].id === 'links'">
</label> <template v-if="project.issues_url">
<input <strong>Issues: </strong>
id="title" <a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
v-model="modPackData[modPackIndex].title"
type="text"
autocomplete="off"
placeholder="Enter title of project..."
/>
</template> </template>
</div> <template v-if="project.source_url">
<div v-else-if="modPackData[modPackIndex].type === 'flame'"> <strong>Source: </strong>
<p> <a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
What is the approval type of {{ modPackData[modPackIndex].title }} (<a </template>
:href="modPackData[modPackIndex].url" <template v-if="project.wiki_url">
target="_blank" <strong>Wiki: </strong>
class="text-link" <a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
>{{ modPackData[modPackIndex].url }}</a </template>
>? <template v-if="project.discord_url">
</p> <strong>Discord: </strong>
<div class="input-group"> <a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
<button <br />
v-for="(option, index) in fileApprovalTypes" </template>
:key="index" <template v-for="(donation, index) in project.donation_urls" :key="index">
class="btn" <strong>{{ donation.platform }}: </strong>
:class="{ <a class="text-link" :href="donation.url">{{ donation.url }}</a>
'option-selected': modPackData[modPackIndex].status === option.id, <br />
}" </template>
@click="modPackData[modPackIndex].status = option.id" </p>
> <p v-if="steps[currentStepIndex].id === 'categories'">
{{ option.name }} <strong>Categories:</strong>
</button> <Categories
</div> :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>
<div <div
v-if=" 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'"> <div
Does this project provide identification and permission for v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
<strong>{{ modPackData[modPackIndex].file_name }}</strong (x) => x.fillers && x.fillers.length > 0,
>? )"
</p> :key="index"
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'"> >
Does this project provide attribution for <div v-for="(filler, idx) in option.fillers" :key="idx">
<strong>{{ modPackData[modPackIndex].file_name }}</strong <label :for="filler.id">
>? <span class="label__title">
</p> {{ filler.question }}
<p v-else> <span v-if="filler.required" class="required">*</span>
Does this project provide proof of permission for </span>
<strong>{{ modPackData[modPackIndex].file_name }}</strong </label>
>? <div v-if="filler.large" class="markdown-editor-spacing">
</p> <MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
<div class="input-group"> </div>
<button <input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
v-for="(option, index) in filePermissionTypes" </div>
:key="index" </div>
class="btn" </div>
:class="{ </div>
'option-selected': modPackData[modPackIndex].approved === option.id, <div class="mt-auto">
}" <div
@click="modPackData[modPackIndex].approved = option.id" class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
> >
{{ option.name }} <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> </button>
</div> </div>
</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> </Collapsible>
<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>
</div> </div>
</template> </template>
@@ -337,8 +374,9 @@ import {
XIcon as CrossIcon, XIcon as CrossIcon,
EyeOffIcon, EyeOffIcon,
ExitIcon, ExitIcon,
ScaleIcon,
} from "@modrinth/assets"; } 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"; import Categories from "~/components/ui/search/Categories.vue";
const props = defineProps({ const props = defineProps({
@@ -355,8 +393,14 @@ const props = defineProps({
required: true, required: true,
default: () => {}, default: () => {},
}, },
collapsed: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(["exit", "toggleCollapsed"]);
const steps = computed(() => const steps = computed(() =>
[ [
{ {
@@ -1008,6 +1052,20 @@ async function sendMessage(status) {
const router = useNativeRouter(); 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() { async function goToNextProject() {
const project = props.futureProjects[0]; const project = props.futureProjects[0];
@@ -1031,23 +1089,8 @@ async function goToNextProject() {
<style scoped lang="scss"> <style scoped lang="scss">
.moderation-checklist { .moderation-checklist {
position: sticky; @media (prefers-reduced-motion) {
bottom: 0; transition: none !important;
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;
} }
.option-selected { .option-selected {

View File

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

View File

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

View File

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

View File

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

View File

@@ -260,7 +260,25 @@
</div> </div>
<NewModal ref="viewLogModal" class="z-[9999]" header="Viewing selected logs"> <NewModal ref="viewLogModal" class="z-[9999]" header="Viewing selected logs">
<div class="text-contrast"> <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> </div>
</NewModal> </NewModal>
</div> </div>
@@ -272,6 +290,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { useDebounceFn } from "@vueuse/core"; import { useDebounceFn } from "@vueuse/core";
import { NewModal } from "@modrinth/ui"; import { NewModal } from "@modrinth/ui";
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue"; import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
import DOMPurify from "dompurify";
import { usePyroConsole } from "~/store/console.ts"; import { usePyroConsole } from "~/store/console.ts";
const { $cosmetics } = useNuxtApp(); 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( watch(
() => pyroConsole.filteredOutput.value, () => pyroConsole.filteredOutput.value,
() => { () => {

View File

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

View File

@@ -69,11 +69,13 @@
</div> </div>
<div <div
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'" 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" /> <div class="flex flex-row gap-2">
Your server has been suspended due to a billing issue. Please visit your billing settings or <UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
contact Modrinth Support for more information. update your billing information or contact Modrinth Support for more information.
</div>
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
</div> </div>
</NuxtLink> </NuxtLink>
</template> </template>

View File

@@ -2,7 +2,7 @@
<div <div
v-if="uptimeSeconds || uptimeSeconds !== 0" v-if="uptimeSeconds || uptimeSeconds !== 0"
v-tooltip="`Online for ${verboseUptime}`" 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 data-pyro-uptime
> >
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div> <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 // Developer flags
developerMode: false, developerMode: false,
showVersionFilesInTable: false, showVersionFilesInTable: false,
// showAdsWithPlus: false, // showAdsWithPlus: false,
alwaysShowChecklistAsPopup: true,
// Feature toggles // Feature toggles
projectTypesPrimaryNav: false, projectTypesPrimaryNav: false,

View File

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

View File

@@ -294,12 +294,19 @@
<template #moderation> <ModerationIcon aria-hidden="true" /> Moderation </template> <template #moderation> <ModerationIcon aria-hidden="true" /> Moderation </template>
<template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template> <template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template>
</OverflowMenu> </OverflowMenu>
<ButtonStyled v-else color="brand"> <template v-else>
<nuxt-link to="/auth/sign-in"> <ButtonStyled color="brand">
<LogInIcon aria-hidden="true" /> <nuxt-link to="/auth/sign-in">
Sign in <LogInIcon aria-hidden="true" />
</nuxt-link> Sign in
</ButtonStyled> </nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<nuxt-link v-tooltip="'Settings'" to="/settings">
<SettingsIcon aria-label="Settings" />
</nuxt-link>
</ButtonStyled>
</template>
</div> </div>
</header> </header>
<header class="mobile-navigation mobile-only"> <header class="mobile-navigation mobile-only">
@@ -466,102 +473,95 @@
</button> </button>
</div> </div>
</header> </header>
<main> <main class="min-h-[calc(100vh-4.5rem-310.59px)]">
<ModalCreation v-if="auth.user" ref="modal_creation" /> <ModalCreation v-if="auth.user" ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" /> <CollectionCreateModal ref="modal_collection_creation" />
<OrganizationCreateModal ref="modal_organization_creation" /> <OrganizationCreateModal ref="modal_organization_creation" />
<slot id="main" /> <slot id="main" />
</main> </main>
<footer> <footer
<div class="logo-info" role="region" aria-label="Modrinth information"> class="footer-brand-background experimental-styles-within mt-6 border-0 border-t-[1px] border-solid"
<BrandTextLogo >
aria-hidden="true" <div class="mx-auto flex max-w-screen-xl flex-col gap-6 p-6 pb-12 sm:px-12 md:py-12">
class="text-logo button-base mx-auto mb-4 lg:mx-0" <div
@click="developerModeIncrement()" class="grid grid-cols-1 gap-4 text-primary md:grid-cols-[1fr_2fr] lg:grid-cols-[auto_auto_auto_auto_auto]"
/> >
<p class="mb-4"> <div
<IntlFormatted :message-id="footerMessages.openSource"> class="flex flex-col items-center gap-3 md:items-start"
<template #github-link="{ children }"> role="region"
<a aria-label="Modrinth information"
: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
> >
</p> <BrandTextLogo
<p>© Rinth, Inc.</p> aria-hidden="true"
</div> class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
<div class="links links-1" role="region" aria-label="Legal"> @click="developerModeIncrement()"
<h4 aria-hidden="true">{{ formatMessage(footerMessages.companyTitle) }}</h4> />
<nuxt-link to="/legal/terms"> {{ formatMessage(footerMessages.terms) }}</nuxt-link> <div class="flex flex-wrap justify-center gap-px sm:-mx-2">
<nuxt-link to="/legal/privacy"> {{ formatMessage(footerMessages.privacy) }}</nuxt-link> <ButtonStyled
<nuxt-link to="/legal/rules"> {{ formatMessage(footerMessages.rules) }}</nuxt-link> v-for="(social, index) in socialLinks"
<a :target="$external()" href="https://careers.modrinth.com"> :key="`footer-social-${index}`"
{{ formatMessage(footerMessages.careers) }} circular
<span v-if="false" class="count-bubble">0</span> type="transparent"
</a> >
</div> <a
<div class="links links-2" role="region" aria-label="Resources"> v-tooltip="social.label"
<h4 aria-hidden="true">{{ formatMessage(footerMessages.resourcesTitle) }}</h4> :href="social.href"
<a :target="$external()" href="https://support.modrinth.com"> target="_blank"
{{ formatMessage(footerMessages.support) }} :rel="`noopener${social.rel ? ` ${social.rel}` : ''}`"
</a> >
<a :target="$external()" href="https://blog.modrinth.com"> <component :is="social.icon" class="h-5 w-5" />
{{ formatMessage(footerMessages.blog) }} </a>
</a> </ButtonStyled>
<a :target="$external()" href="https://docs.modrinth.com"> </div>
{{ formatMessage(footerMessages.docs) }} <div class="mt-auto flex flex-wrap justify-center gap-3 md:flex-col">
</a> <p class="m-0">
<a :target="$external()" href="https://status.modrinth.com"> <IntlFormatted :message-id="footerMessages.openSource">
{{ formatMessage(footerMessages.status) }} <template #github-link="{ children }">
</a> <a
</div> href="https://github.com/modrinth/code"
<div class="links links-3" role="region" aria-label="Interact"> class="text-brand hover:underline"
<h4 aria-hidden="true">{{ formatMessage(footerMessages.interactTitle) }}</h4> target="_blank"
<a rel="noopener" :target="$external()" href="https://discord.modrinth.com"> Discord </a> rel="noopener"
<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> <component :is="() => children" />
<a rel="noopener" :target="$external()" href="https://crowdin.com/project/modrinth"> </a>
Crowdin </template>
</a> </IntlFormatted>
</div> </p>
<div class="buttons"> <p class="m-0">© 2025 Rinth, Inc.</p>
<nuxt-link class="btn btn-outline btn-primary" to="/app"> </div>
<DownloadIcon aria-hidden="true" /> </div>
{{ formatMessage(messages.getModrinthApp) }} <div class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:contents">
</nuxt-link> <div
<button class="iconified-button raised-button" @click="changeTheme"> v-for="group in footerLinks"
<MoonIcon v-if="$theme.active === 'light'" aria-hidden="true" /> :key="group.label"
<SunIcon v-else aria-hidden="true" /> class="flex flex-col items-center gap-3 sm:items-start"
{{ formatMessage(messages.changeTheme) }} >
</button> <h3 class="m-0 text-base text-contrast">{{ group.label }}</h3>
<nuxt-link class="iconified-button raised-button" to="/settings"> <template v-for="item in group.links" :key="item.label">
<SettingsIcon aria-hidden="true" /> <nuxt-link
{{ formatMessage(commonMessages.settingsLabel) }} v-if="item.href.startsWith('/')"
</nuxt-link> :to="item.href"
</div> class="w-fit hover:underline"
<div class="not-affiliated-notice"> >
{{ formatMessage(footerMessages.legalDisclaimer) }} {{ 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> </div>
</footer> </footer>
</div> </div>
@@ -599,6 +599,12 @@ import {
GlassesIcon, GlassesIcon,
PaintBrushIcon, PaintBrushIcon,
PackageOpenIcon, PackageOpenIcon,
DiscordIcon,
BlueskyIcon,
TumblrIcon,
TwitterIcon,
MastodonIcon,
GitHubIcon,
XIcon as CrossIcon, XIcon as CrossIcon,
ScaleIcon as ModerationIcon, ScaleIcon as ModerationIcon,
BellIcon as NotificationIcon, BellIcon as NotificationIcon,
@@ -708,50 +714,6 @@ const footerMessages = defineMessages({
id: "layout.footer.open-source", id: "layout.footer.open-source",
defaultMessage: "Modrinth is <github-link>open source</github-link>.", 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: { legalDisclaimer: {
id: "layout.footer.legal-disclaimer", id: "layout.footer.legal-disclaimer",
defaultMessage: defaultMessage:
@@ -1023,6 +985,194 @@ const { cycle: changeTheme } = useTheme();
function hideStagingBanner() { function hideStagingBanner() {
cosmetics.value.hideStagingBanner = true; 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> </script>
<style lang="scss"> <style lang="scss">
@@ -1037,127 +1187,9 @@ function hideStagingBanner() {
min-height: calc(100vh - var(--spacing-card-bg)); min-height: calc(100vh - var(--spacing-card-bg));
} }
@media screen and (max-width: 750px) {
margin-bottom: calc(var(--size-mobile-navbar-height) + 2rem);
}
main { main {
grid-area: 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) { @media (min-width: 1024px) {
@@ -1445,5 +1477,10 @@ function hideStagingBanner() {
display: flex; display: flex;
} }
} }
.footer-brand-background {
background: var(--brand-gradient-strong-bg);
border-color: var(--brand-gradient-border);
}
</style> </style>
<style src="vue-multiselect/dist/vue-multiselect.css"></style> <style src="vue-multiselect/dist/vue-multiselect.css"></style>

View File

@@ -287,45 +287,90 @@
"layout.banner.verify-email.title": { "layout.banner.verify-email.title": {
"message": "For security purposes, please verify your email address on Modrinth." "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" "message": "Careers"
}, },
"layout.footer.company.privacy": { "layout.footer.about.changelog": {
"message": "Privacy" "message": "Changelog"
}, },
"layout.footer.company.rules": { "layout.footer.about.rewards-program": {
"message": "Rules" "message": "Rewards Program"
}, },
"layout.footer.company.terms": { "layout.footer.about.status": {
"message": "Terms" "message": "Status"
}, },
"layout.footer.company.title": { "layout.footer.legal": {
"message": "Company" "message": "Legal"
},
"layout.footer.interact.title": {
"message": "Interact"
}, },
"layout.footer.legal-disclaimer": { "layout.footer.legal-disclaimer": {
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT." "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": { "layout.footer.open-source": {
"message": "Modrinth is <github-link>open source</github-link>." "message": "Modrinth is <github-link>open source</github-link>."
}, },
"layout.footer.resources.blog": { "layout.footer.products": {
"message": "Blog" "message": "Products"
}, },
"layout.footer.resources.docs": { "layout.footer.products.app": {
"message": "Docs" "message": "Modrinth App"
}, },
"layout.footer.resources.status": { "layout.footer.products.plus": {
"message": "Status" "message": "Modrinth+"
}, },
"layout.footer.resources.support": { "layout.footer.products.servers": {
"message": "Support" "message": "Modrinth Servers"
}, },
"layout.footer.resources.title": { "layout.footer.resources": {
"message": "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": { "layout.menu-toggle.action": {
"message": "Toggle menu" "message": "Toggle menu"
}, },

View File

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

View File

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

View File

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

View File

@@ -40,12 +40,7 @@
</span> </span>
<span> Whether or not the subscription should be unprovisioned on refund. </span> <span> Whether or not the subscription should be unprovisioned on refund. </span>
</label> </label>
<Toggle <Toggle id="unprovision" v-model="unprovision" />
id="unprovision"
:model-value="unprovision"
:checked="unprovision"
@update:model-value="() => (unprovision = !unprovision)"
/>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
<ButtonStyled color="brand"> <ButtonStyled color="brand">
@@ -114,7 +109,7 @@
</div> </div>
</template> </template>
<script setup> <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 { formatPrice } from "@modrinth/utils";
import { CheckIcon, XIcon } from "@modrinth/assets"; import { CheckIcon, XIcon } from "@modrinth/assets";
import { products } from "~/generated/state.json"; 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> <script setup>
import { import {
BoxIcon,
CalendarIcon, CalendarIcon,
EditIcon, EditIcon,
XIcon,
SaveIcon,
UploadIcon,
TrashIcon,
LinkIcon,
LockIcon,
GridIcon, GridIcon,
ImageIcon, ImageIcon,
ListIcon,
UpdatedIcon,
LibraryIcon, LibraryIcon,
BoxIcon, LinkIcon,
ListIcon,
LockIcon,
SaveIcon,
TrashIcon,
UpdatedIcon,
UploadIcon,
XIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { import {
PopoutMenu,
FileInput,
DropdownSelect,
Avatar, Avatar,
Button, Button,
commonMessages, commonMessages,
ConfirmModal, ConfirmModal,
DropdownSelect,
FileInput,
PopoutMenu,
} from "@modrinth/ui"; } from "@modrinth/ui";
import { isAdmin } from "@modrinth/utils"; import { isAdmin } from "@modrinth/utils";
@@ -651,7 +651,7 @@ async function saveChanges() {
method: "PATCH", method: "PATCH",
body: { body: {
name: name.value, name: name.value,
description: summary.value, description: summary.value || null,
status: visibility.value, status: visibility.value,
new_projects: newProjectIds, new_projects: newProjectIds,
}, },

View File

@@ -49,7 +49,9 @@
</div> </div>
</nuxt-link> </nuxt-link>
<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" :key="collection.id"
:to="`/collection/${collection.id}`" :to="`/collection/${collection.id}`"
class="universal-card recessed collection" class="universal-card recessed collection"

View File

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

View File

@@ -1,39 +1,95 @@
<template> <template>
<div> <div class="experimental-styles-within">
<section class="universal-card"> <section class="universal-card">
<h2 class="text-2xl">Revenue</h2> <h2 class="text-2xl">Revenue</h2>
<div v-if="userBalance.available >= minWithdraw"> <div class="grid-display">
<p> <div class="grid-display__item">
You have <div class="label">Available now</div>
<strong>{{ $formatMoney(userBalance.available) }}</strong> <div class="value">
available to withdraw. <strong>{{ $formatMoney(userBalance.pending) }}</strong> of your {{ $formatMoney(userBalance.available) }}
balance is <nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>. </div>
</p> </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> </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"> <div class="input-group mt-4">
<nuxt-link <span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
v-if="userBalance.available >= minWithdraw" <nuxt-link
class="iconified-button brand-button" :aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
to="/dashboard/revenue/withdraw" :class="{ 'disabled-link': userBalance.available < minWithdraw }"
> :disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
<TransferIcon /> Withdraw :tabindex="userBalance.available < minWithdraw ? -1 : undefined"
</nuxt-link> class="iconified-button brand-button"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
</span>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers"> <NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
<HistoryIcon /> View transfer history <HistoryIcon />
View transfer history
</NuxtLink> </NuxtLink>
</div> </div>
<p> <p class="text-sm text-secondary">
By uploading projects to Modrinth and withdrawing money from your account, you agree to the 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 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> </p>
</section> </section>
<section class="universal-card"> <section class="universal-card">
@@ -46,12 +102,13 @@
{{ auth.user.payout_data.paypal_address }} {{ auth.user.payout_data.paypal_address }}
</p> </p>
<button class="btn mt-4" @click="removeAuthProvider('paypal')"> <button class="btn mt-4" @click="removeAuthProvider('paypal')">
<XIcon /> Disconnect account <XIcon />
Disconnect account
</button> </button>
</template> </template>
<template v-else> <template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p> <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 /> <PayPalIcon />
Sign in with PayPal Sign in with PayPal
</a> </a>
@@ -60,7 +117,8 @@
<p> <p>
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email, Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
visit visit
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>. <nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
.
</p> </p>
<h3>Venmo</h3> <h3>Venmo</h3>
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p> <p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
@@ -68,18 +126,32 @@
<input <input
id="venmo" id="venmo"
v-model="auth.user.payout_data.venmo_handle" v-model="auth.user.payout_data.venmo_handle"
autocomplete="off"
class="mt-4" class="mt-4"
type="search"
name="search" name="search"
placeholder="@example" 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> </section>
</div> </div>
</template> </template>
<script setup> <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 auth = await useAuth();
const minWithdraw = ref(0.01); const minWithdraw = ref(0.01);
@@ -88,6 +160,33 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
useBaseFetch(`payout/balance`, { apiVersion: 3 }), 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() { async function updateVenmo() {
startLoading(); startLoading();
try { try {
@@ -118,4 +217,16 @@ strong {
color: var(--color-text-dark); color: var(--color-text-dark);
font-weight: 500; 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> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="markdown-body"> <div class="markdown-body">
<h1>Rewards Program Information</h1> <h1>Rewards Program Information</h1>
<p><em>Last modified: Sep 12, 2024</em></p> <p><em>Last modified: Feb 20, 2025</em></p>
<p> <p>
This page was created for transparency for how the rewards program works on Modrinth. Feel This page was created for transparency for how the rewards program works on Modrinth. Feel
free to join our Discord or email free to join our Discord or email
@@ -82,42 +82,41 @@
<p> <p>
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all 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 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 from our ad providers, which is 60 days after the last day of each month.
some example dates of how NET 60 payments are made:
</p> </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> <table>
<thead> <tr>
<tr> <th>Timeline</th>
<th>Date</th> <th>Date</th>
<th>Payment available date</th> </tr>
</tr> <tr>
</thead> <td>Revenue earned on</td>
<tbody> <td>
<tr> <input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
<td>January 1st</td> <noscript
<td>March 31st</td> >(JavaScript must be enabled for the date picker to function, example date: 2024-07-15)
</tr> </noscript>
<tr> </td>
<td>January 15th</td> </tr>
<td>March 31st</td> <tr>
</tr> <td>End of the month</td>
<tr> <td>{{ formatDate(endOfMonthDate) }}</td>
<td>March 3rd</td> </tr>
<td>May 30th</td> <tr>
</tr> <td>NET 60 policy applied</td>
<tr> <td>+ 60 days</td>
<td>June 30th</td> </tr>
<td>August 29th</td> <tr class="final-result">
</tr> <td>Available for withdrawal</td>
<tr> <td>{{ formatDate(withdrawalDate) }}</td>
<td>July 14th</td> </tr>
<td>September 29th</td>
</tr>
<tr>
<td>October 12th</td>
<td>December 30th</td>
</tr>
</tbody>
</table> </table>
<h3>How do I know Modrinth is being transparent about revenue?</h3> <h3>How do I know Modrinth is being transparent about revenue?</h3>
<p> <p>
@@ -127,12 +126,40 @@
revenue distribution system</a revenue distribution system</a
>. We also have an >. We also have an
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users <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> </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> </div>
</template> </template>
<script setup> <script lang="ts" setup>
import dayjs from "dayjs";
import { computed, ref } from "vue";
import { formatDate, formatMoney } from "@modrinth/utils";
const description = const description =
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft."; "Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
@@ -142,4 +169,18 @@ useSeoMeta({
ogTitle: "Rewards Program Information", ogTitle: "Rewards Program Information",
ogDescription: description, 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> </script>

View File

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

View File

@@ -258,7 +258,8 @@
<button <button
v-if=" v-if="
result.installed || 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 server.general?.project?.id === result.project_id
" "
disabled disabled
@@ -376,7 +377,9 @@ async function updateServerContext() {
if (!auth.value.user) { if (!auth.value.user) {
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath)); router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
} else if (route.query.sid !== null) { } 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]; ) ?? versions[0];
if (projectType.value.id === "modpack") { if (projectType.value.id === "modpack") {
await server.value.general?.reinstall( await server.value.general.reinstall(
route.query.sid, server.value.serverId,
false, false,
project.project_id, project.project_id,
version.id, version.id,
@@ -504,7 +507,7 @@ async function serverInstall(project) {
eraseDataOnInstall.value, eraseDataOnInstall.value,
); );
project.installed = true; 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") { } else if (projectType.value.id === "mod") {
await server.value.content.install("mod", version.project_id, version.id); await server.value.content.install("mod", version.project_id, version.id);
await server.value.refresh(["content"]); await server.value.refresh(["content"]);

View File

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

View File

@@ -10,10 +10,10 @@
<div class="grid place-content-center rounded-full bg-bg-blue p-4"> <div class="grid place-content-center rounded-full bg-bg-blue p-4">
<TransferIcon class="size-12 text-blue" /> <TransferIcon class="size-12 text-blue" />
</div> </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> </div>
<p class="text-lg text-secondary"> <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> </p>
</div> </div>
</div> </div>
@@ -47,17 +47,18 @@
<div class="grid place-content-center rounded-full bg-bg-orange p-4"> <div class="grid place-content-center rounded-full bg-bg-orange p-4">
<LockIcon class="size-12 text-orange" /> <LockIcon class="size-12 text-orange" />
</div> </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> </div>
<p class="text-lg text-secondary"> <p class="text-lg text-secondary">
{{ {{
serverData.suspension_reason serverData.suspension_reason === "cancelled"
? `Your server has been suspended: ${serverData.suspension_reason}` ? "Your subscription has been cancelled."
: "Your server has been suspended." : serverData.suspension_reason
? `Your server has been suspended: ${serverData.suspension_reason}`
: "Your server has been suspended."
}} }}
<br /> <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> </p>
</div> </div>
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')"> <ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
@@ -66,7 +67,10 @@
</div> </div>
</div> </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" 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"> <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. this is an error, please contact Modrinth support.
</p> </p>
</div> </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')"> <ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
<button class="mt-6 !w-full">Go back to all servers</button> <button class="mt-6 !w-full">Go back to all servers</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </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" 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"> <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> </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" 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"> <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. temporary network issue. You'll be reconnected automatically.
</p> </p>
</div> </div>
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" /> <UiCopyCode :text="JSON.stringify(server.general?.error)" />
<ButtonStyled <ButtonStyled
:disabled="formattedTime !== '00'" :disabled="formattedTime !== '00'"
size="large" size="large"
@@ -228,7 +233,7 @@
:show-loader-label="showLoaderLabel" :show-loader-label="showLoaderLabel"
:uptime-seconds="uptimeSeconds" :uptime-seconds="uptimeSeconds"
:linked="true" :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>
</div> </div>
@@ -363,7 +368,6 @@
</div> </div>
</div> </div>
</div> </div>
<NuxtPage <NuxtPage
:route="route" :route="route"
:is-connected="isConnected" :is-connected="isConnected"
@@ -425,21 +429,25 @@ const createdAt = ref(
const route = useNativeRoute(); const route = useNativeRoute();
const router = useRouter(); const router = useRouter();
const serverId = route.params.id as string; const serverId = route.params.id as string;
const server = await usePyroServer(serverId, [
"general", const server = await usePyroServer(serverId, ["general", "ws"]);
"content",
"backups", const loadModulesPromise = Promise.resolve().then(() => {
"network", if (server.general?.status === "suspended") {
"startup", return;
"ws", }
"fs", return server.loadModules(["content", "backups", "network", "startup", "fs"]);
]); });
provide("modulesLoaded", loadModulesPromise);
watch( watch(
() => server.error, () => [server.general?.error, server.ws?.error],
(newError) => { ([generalError, wsError]) => {
if (server.general?.status === "suspended") return; if (server.general?.status === "suspended") return;
if (newError && !newError.message.includes("Forbidden")) {
const error = generalError?.error || wsError?.error;
if (error && error.statusCode !== 403) {
startPolling(); startPolling();
} }
}, },
@@ -450,11 +458,9 @@ const errorMessage = ref("An unexpected error occurred.");
const errorLog = ref(""); const errorLog = ref("");
const errorLogFile = ref(""); const errorLogFile = ref("");
const serverData = computed(() => server.general); const serverData = computed(() => server.general);
const error = ref<Error | null>(null);
const isConnected = ref(false); const isConnected = ref(false);
const isWSAuthIncorrect = ref(false); const isWSAuthIncorrect = ref(false);
const pyroConsole = usePyroConsole(); const pyroConsole = usePyroConsole();
console.log("||||||||||||||||||||||| console", pyroConsole.output);
const cpuData = ref<number[]>([]); const cpuData = ref<number[]>([]);
const ramData = ref<number[]>([]); const ramData = ref<number[]>([]);
const isActioning = ref(false); const isActioning = ref(false);
@@ -465,6 +471,7 @@ const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>();
const uptimeSeconds = ref(0); const uptimeSeconds = ref(0);
const firstConnect = ref(true); const firstConnect = ref(true);
const copied = ref(false); const copied = ref(false);
const error = ref<Error | null>(null);
const initialConsoleMessage = [ const initialConsoleMessage = [
" __________________________________________________", " __________________________________________________",
@@ -665,6 +672,26 @@ const newLoader = ref<string | null>(null);
const newLoaderVersion = ref<string | null>(null); const newLoaderVersion = ref<string | null>(null);
const newMCVersion = 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) => { const handleInstallationResult = async (data: WSInstallationResultEvent) => {
switch (data.result) { switch (data.result) {
case "ok": { 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"]) => { const updateStats = (currentStats: Stats["current"]) => {
isConnected.value = true; isConnected.value = true;
stats.value = { stats.value = {
@@ -924,6 +931,10 @@ const cleanup = () => {
onMounted(() => { onMounted(() => {
isMounted.value = true; isMounted.value = true;
if (server.general?.status === "suspended") {
isLoading.value = false;
return;
}
if (server.error) { if (server.error) {
if (!server.error.message.includes("Forbidden")) { if (!server.error.message.includes("Forbidden")) {
startPolling(); startPolling();
@@ -991,7 +1002,7 @@ definePageMeta({
}); });
</script> </script>
<style scoped> <style>
@keyframes server-action-buttons-anim { @keyframes server-action-buttons-anim {
0% { 0% {
opacity: 0; opacity: 0;

View File

@@ -1,6 +1,30 @@
<template> <template>
<div class="contents"> <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 <LazyUiServersBackupCreateModal
ref="createBackupModal" ref="createBackupModal"
:server="server" :server="server"
@@ -241,6 +265,7 @@ import {
BoxIcon, BoxIcon,
LockIcon, LockIcon,
LockOpenIcon, LockOpenIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ref, computed } from "vue"; import { ref, computed } from "vue";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
@@ -297,33 +322,37 @@ const showbackupSettingsModal = () => {
backupSettingsModal.value?.show(); backupSettingsModal.value?.show();
}; };
const handleBackupCreated = (payload: { success: boolean; message: string }) => { const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupRenamed = (payload: { success: boolean; message: string }) => { const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupRestored = (payload: { success: boolean; message: string }) => { const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
}; };
const handleBackupDeleted = (payload: { success: boolean; message: string }) => { const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
if (payload.success) { if (payload.success) {
addNotification({ type: "success", text: payload.message }); addNotification({ type: "success", text: payload.message });
await props.server.refresh(["backups"]);
} else { } else {
addNotification({ type: "error", text: payload.message }); addNotification({ type: "error", text: payload.message });
} }
@@ -387,8 +416,8 @@ onMounted(() => {
} }
if (hasOngoingBackups) { if (hasOngoingBackups) {
refreshInterval.value = setInterval(() => { refreshInterval.value = setInterval(async () => {
props.server.refresh(["backups"]); await props.server.refresh(["backups"]);
}, 10000); }, 10000);
} }
}); });

View File

@@ -10,7 +10,30 @@
@change-version="changeModVersion($event)" @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 ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
<div class="relative flex h-full w-full flex-col"> <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"> <div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
@@ -322,6 +345,7 @@ import {
WrenchIcon, WrenchIcon,
ListIcon, ListIcon,
FileIcon, FileIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import { ref, computed, watch, onMounted, onUnmounted } from "vue"; 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"]>; server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
}>(); }>();
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
@@ -245,6 +247,8 @@ useHead({
}); });
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => { const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
await modulesLoaded;
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value; const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
try { try {
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults); 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 () => { onMounted(async () => {
await modulesLoaded;
await initializeFileEdit();
await import("ace-builds"); await import("ace-builds");
await import("ace-builds/src-noconflict/mode-json"); await import("ace-builds/src-noconflict/mode-json");
await import("ace-builds/src-noconflict/mode-yaml"); await import("ace-builds/src-noconflict/mode-yaml");

View File

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

View File

@@ -5,7 +5,7 @@
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
<label for="server-name-field" class="flex flex-col gap-2"> <label for="server-name-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server name</span> <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> </label>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<input <input
@@ -64,10 +64,7 @@
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
<label for="server-icon-field" class="flex flex-col gap-2"> <label for="server-icon-field" class="flex flex-col gap-2">
<span class="text-lg font-bold text-contrast">Server icon</span> <span class="text-lg font-bold text-contrast">Server icon</span>
<span> <span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
Change your server's icon. Changes will be visible on the Minecraft server list and on
Modrinth.
</span>
</label> </label>
<div class="flex gap-4"> <div class="flex gap-4">
<div <div
@@ -91,20 +88,7 @@
> >
<EditIcon class="h-8 w-8 text-contrast" /> <EditIcon class="h-8 w-8 text-contrast" />
</div> </div>
<img <UiServersServerIcon :image="icon" />
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"
/>
</div> </div>
<ButtonStyled> <ButtonStyled>
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon"> <button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
@@ -234,67 +218,106 @@ const resetGeneral = () => {
const uploadFile = async (e: Event) => { const uploadFile = async (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]; 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) => { 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 canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
const img = new Image(); const img = new Image();
img.src = URL.createObjectURL(file);
img.onload = () => { img.onload = () => {
canvas.width = 64; canvas.width = 64;
canvas.height = 64; canvas.height = 64;
ctx?.drawImage(img, 0, 0, 64, 64); ctx?.drawImage(img, 0, 0, 64, 64);
// turn the downscaled image back to a png file
canvas.toBlob((blob) => { canvas.toBlob((blob) => {
if (blob) { if (blob) {
const data = new File([blob], "server-icon.png", { type: "image/png" }); resolve(new File([blob], "server-icon.png", { type: "image/png" }));
resolve(data);
} else { } else {
reject(new Error("Canvas toBlob failed")); reject(new Error("Canvas toBlob failed"));
} }
}, "image/png"); }, "image/png");
URL.revokeObjectURL(img.src);
}; };
img.onerror = reject; 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({ try {
group: "serverOptions", if (data.value?.image) {
type: "success", await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
title: "Server icon updated", await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
text: "Your server icon was successfully changed.", }
});
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 () => { const resetIcon = async () => {
if (data.value?.image) { if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false); try {
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false); await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
await new Promise((resolve) => setTimeout(resolve, 2000)); await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
await reloadNuxtApp();
addNotification({ useState(`server-icon-${props.server.serverId}`).value = undefined;
group: "serverOptions", if (data.value) data.value.image = undefined;
type: "success",
title: "Server icon reset", await props.server.refresh(["general"]);
text: "Your server icon was successfully reset.",
}); 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 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"> <div class="flex h-full flex-col">
<!-- Subdomain section --> <!-- Subdomain section -->
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
@@ -155,7 +177,7 @@
</span> </span>
</div> </div>
<ButtonStyled type="standard" color="brand" @click="showNewAllocationModal"> <ButtonStyled type="standard" @click="showNewAllocationModal">
<button class="!w-full sm:!w-auto"> <button class="!w-full sm:!w-auto">
<PlusIcon /> <PlusIcon />
<span>New allocation</span> <span>New allocation</span>
@@ -247,6 +269,7 @@ import {
SaveIcon, SaveIcon,
InfoIcon, InfoIcon,
UploadIcon, UploadIcon,
IssuesIcon,
} from "@modrinth/assets"; } from "@modrinth/assets";
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui"; import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
import { ref, computed, nextTick } from "vue"; import { ref, computed, nextTick } from "vue";
@@ -286,12 +309,11 @@ const addNewAllocation = async () => {
try { try {
await props.server.network?.reserveAllocation(newAllocationName.value); await props.server.network?.reserveAllocation(newAllocationName.value);
await props.server.refresh(["network"]);
newAllocationModal.value?.hide(); newAllocationModal.value?.hide();
newAllocationName.value = ""; newAllocationName.value = "";
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
@@ -332,8 +354,8 @@ const confirmDeleteAllocation = async () => {
if (allocationToDelete.value === null) return; if (allocationToDelete.value === null) return;
await props.server.network?.deleteAllocation(allocationToDelete.value); await props.server.network?.deleteAllocation(allocationToDelete.value);
await props.server.refresh(["network"]);
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
@@ -349,12 +371,11 @@ const editAllocation = async () => {
try { try {
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value); await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
await props.server.refresh(["network"]);
editAllocationModal.value?.hide(); editAllocationModal.value?.hide();
newAllocationName.value = ""; newAllocationName.value = "";
await props.server.refresh();
addNotification({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",

View File

@@ -1,7 +1,27 @@
<template> <template>
<div class="relative h-full w-full select-none overflow-y-auto"> <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 <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" class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
> >
<div class="card flex flex-col gap-4"> <div class="card flex flex-col gap-4">
@@ -118,8 +138,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, computed } from "vue"; import { ref, watch, computed, inject } from "vue";
import { EyeIcon, SearchIcon } from "@modrinth/assets"; import { EyeIcon, SearchIcon, IssuesIcon } from "@modrinth/assets";
import Fuse from "fuse.js"; import Fuse from "fuse.js";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
@@ -134,7 +154,9 @@ const isUpdating = ref(false);
const searchInput = ref(""); const searchInput = ref("");
const data = computed(() => props.server.general); const data = computed(() => props.server.general);
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
const { data: propsData, status } = await useAsyncData("ServerProperties", async () => { const { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
await modulesLoaded;
const rawProps = await props.server.fs?.downloadFile("server.properties"); const rawProps = await props.server.fs?.downloadFile("server.properties");
if (!rawProps) return null; if (!rawProps) return null;

View File

@@ -1,7 +1,33 @@
<template> <template>
<div class="relative h-full w-full"> <div class="relative h-full w-full">
<div v-if="data" class="flex h-full w-full flex-col gap-4"> <div
<div class="rounded-2xl border-solid border-orange bg-bg-orange p-4 text-contrast"> 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. These settings are for advanced users. Changing them can break your server.
</div> </div>
@@ -84,7 +110,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { UpdatedIcon } from "@modrinth/assets"; import { UpdatedIcon, IssuesIcon } from "@modrinth/assets";
import { ButtonStyled } from "@modrinth/ui"; import { ButtonStyled } from "@modrinth/ui";
import type { Server } from "~/composables/pyroServers"; import type { Server } from "~/composables/pyroServers";
@@ -109,13 +135,41 @@ const jdkBuildMap = [
{ value: "graal", label: "GraalVM" }, { value: "graal", label: "GraalVM" },
]; ];
const invocation = ref(startupSettings.value?.invocation); const invocation = ref("");
const jdkVersion = ref( const jdkVersion = ref("");
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "", 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 isUpdating = ref(false);
const compatibleJavaVersions = computed(() => { const compatibleJavaVersions = computed(() => {
@@ -139,15 +193,6 @@ const displayedJavaVersions = computed(() => {
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value; 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 () => { const saveStartup = async () => {
try { try {
isUpdating.value = true; isUpdating.value = true;
@@ -155,14 +200,25 @@ const saveStartup = async () => {
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value; const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value;
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.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 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({ addNotification({
group: "serverOptions", group: "serverOptions",
type: "success", type: "success",
title: "Server settings updated", title: "Server settings updated",
text: "Your server settings were successfully changed.", text: "Your server settings were successfully changed.",
}); });
await props.server.refresh();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
addNotification({ addNotification({
@@ -177,15 +233,13 @@ const saveStartup = async () => {
}; };
const resetStartup = () => { const resetStartup = () => {
invocation.value = startupSettings.value?.invocation; invocation.value = originalInvocation.value;
jdkVersion.value = jdkVersion.value = originalJdkVersion.value;
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || ""; jdkBuild.value = originalJdkBuild.value;
jdkBuild.value =
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "";
}; };
const resetToDefault = () => { const resetToDefault = () => {
invocation.value = startupSettings.value?.original_invocation; invocation.value = startupSettings.value?.original_invocation ?? "";
}; };
</script> </script>

View File

@@ -1,5 +1,5 @@
<template> <template>
<section class="universal-card"> <section class="universal-card experimental-styles-within">
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2> <h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
<p>{{ formatMessage(messages.subscriptionDescription) }}</p> <p>{{ formatMessage(messages.subscriptionDescription) }}</p>
<div class="universal-card recessed"> <div class="universal-card recessed">
@@ -88,67 +88,69 @@
v-if="midasCharge && midasCharge.status === 'failed'" v-if="midasCharge && midasCharge.status === 'failed'"
class="ml-auto flex flex-row-reverse items-center gap-2" 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 <button
v-if="midasCharge && midasCharge.status === 'failed'" class="ml-auto"
class="iconified-button raised-button"
@click=" @click="
() => { () => {
purchaseModalStep = 0; cancelSubscriptionId = midasSubscription.id;
$refs.purchaseModal.show(); $refs.modalCancel.show();
} }
" "
> >
<UpdatedIcon /> <XIcon /> Cancel
Update method
</button> </button>
<OverflowMenu </ButtonStyled>
class="btn icon-only transparent" <ButtonStyled
: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
v-else-if="midasCharge && midasCharge.status === 'cancelled'" v-else-if="midasCharge && midasCharge.status === 'cancelled'"
class="btn btn-purple btn-large ml-auto" color="purple"
@click="cancelSubscription(midasSubscription.id, false)"
> >
<RightArrowIcon /> Resubscribe <button class="ml-auto" @click="cancelSubscription(midasSubscription.id, false)">
</button> Resubscribe <RightArrowIcon />
<button </button>
v-else </ButtonStyled>
class="btn btn-purple btn-large ml-auto" <ButtonStyled v-else color="purple" size="large">
@click=" <button
() => { class="ml-auto"
purchaseModalStep = 0; @click="
$refs.purchaseModal.show(); () => {
} $refs.midasPurchaseModal.show();
" }
> "
<RightArrowIcon /> >
Subscribe Subscribe <RightArrowIcon />
</button> </button>
</ButtonStyled>
</div> </div>
</div> </div>
</div> </div>
@@ -282,25 +284,37 @@
getPyroCharge(subscription).status !== 'cancelled' && getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed' getPyroCharge(subscription).status !== 'failed'
" "
type="standard"
@click="showPyroCancelModal(subscription.id)"
> >
<button class="text-contrast"> <button @click="showPyroCancelModal(subscription.id)">
<XIcon /> <XIcon />
Cancel Cancel
</button> </button>
</ButtonStyled> </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 <ButtonStyled
v-else-if=" v-else-if="
getPyroCharge(subscription) && getPyroCharge(subscription) &&
(getPyroCharge(subscription).status === 'cancelled' || (getPyroCharge(subscription).status === 'cancelled' ||
getPyroCharge(subscription).status === 'failed') getPyroCharge(subscription).status === 'failed')
" "
type="standard"
color="green" color="green"
@click="resubscribePyro(subscription.id)"
> >
<button class="text-contrast">Resubscribe</button> <button @click="resubscribePyro(subscription.id)">
Resubscribe <RightArrowIcon />
</button>
</ButtonStyled> </ButtonStyled>
</div> </div>
</div> </div>
@@ -312,7 +326,7 @@
</div> </div>
</section> </section>
<section class="universal-card"> <section class="universal-card experimental-styles-within">
<ConfirmModal <ConfirmModal
ref="modal_confirm" ref="modal_confirm"
:title="formatMessage(deleteModalMessages.title)" :title="formatMessage(deleteModalMessages.title)"
@@ -321,7 +335,7 @@
@proceed="removePaymentMethod(removePaymentMethodIndex)" @proceed="removePaymentMethod(removePaymentMethodIndex)"
/> />
<PurchaseModal <PurchaseModal
ref="purchaseModal" ref="midasPurchaseModal"
:product="midasProduct" :product="midasProduct"
:country="country" :country="country"
:publishable-key="config.public.stripePublishableKey" :publishable-key="config.public.stripePublishableKey"
@@ -342,6 +356,38 @@
:payment-methods="paymentMethods" :payment-methods="paymentMethods"
:return-url="`${config.public.siteUrl}/settings/billing`" :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"> <NewModal ref="addPaymentMethodModal">
<template #title> <template #title>
<span class="text-lg font-extrabold text-contrast"> <span class="text-lg font-extrabold text-contrast">
@@ -359,15 +405,19 @@
<div id="address-element"></div> <div id="address-element"></div>
<div id="payment-element" class="mt-4"></div> <div id="payment-element" class="mt-4"></div>
</div> </div>
<div v-show="loadingPaymentMethodModal === 2" class="input-group push-right mt-auto pt-4"> <div v-show="loadingPaymentMethodModal === 2" class="input-group mt-auto pt-4">
<button class="btn" @click="$refs.addPaymentMethodModal.hide()"> <ButtonStyled color="brand">
<XIcon /> <button :disabled="loadingAddMethod" @click="submit">
{{ formatMessage(commonMessages.cancelButton) }} <PlusIcon />
</button> {{ formatMessage(messages.paymentMethodAdd) }}
<button class="btn btn-primary" :disabled="loadingAddMethod" @click="submit"> </button>
<PlusIcon /> </ButtonStyled>
{{ formatMessage(messages.paymentMethodAdd) }} <ButtonStyled>
</button> <button @click="$refs.addPaymentMethodModal.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div> </div>
</div> </div>
</NewModal> </NewModal>
@@ -442,6 +492,7 @@
</div> </div>
</div> </div>
<OverflowMenu <OverflowMenu
:dropdown-id="`${baseId}-payment-method-overflow-${index}`"
class="btn icon-only transparent" class="btn icon-only transparent"
:options=" :options="
[ [
@@ -493,6 +544,7 @@ import {
} from "@modrinth/ui"; } from "@modrinth/ui";
import { import {
PlusIcon, PlusIcon,
ArrowBigUpDashIcon,
XIcon, XIcon,
CardIcon, CardIcon,
MoreVerticalIcon, MoreVerticalIcon,
@@ -515,6 +567,10 @@ definePageMeta({
middleware: "auth", middleware: "auth",
}); });
const app = useNuxtApp();
const auth = await useAuth();
const baseId = useId();
useHead({ useHead({
script: [ script: [
{ {
@@ -704,7 +760,7 @@ const pyroSubscriptions = computed(() => {
}); });
}); });
const purchaseModal = ref(); const midasPurchaseModal = ref();
const country = useUserCountry(); const country = useUserCountry();
const price = computed(() => const price = computed(() =>
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)), 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) => { const resubscribePyro = async (subscriptionId) => {
try { try {
await useBaseFetch(`billing/subscription/${subscriptionId}`, { await useBaseFetch(`billing/subscription/${subscriptionId}`, {

View File

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

View File

@@ -1,7 +1,11 @@
import dayjs from "dayjs"; import dayjs from "dayjs";
import quarterOfYear from "dayjs/plugin/quarterOfYear"; import quarterOfYear from "dayjs/plugin/quarterOfYear";
import advanced from "dayjs/plugin/advancedFormat";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(quarterOfYear); dayjs.extend(quarterOfYear);
dayjs.extend(advanced);
dayjs.extend(relativeTime);
export default defineNuxtPlugin(() => { export default defineNuxtPlugin(() => {
return { 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", "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": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
@@ -24,5 +24,5 @@
}, },
"nullable": [] "nullable": []
}, },
"hash": "933606e1ee3cd9a33e57eaf507ee8b7f966e8d3de5aaafadfe7ae30c12c925d2" "hash": "693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59"
} }

View File

@@ -115,7 +115,11 @@ impl ChargeItem {
payment_platform = EXCLUDED.payment_platform, payment_platform = EXCLUDED.payment_platform,
payment_platform_id = EXCLUDED.payment_platform_id, payment_platform_id = EXCLUDED.payment_platform_id,
parent_charge_id = EXCLUDED.parent_charge_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.id.0,
self.user_id.0, self.user_id.0,

View File

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

View File

@@ -411,11 +411,7 @@ pub async fn edit_subscription(
} }
let interval = open_charge.due - Utc::now(); let interval = open_charge.due - Utc::now();
let duration = PriceDuration::iterator() let duration = PriceDuration::Monthly;
.min_by_key(|x| {
(x.duration().num_seconds() - interval.num_seconds()).abs()
})
.unwrap_or(PriceDuration::Monthly);
let current_amount = match &current_price.prices { let current_amount = match &current_price.prices {
Price::OneTime { price } => *price, Price::OneTime { price } => *price,
@@ -461,23 +457,6 @@ pub async fn edit_subscription(
} }
let charge_id = generate_charge_id(&mut transaction).await?; 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( let customer_id = get_or_create_customer(
user.id, user.id,
@@ -504,6 +483,30 @@ pub async fn edit_subscription(
"modrinth_user_id".to_string(), "modrinth_user_id".to_string(),
to_base62(user.id.0), 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.customer = Some(customer_id);
intent.metadata = Some(metadata); intent.metadata = Some(metadata);
@@ -529,9 +532,6 @@ pub async fn edit_subscription(
stripe::PaymentIntent::create(&stripe_client, intent) stripe::PaymentIntent::create(&stripe_client, intent)
.await?; .await?;
charge.payment_platform_id = Some(intent.id.to_string());
charge.upsert(&mut transaction).await?;
Some((proration, 0, intent)) Some((proration, 0, intent))
} }
} else { } else {
@@ -938,6 +938,7 @@ pub async fn active_servers(
struct ActiveServer { struct ActiveServer {
pub user_id: crate::models::ids::UserId, pub user_id: crate::models::ids::UserId,
pub server_id: String, pub server_id: String,
pub price_id: crate::models::ids::ProductPriceId,
pub interval: PriceDuration, pub interval: PriceDuration,
} }
@@ -948,6 +949,7 @@ pub async fn active_servers(
SubscriptionMetadata::Pyro { id } => ActiveServer { SubscriptionMetadata::Pyro { id } => ActiveServer {
user_id: x.user_id.into(), user_id: x.user_id.into(),
server_id: id.clone(), server_id: id.clone(),
price_id: x.price_id.into(),
interval: x.interval, interval: x.interval,
}, },
}) })
@@ -1139,7 +1141,7 @@ pub async fn initiate_payment(
let country = user_country.as_deref().unwrap_or("US"); let country = user_country.as_deref().unwrap_or("US");
let recommended_currency_code = infer_currency_code(country); 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 { match payment_request.charge {
ChargeRequestType::Existing { id } => { ChargeRequestType::Existing { id } => {
let charge = let charge =
@@ -1160,6 +1162,7 @@ pub async fn initiate_payment(
charge.subscription_interval, charge.subscription_interval,
charge.price_id, charge.price_id,
Some(id), Some(id),
charge.type_,
) )
} }
ChargeRequestType::New { ChargeRequestType::New {
@@ -1256,6 +1259,11 @@ pub async fn initiate_payment(
interval, interval,
price_item.id, price_item.id,
None, 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 { if let Some(charge_id) = charge_id {
metadata.insert( metadata.insert(
"modrinth_charge_id".to_string(), "modrinth_charge_id".to_string(),
@@ -1399,10 +1412,15 @@ pub async fn stripe_webhook(
pub user_subscription_item: pub user_subscription_item:
Option<user_subscription_item::UserSubscriptionItem>, Option<user_subscription_item::UserSubscriptionItem>,
pub payment_metadata: Option<PaymentRequestMetadata>, pub payment_metadata: Option<PaymentRequestMetadata>,
#[allow(dead_code)]
pub charge_type: ChargeType,
} }
#[allow(clippy::too_many_arguments)]
async fn get_payment_intent_metadata( async fn get_payment_intent_metadata(
payment_intent_id: PaymentIntentId, payment_intent_id: PaymentIntentId,
amount: i64,
currency: String,
metadata: HashMap<String, String>, metadata: HashMap<String, String>,
pool: &PgPool, pool: &PgPool,
redis: &RedisPool, redis: &RedisPool,
@@ -1445,6 +1463,15 @@ pub async fn stripe_webhook(
break 'metadata; 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( let (charge, price, product, subscription) = if let Some(
mut charge, mut charge,
) = ) =
@@ -1549,8 +1576,8 @@ pub async fn stripe_webhook(
break 'metadata; break 'metadata;
}; };
let (amount, subscription) = match &price.prices { let subscription = match &price.prices {
Price::OneTime { price } => (*price, None), Price::OneTime { .. } => None,
Price::Recurring { intervals } => { Price::Recurring { intervals } => {
let interval = if let Some(interval) = metadata let interval = if let Some(interval) = metadata
.get("modrinth_subscription_interval") .get("modrinth_subscription_interval")
@@ -1561,7 +1588,7 @@ pub async fn stripe_webhook(
break 'metadata; break 'metadata;
}; };
if let Some(price) = intervals.get(&interval) { if intervals.get(&interval).is_some() {
let subscription_id = if let Some(subscription_id) = metadata let subscription_id = if let Some(subscription_id) = metadata
.get("modrinth_subscription_id") .get("modrinth_subscription_id")
.and_then(|x| parse_base62(x).ok()) .and_then(|x| parse_base62(x).ok())
@@ -1573,21 +1600,29 @@ pub async fn stripe_webhook(
break 'metadata; break 'metadata;
}; };
let subscription = user_subscription_item::UserSubscriptionItem { let subscription = if let Some(mut subscription) = user_subscription_item::UserSubscriptionItem::get(subscription_id, pool).await? {
id: subscription_id, subscription.status = SubscriptionStatus::Unprovisioned;
user_id, subscription.price_id = price_id;
price_id, subscription.interval = interval;
interval,
created: Utc::now(), subscription
status: SubscriptionStatus::Unprovisioned, } else {
metadata: None, 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 { if charge_status != ChargeStatus::Failed {
subscription.upsert(transaction).await?; subscription.upsert(transaction).await?;
} }
(*price, Some(subscription)) Some(subscription)
} else { } else {
break 'metadata; break 'metadata;
} }
@@ -1598,16 +1633,12 @@ pub async fn stripe_webhook(
id: charge_id, id: charge_id,
user_id, user_id,
price_id, price_id,
amount: amount as i64, amount,
currency_code: price.currency_code.clone(), currency_code: currency,
status: charge_status, status: charge_status,
due: Utc::now(), due: Utc::now(),
last_attempt: Some(Utc::now()), last_attempt: Some(Utc::now()),
type_: if subscription.is_some() { type_: charge_type,
ChargeType::Subscription
} else {
ChargeType::OneTime
},
subscription_id: subscription.as_ref().map(|x| x.id), subscription_id: subscription.as_ref().map(|x| x.id),
subscription_interval: subscription subscription_interval: subscription
.as_ref() .as_ref()
@@ -1634,6 +1665,7 @@ pub async fn stripe_webhook(
charge_item: charge, charge_item: charge,
user_subscription_item: subscription, user_subscription_item: subscription,
payment_metadata, payment_metadata,
charge_type,
}); });
} }
@@ -1651,6 +1683,8 @@ pub async fn stripe_webhook(
let mut metadata = get_payment_intent_metadata( let mut metadata = get_payment_intent_metadata(
payment_intent.id, payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata, payment_intent.metadata,
&pool, &pool,
&redis, &redis,
@@ -1899,6 +1933,8 @@ pub async fn stripe_webhook(
let mut transaction = pool.begin().await?; let mut transaction = pool.begin().await?;
get_payment_intent_metadata( get_payment_intent_metadata(
payment_intent.id, payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata, payment_intent.metadata,
&pool, &pool,
&redis, &redis,
@@ -1917,6 +1953,8 @@ pub async fn stripe_webhook(
let metadata = get_payment_intent_metadata( let metadata = get_payment_intent_metadata(
payment_intent.id, payment_intent.id,
payment_intent.amount,
payment_intent.currency.to_string().to_uppercase(),
payment_intent.metadata, payment_intent.metadata,
&pool, &pool,
&redis, &redis,
@@ -2320,6 +2358,10 @@ pub async fn task(
"modrinth_charge_id".to_string(), "modrinth_charge_id".to_string(),
to_base62(charge.id.0 as u64), 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.metadata = Some(metadata);
intent.customer = Some(customer.id); 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::queue::session::AuthQueue;
use crate::routes::ApiError; use crate::routes::ApiError;
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; 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 hex::ToHex;
use hmac::{Hmac, Mac, NewMac}; use hmac::{Hmac, Mac, NewMac};
use reqwest::Method; use reqwest::Method;
@@ -763,6 +763,7 @@ pub async fn payment_methods(
pub struct UserBalance { pub struct UserBalance {
pub available: Decimal, pub available: Decimal,
pub pending: Decimal, pub pending: Decimal,
pub dates: HashMap<DateTime<Utc>, Decimal>,
} }
#[get("balance")] #[get("balance")]
@@ -791,27 +792,27 @@ async fn get_user_balance(
user_id: crate::database::models::ids::UserId, user_id: crate::database::models::ids::UserId,
pool: &PgPool, pool: &PgPool,
) -> Result<UserBalance, sqlx::Error> { ) -> Result<UserBalance, sqlx::Error> {
let available = sqlx::query!( let payouts = sqlx::query!(
" "
SELECT SUM(amount) SELECT date_available, SUM(amount) sum
FROM payouts_values 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 user_id.0
) )
.fetch_optional(pool) .fetch_all(pool)
.await?; .await?;
let pending = sqlx::query!( let available = payouts
" .iter()
SELECT SUM(amount) .filter(|x| x.date_available <= Utc::now())
FROM payouts_values .fold(Decimal::ZERO, |acc, x| acc + x.sum.unwrap_or(Decimal::ZERO));
WHERE user_id = $1 AND date_available > NOW() let pending = payouts
", .iter()
user_id.0 .filter(|x| x.date_available > Utc::now())
) .fold(Decimal::ZERO, |acc, x| acc + x.sum.unwrap_or(Decimal::ZERO));
.fetch_optional(pool)
.await?;
let withdrawn = sqlx::query!( let withdrawn = sqlx::query!(
" "
@@ -824,12 +825,6 @@ async fn get_user_balance(
.fetch_optional(pool) .fetch_optional(pool)
.await?; .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 let (withdrawn, fees) = withdrawn
.map(|x| { .map(|x| {
( (
@@ -844,6 +839,10 @@ async fn get_user_balance(
- withdrawn.round_dp(16) - withdrawn.round_dp(16)
- fees.round_dp(16), - fees.round_dp(16),
pending, 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) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.route("user", web::get().to(user_auth_get)); cfg.route("user", web::get().to(user_auth_get));
cfg.route("users", web::get().to(users_get)); cfg.route("users", web::get().to(users_get));
cfg.route("user_email", web::get().to(admin_user_email));
cfg.service( cfg.service(
web::scope("user") 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( pub async fn projects_list(
req: HttpRequest, req: HttpRequest,
info: web::Path<(String,)>, info: web::Path<(String,)>,

View File

@@ -214,34 +214,6 @@ impl DirectoryInfo {
} }
} }
fn is_same_disk(
old_dir: &Path,
new_dir: &Path,
) -> crate::Result<bool> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Ok(old_dir.metadata()?.dev() == new_dir.metadata()?.dev())
}
#[cfg(windows)]
{
let old_dir = crate::util::io::canonicalize(old_dir)?;
let new_dir = crate::util::io::canonicalize(new_dir)?;
let old_component = old_dir.components().next();
let new_component = new_dir.components().next();
match (old_component, new_component) {
(
Some(std::path::Component::Prefix(old)),
Some(std::path::Component::Prefix(new)),
) => Ok(old.as_os_str() == new.as_os_str()),
_ => Ok(false),
}
}
}
fn get_disk_usage(path: &Path) -> crate::Result<Option<u64>> { fn get_disk_usage(path: &Path) -> crate::Result<Option<u64>> {
let path = crate::util::io::canonicalize(path)?; let path = crate::util::io::canonicalize(path)?;
@@ -340,7 +312,9 @@ impl DirectoryInfo {
let paths_len = paths.len(); let paths_len = paths.len();
if is_same_disk(&prev_dir, &move_dir).unwrap_or(false) { if crate::util::io::is_same_disk(&prev_dir, &move_dir)
.unwrap_or(false)
{
let success_idxs = Arc::new(DashSet::new()); let success_idxs = Arc::new(DashSet::new());
let loader_bar_id = Arc::new(&loader_bar_id); let loader_bar_id = Arc::new(&loader_bar_id);
@@ -364,7 +338,7 @@ impl DirectoryInfo {
})?; })?;
} }
crate::util::io::rename( crate::util::io::rename_or_move(
&x.old, &x.old,
&x.new, &x.new,
) )

View File

@@ -928,7 +928,8 @@ impl Profile {
format!("{project_path}.disabled") format!("{project_path}.disabled")
}; };
io::rename(&path.join(project_path), &path.join(&new_path)).await?; io::rename_or_move(&path.join(project_path), &path.join(&new_path))
.await?;
Ok(new_path) Ok(new_path)
} }

View File

@@ -59,6 +59,19 @@ pub async fn read_dir(
}) })
} }
// create_dir
pub async fn create_dir(
path: impl AsRef<std::path::Path>,
) -> Result<(), IOError> {
let path = path.as_ref();
tokio::fs::create_dir(path)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: path.to_string_lossy().to_string(),
})
}
// create_dir_all // create_dir_all
pub async fn create_dir_all( pub async fn create_dir_all(
path: impl AsRef<std::path::Path>, path: impl AsRef<std::path::Path>,
@@ -150,19 +163,72 @@ fn sync_write(
tmp_path.persist(path)?; tmp_path.persist(path)?;
std::io::Result::Ok(()) std::io::Result::Ok(())
} }
pub fn is_same_disk(old_dir: &Path, new_dir: &Path) -> Result<bool, IOError> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
Ok(old_dir.metadata()?.dev() == new_dir.metadata()?.dev())
}
#[cfg(windows)]
{
let old_dir = canonicalize(old_dir)?;
let new_dir = canonicalize(new_dir)?;
let old_component = old_dir.components().next();
let new_component = new_dir.components().next();
match (old_component, new_component) {
(
Some(std::path::Component::Prefix(old)),
Some(std::path::Component::Prefix(new)),
) => Ok(old.as_os_str() == new.as_os_str()),
_ => Ok(false),
}
}
}
// rename // rename
pub async fn rename( pub async fn rename_or_move(
from: impl AsRef<std::path::Path>, from: impl AsRef<std::path::Path>,
to: impl AsRef<std::path::Path>, to: impl AsRef<std::path::Path>,
) -> Result<(), IOError> { ) -> Result<(), IOError> {
let from = from.as_ref(); let from = from.as_ref();
let to = to.as_ref(); let to = to.as_ref();
tokio::fs::rename(from, to)
.await if to
.map_err(|e| IOError::IOPathError { .parent()
source: e, .map_or(Ok(false), |to_dir| is_same_disk(from, to_dir))?
path: from.to_string_lossy().to_string(), {
}) tokio::fs::rename(from, to)
.await
.map_err(|e| IOError::IOPathError {
source: e,
path: from.to_string_lossy().to_string(),
})
} else {
move_recursive(from, to).await
}
}
#[async_recursion::async_recursion]
async fn move_recursive(from: &Path, to: &Path) -> Result<(), IOError> {
if from.is_file() {
copy(from, to).await?;
remove_file(from).await?;
return Ok(());
}
create_dir(to).await?;
let mut dir = read_dir(from).await?;
while let Some(entry) = dir.next_entry().await? {
let new_path = to.join(entry.file_name());
move_recursive(&entry.path(), &new_path).await?;
}
Ok(())
} }
// copy // copy

3
packages/assets/external/bluesky.svg vendored Normal file
View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.20232 2.85649C7.95386 4.92218 10.9135 9.11052 12.0001 11.3582C13.0868 9.11069 16.0462 4.92213 18.7978 2.85649C20.7832 1.36598 24 0.2127 24 3.88249C24 4.61539 23.5798 10.0393 23.3333 10.9198C22.4767 13.9812 19.355 14.762 16.5782 14.2894C21.432 15.1155 22.6667 17.8519 20.0001 20.5882C14.9357 25.785 12.7211 19.2843 12.1534 17.6186C12.0494 17.3132 12.0007 17.1703 12 17.2918C11.9993 17.1703 11.9506 17.3132 11.8466 17.6186C11.2791 19.2843 9.06454 25.7851 3.99987 20.5882C1.33323 17.8519 2.56794 15.1154 7.42179 14.2894C4.64492 14.762 1.5232 13.9812 0.666658 10.9198C0.420196 10.0392 0 4.61531 0 3.88249C0 0.2127 3.21689 1.36598 5.20218 2.85649H5.20232Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 789 B

15
packages/assets/external/github.svg vendored Normal file
View File

@@ -0,0 +1,15 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3972_7229)">
<g clip-path="url(#clip1_3972_7229)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0358 0C18.6517 0 24 5.5 24 12.3042C24 17.7432 20.5731 22.3472 15.8192 23.9767C15.2248 24.0992 15.0071 23.712 15.0071 23.3862C15.0071 23.101 15.0267 22.1232 15.0267 21.1045C18.3549 21.838 19.0479 19.6378 19.0479 19.6378C19.5828 18.2118 20.3753 17.8452 20.3753 17.8452C21.4646 17.0915 20.2959 17.0915 20.2959 17.0915C19.0876 17.173 18.4536 18.3545 18.4536 18.3545C17.3841 20.2285 15.6607 19.699 14.9674 19.373C14.8685 18.5785 14.5513 18.0285 14.2146 17.723C16.8691 17.4377 19.6619 16.3785 19.6619 11.6523C19.6619 10.3078 19.1868 9.20775 18.434 8.35225C18.5527 8.04675 18.9688 6.7835 18.3149 5.09275C18.3149 5.09275 17.3047 4.76675 15.0269 6.35575C14.0517 6.08642 13.046 5.9494 12.0358 5.94825C11.0256 5.94825 9.99575 6.091 9.04482 6.35575C6.76677 4.76675 5.75657 5.09275 5.75657 5.09275C5.10269 6.7835 5.51902 8.04675 5.6378 8.35225C4.86514 9.20775 4.40963 10.3078 4.40963 11.6523C4.40963 16.3785 7.20245 17.4172 9.87674 17.723C9.44082 18.11 9.06465 18.8432 9.06465 20.0045C9.06465 21.6545 9.08425 22.9787 9.08425 23.386C9.08425 23.712 8.86629 24.0992 8.27216 23.977C3.5182 22.347 0.091347 17.7432 0.091347 12.3042C0.0717551 5.5 5.43967 0 12.0358 0Z" fill="currentColor"/>
</g>
</g>
<defs>
<clipPath id="clip0_3972_7229">
<rect width="24" height="24" fill="white"/>
</clipPath>
<clipPath id="clip1_3972_7229">
<rect width="24" height="24" fill="white" transform="matrix(-1 0 0 1 24 0)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M480,173.59c0-104.13-68.26-134.65-68.26-134.65C377.3,23.15,318.2,16.5,256.8,16h-1.51c-61.4.5-120.46,7.15-154.88,22.94,0,0-68.27,30.52-68.27,134.65,0,23.85-.46,52.35.29,82.59C34.91,358,51.11,458.37,145.32,483.29c43.43,11.49,80.73,13.89,110.76,12.24,54.47-3,85-19.42,85-19.42l-1.79-39.5s-38.93,12.27-82.64,10.77c-43.31-1.48-89-4.67-96-57.81a108.44,108.44,0,0,1-1-14.9,558.91,558.91,0,0,0,96.39,12.85c32.95,1.51,63.84-1.93,95.22-5.67,60.18-7.18,112.58-44.24,119.16-78.09C480.84,250.42,480,173.59,480,173.59ZM399.46,307.75h-50V185.38c0-25.8-10.86-38.89-32.58-38.89-24,0-36.06,15.53-36.06,46.24v67H231.16v-67c0-30.71-12-46.24-36.06-46.24-21.72,0-32.58,13.09-32.58,38.89V307.75h-50V181.67q0-38.65,19.75-61.39c13.6-15.15,31.4-22.92,53.51-22.92,25.58,0,44.95,9.82,57.75,29.48L256,147.69l12.45-20.85c12.81-19.66,32.17-29.48,57.75-29.48,22.11,0,39.91,7.77,53.51,22.92Q399.5,143,399.46,181.67Z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M480,173.59c0-104.13-68.26-134.65-68.26-134.65C377.3,23.15,318.2,16.5,256.8,16h-1.51c-61.4.5-120.46,7.15-154.88,22.94,0,0-68.27,30.52-68.27,134.65,0,23.85-.46,52.35.29,82.59C34.91,358,51.11,458.37,145.32,483.29c43.43,11.49,80.73,13.89,110.76,12.24,54.47-3,85-19.42,85-19.42l-1.79-39.5s-38.93,12.27-82.64,10.77c-43.31-1.48-89-4.67-96-57.81a108.44,108.44,0,0,1-1-14.9,558.91,558.91,0,0,0,96.39,12.85c32.95,1.51,63.84-1.93,95.22-5.67,60.18-7.18,112.58-44.24,119.16-78.09C480.84,250.42,480,173.59,480,173.59ZM399.46,307.75h-50V185.38c0-25.8-10.86-38.89-32.58-38.89-24,0-36.06,15.53-36.06,46.24v67H231.16v-67c0-30.71-12-46.24-36.06-46.24-21.72,0-32.58,13.09-32.58,38.89V307.75h-50V181.67q0-38.65,19.75-61.39c13.6-15.15,31.4-22.92,53.51-22.92,25.58,0,44.95,9.82,57.75,29.48L256,147.69l12.45-20.85c12.81-19.66,32.17-29.48,57.75-29.48,22.11,0,39.91,7.77,53.51,22.92Q399.5,143,399.46,181.67Z" fill="currentColor"/></svg>

Before

Width:  |  Height:  |  Size: 962 B

After

Width:  |  Height:  |  Size: 983 B

10
packages/assets/external/tumblr.svg vendored Normal file
View File

@@ -0,0 +1,10 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3466_5793)">
<path d="M18.7939 24H14.7856C11.1765 24 8.48678 22.1429 8.48678 17.7012V10.5855H5.20605V6.73219C8.81512 5.79709 10.3255 2.68972 10.4988 0H14.2471V6.10966H18.6205V10.5882H14.2471V16.7845C14.2471 18.6416 15.1848 19.2825 16.6768 19.2825H18.7939V24.0026V24Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_3466_5793">
<rect width="13.5878" height="24" fill="white" transform="translate(5.20605)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 562 B

View File

@@ -1 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.4.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2023 Fonticons, Inc. --><path d="M459.37 151.716c.325 4.548.325 9.097.325 13.645 0 138.72-105.583 298.558-298.558 298.558-59.452 0-114.68-17.219-161.137-47.106 8.447.974 16.568 1.299 25.34 1.299 49.055 0 94.213-16.568 130.274-44.832-46.132-.975-84.792-31.188-98.112-72.772 6.498.974 12.995 1.624 19.818 1.624 9.421 0 18.843-1.3 27.614-3.573-48.081-9.747-84.143-51.98-84.143-102.985v-1.299c13.969 7.797 30.214 12.67 47.431 13.319-28.264-18.843-46.781-51.005-46.781-87.391 0-19.492 5.197-37.36 14.294-52.954 51.655 63.675 129.3 105.258 216.365 109.807-1.624-7.797-2.599-15.918-2.599-24.04 0-57.828 46.782-104.934 104.934-104.934 30.213 0 57.502 12.67 76.67 33.137 23.715-4.548 46.456-13.32 66.599-25.34-7.798 24.366-24.366 44.833-46.132 57.827 21.117-2.273 41.584-8.122 60.426-16.243-14.292 20.791-32.161 39.308-52.628 54.253z"/></svg> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_3564_5601)">
<path d="M18.9641 1.3335H22.6441L14.5641 10.3864L24.0041 22.6668H16.5961L10.7961 15.2041L4.15609 22.6668H0.476094L9.03609 12.9842L-0.00390625 1.3335H7.58809L12.8281 8.15072L18.9641 1.3335ZM17.6761 20.5414H19.7161L6.51609 3.38024H4.32409L17.6761 20.5414Z" fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_3564_5601">
<rect width="24" height="24" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2">
<path d="M10 21.8c-1.3-.3-2.4-.7-3.5-1.5M17.6 3.7c1.1.7 2 1.6 2.7 2.7M2.2 10c.3-1.3.7-2.4 1.5-3.5M20.3 17.6c-.7 1.1-1.6 2-2.7 2.7M21.8 10.1c.2 1.3.2 2.5 0 3.8M6.5 3.6c1.1-.7 2.3-1.2 3.5-1.5M3.6 17.5c-.7-1.1-1.2-2.3-1.5-3.5"/>
<path d="M13.9 2.2c4.6.9 8.1 5 8.1 9.8s-3.4 8.9-8 9.8"/>
<path d="M12 6v6l4 2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 498 B

View File

@@ -13,14 +13,17 @@ import _SSOGoogleIcon from './external/sso/google.svg?component'
import _SSOMicrosoftIcon from './external/sso/microsoft.svg?component' import _SSOMicrosoftIcon from './external/sso/microsoft.svg?component'
import _SSOSteamIcon from './external/sso/steam.svg?component' import _SSOSteamIcon from './external/sso/steam.svg?component'
import _AppleIcon from './external/apple.svg?component' import _AppleIcon from './external/apple.svg?component'
import _BlueskyIcon from './external/bluesky.svg?component'
import _BuyMeACoffeeIcon from './external/bmac.svg?component' import _BuyMeACoffeeIcon from './external/bmac.svg?component'
import _DiscordIcon from './external/discord.svg?component' import _DiscordIcon from './external/discord.svg?component'
import _GithubIcon from './external/github.svg?component'
import _KoFiIcon from './external/kofi.svg?component' import _KoFiIcon from './external/kofi.svg?component'
import _MastodonIcon from './external/mastodon.svg?component' import _MastodonIcon from './external/mastodon.svg?component'
import _OpenCollectiveIcon from './external/opencollective.svg?component' import _OpenCollectiveIcon from './external/opencollective.svg?component'
import _PatreonIcon from './external/patreon.svg?component' import _PatreonIcon from './external/patreon.svg?component'
import _PayPalIcon from './external/paypal.svg?component' import _PayPalIcon from './external/paypal.svg?component'
import _RedditIcon from './external/reddit.svg?component' import _RedditIcon from './external/reddit.svg?component'
import _TumblrIcon from './external/tumblr.svg?component'
import _TwitterIcon from './external/twitter.svg?component' import _TwitterIcon from './external/twitter.svg?component'
import _WindowsIcon from './external/windows.svg?component' import _WindowsIcon from './external/windows.svg?component'
import _YouTubeIcon from './external/youtube.svg?component' import _YouTubeIcon from './external/youtube.svg?component'
@@ -90,6 +93,7 @@ import _HeartHandshakeIcon from './icons/heart-handshake.svg?component'
import _HistoryIcon from './icons/history.svg?component' import _HistoryIcon from './icons/history.svg?component'
import _HomeIcon from './icons/home.svg?component' import _HomeIcon from './icons/home.svg?component'
import _ImageIcon from './icons/image.svg?component' import _ImageIcon from './icons/image.svg?component'
import _InProgressIcon from './icons/in-progress.svg?component'
import _InfoIcon from './icons/info.svg?component' import _InfoIcon from './icons/info.svg?component'
import _IssuesIcon from './icons/issues.svg?component' import _IssuesIcon from './icons/issues.svg?component'
import _KeyIcon from './icons/key.svg?component' import _KeyIcon from './icons/key.svg?component'
@@ -225,7 +229,9 @@ export const SSOGoogleIcon = _SSOGoogleIcon
export const SSOMicrosoftIcon = _SSOMicrosoftIcon export const SSOMicrosoftIcon = _SSOMicrosoftIcon
export const SSOSteamIcon = _SSOSteamIcon export const SSOSteamIcon = _SSOSteamIcon
export const AppleIcon = _AppleIcon export const AppleIcon = _AppleIcon
export const BlueskyIcon = _BlueskyIcon
export const BuyMeACoffeeIcon = _BuyMeACoffeeIcon export const BuyMeACoffeeIcon = _BuyMeACoffeeIcon
export const GithubIcon = _GithubIcon
export const DiscordIcon = _DiscordIcon export const DiscordIcon = _DiscordIcon
export const KoFiIcon = _KoFiIcon export const KoFiIcon = _KoFiIcon
export const MastodonIcon = _MastodonIcon export const MastodonIcon = _MastodonIcon
@@ -234,6 +240,7 @@ export const PatreonIcon = _PatreonIcon
export const PayPalIcon = _PayPalIcon export const PayPalIcon = _PayPalIcon
export const PyroIcon = _PyroIcon export const PyroIcon = _PyroIcon
export const RedditIcon = _RedditIcon export const RedditIcon = _RedditIcon
export const TumblrIcon = _TumblrIcon
export const TwitterIcon = _TwitterIcon export const TwitterIcon = _TwitterIcon
export const WindowsIcon = _WindowsIcon export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon export const YouTubeIcon = _YouTubeIcon
@@ -300,6 +307,7 @@ export const HeartHandshakeIcon = _HeartHandshakeIcon
export const HistoryIcon = _HistoryIcon export const HistoryIcon = _HistoryIcon
export const HomeIcon = _HomeIcon export const HomeIcon = _HomeIcon
export const ImageIcon = _ImageIcon export const ImageIcon = _ImageIcon
export const InProgressIcon = _InProgressIcon
export const InfoIcon = _InfoIcon export const InfoIcon = _InfoIcon
export const IssuesIcon = _IssuesIcon export const IssuesIcon = _IssuesIcon
export const KeyIcon = _KeyIcon export const KeyIcon = _KeyIcon

View File

@@ -56,6 +56,11 @@
rgba(68, 182, 138, 0.175) 0%, rgba(68, 182, 138, 0.175) 0%,
rgba(58, 250, 112, 0.125) 100% rgba(58, 250, 112, 0.125) 100%
); );
--brand-gradient-strong-bg: linear-gradient(
270deg,
rgba(68, 182, 138, 0.175) 0%,
rgba(36, 225, 91, 0.12) 100%
);
--brand-gradient-button: rgba(255, 255, 255, 0.5); --brand-gradient-button: rgba(255, 255, 255, 0.5);
--brand-gradient-border: rgba(32, 64, 32, 0.15); --brand-gradient-border: rgba(32, 64, 32, 0.15);
--brand-gradient-fade-out-color: linear-gradient(to bottom, rgba(213, 235, 224, 0), #d0ece0 70%); --brand-gradient-fade-out-color: linear-gradient(to bottom, rgba(213, 235, 224, 0), #d0ece0 70%);
@@ -167,6 +172,7 @@ html {
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px; --shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
--brand-gradient-bg: linear-gradient(0deg, rgba(14, 35, 19, 0.2) 0%, rgba(55, 137, 73, 0.1) 100%); --brand-gradient-bg: linear-gradient(0deg, rgba(14, 35, 19, 0.2) 0%, rgba(55, 137, 73, 0.1) 100%);
--brand-gradient-strong-bg: linear-gradient(270deg, #09110d 10%, #131f17 100%);
--brand-gradient-button: rgba(255, 255, 255, 0.08); --brand-gradient-button: rgba(255, 255, 255, 0.08);
--brand-gradient-border: rgba(155, 255, 160, 0.08); --brand-gradient-border: rgba(155, 255, 160, 0.08);
--brand-gradient-fade-out-color: linear-gradient(to bottom, rgba(24, 30, 31, 0), #171d1e 80%); --brand-gradient-fade-out-color: linear-gradient(to bottom, rgba(24, 30, 31, 0), #171d1e 80%);
@@ -205,6 +211,15 @@ html {
rgba(22, 66, 51, 0.15) 0%, rgba(22, 66, 51, 0.15) 0%,
rgba(55, 137, 73, 0.1) 100% rgba(55, 137, 73, 0.1) 100%
); );
--brand-gradient-strong-bg: linear-gradient(
270deg,
rgba(9, 18, 14, 0.6) 10%,
rgba(19, 31, 23, 0.5) 100%
);
}
.retro-mode {
--brand-gradient-strong-bg: #3a3b38;
} }
.experimental-styles-within { .experimental-styles-within {

View File

@@ -2,7 +2,7 @@
<div class="chips"> <div class="chips">
<Button <Button
v-for="item in items" v-for="item in items"
:key="item" :key="formatLabel(item)"
class="btn" class="btn"
:class="{ selected: selected === item, capitalize: capitalize }" :class="{ selected: selected === item, capitalize: capitalize }"
@click="toggleItem(item)" @click="toggleItem(item)"
@@ -12,62 +12,39 @@
</Button> </Button>
</div> </div>
</template> </template>
<script setup>
<script setup lang="ts" generic="T">
import { CheckIcon } from '@modrinth/assets' import { CheckIcon } from '@modrinth/assets'
</script>
<script>
import { defineComponent } from 'vue'
import Button from './Button.vue' import Button from './Button.vue'
export default defineComponent({ const props = withDefaults(
props: { defineProps<{
modelValue: { items: T[]
required: true, formatLabel?: (item: T) => string
type: String, neverEmpty?: boolean
}, capitalize?: boolean
items: { }>(),
required: true, {
type: Array, neverEmpty: true,
}, // Intentional any type, as this default should only be used for primitives (string or number)
neverEmpty: { formatLabel: (item) => item.toString(),
default: true, capitalize: true,
type: Boolean,
},
formatLabel: {
default: (x) => x,
type: Function,
},
capitalize: {
type: Boolean,
default: true,
},
}, },
emits: ['update:modelValue'], )
computed: { const selected = defineModel<T | null>()
selected: {
get() { // If one always has to be selected, default to the first one
return this.modelValue if (props.items.length > 0 && props.neverEmpty && !selected.value) {
}, selected.value = props.items[0]
set(value) { }
this.$emit('update:modelValue', value)
}, function toggleItem(item: T) {
}, if (selected.value === item && !props.neverEmpty) {
}, selected.value = null
created() { } else {
if (this.items.length > 0 && this.neverEmpty && !this.modelValue) { selected.value = item
this.selected = this.items[0] }
} }
},
methods: {
toggleItem(item) {
if (this.selected === item && !this.neverEmpty) {
this.selected = null
} else {
this.selected = item
}
},
},
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -0,0 +1,39 @@
<template>
<div class="accordion-content" :class="(baseClass ?? ``) + (collapsed ? `` : ` open`)">
<div v-bind="$attrs" :inert="collapsed">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
baseClass?: string
collapsed: boolean
}>()
defineOptions({
inheritAttrs: false,
})
</script>
<style scoped>
.accordion-content {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.3s ease-in-out;
}
@media (prefers-reduced-motion) {
.accordion-content {
transition: none !important;
}
}
.accordion-content.open {
grid-template-rows: 1fr;
}
.accordion-content > div {
overflow: hidden;
}
</style>

View File

@@ -29,6 +29,7 @@ async function copyText() {
<style lang="scss" scoped> <style lang="scss" scoped>
.code { .code {
color: var(--color-text);
display: inline-flex; display: inline-flex;
grid-gap: 0.5rem; grid-gap: 0.5rem;
font-family: var(--mono-font); font-family: var(--mono-font);

View File

@@ -225,7 +225,7 @@
</template> </template>
</div> </div>
<div class="preview"> <div class="preview">
<Toggle id="preview" v-model="previewMode" :checked="previewMode" /> <Toggle id="preview" v-model="previewMode" />
<label class="label" for="preview"> Preview </label> <label class="label" for="preview"> Preview </label>
</div> </div>
</div> </div>
@@ -263,31 +263,31 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { type Component, computed, ref, onMounted, onBeforeUnmount, toRef, watch } from 'vue' import { type Component, computed, onBeforeUnmount, onMounted, ref, toRef, watch } from 'vue'
import { Compartment, EditorState } from '@codemirror/state' import { Compartment, EditorState } from '@codemirror/state'
import { EditorView, keymap, placeholder as cm_placeholder } from '@codemirror/view' import { EditorView, keymap, placeholder as cm_placeholder } from '@codemirror/view'
import { markdown } from '@codemirror/lang-markdown' import { markdown } from '@codemirror/lang-markdown'
import { indentWithTab, historyKeymap, history } from '@codemirror/commands' import { history, historyKeymap, indentWithTab } from '@codemirror/commands'
import { import {
AlignLeftIcon,
BoldIcon,
CodeIcon,
Heading1Icon, Heading1Icon,
Heading2Icon, Heading2Icon,
Heading3Icon, Heading3Icon,
BoldIcon, ImageIcon,
InfoIcon,
ItalicIcon, ItalicIcon,
ScanEyeIcon, LinkIcon,
StrikethroughIcon,
CodeIcon,
ListBulletedIcon, ListBulletedIcon,
ListOrderedIcon, ListOrderedIcon,
TextQuoteIcon,
LinkIcon,
ImageIcon,
YouTubeIcon,
AlignLeftIcon,
PlusIcon, PlusIcon,
XIcon, ScanEyeIcon,
StrikethroughIcon,
TextQuoteIcon,
UploadIcon, UploadIcon,
InfoIcon, XIcon,
YouTubeIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror' import { markdownCommands, modrinthMarkdownEditorKeymap } from '@modrinth/utils/codemirror'
import { renderHighlightedString } from '@modrinth/utils/highlight' import { renderHighlightedString } from '@modrinth/utils/highlight'

View File

@@ -3,33 +3,17 @@
:id="id" :id="id"
type="checkbox" type="checkbox"
class="switch stylized-toggle" class="switch stylized-toggle"
:disabled="disabled"
:checked="checked" :checked="checked"
@change="toggle" @change="checked = !checked"
/> />
</template> </template>
<script> <script setup lang="ts">
export default { defineProps<{
props: { id?: string
id: { disabled?: boolean
type: String, }>()
required: true,
}, const checked = defineModel<boolean>()
modelValue: {
type: Boolean,
},
checked: {
type: Boolean,
required: true,
},
},
emits: ['update:modelValue'],
methods: {
toggle() {
if (!this.disabled) {
this.$emit('update:modelValue', !this.modelValue)
}
},
},
}
</script> </script>

View File

@@ -2,15 +2,16 @@
<NewModal ref="purchaseModal"> <NewModal ref="purchaseModal">
<template #title> <template #title>
<span class="text-contrast text-xl font-extrabold"> <span class="text-contrast text-xl font-extrabold">
<template v-if="product.metadata.type === 'midas'">Subscribe to Modrinth Plus!</template> <template v-if="productType === 'midas'">Subscribe to Modrinth+!</template>
<template v-else-if="product.metadata.type === 'pyro'" <template v-else-if="productType === 'pyro'">
>Subscribe to Modrinth Servers!</template <template v-if="existingSubscription"> Upgrade server plan </template>
> <template v-else> Subscribe to Modrinth Servers! </template>
</template>
<template v-else>Purchase product</template> <template v-else>Purchase product</template>
</span> </span>
</template> </template>
<div class="flex items-center gap-1 pb-4"> <div class="flex items-center gap-1 pb-4">
<template v-if="product.metadata.type === 'pyro' && !projectId"> <template v-if="productType === 'pyro' && !projectId">
<span <span
:class="{ :class="{
'text-secondary': purchaseModalStep !== 0, 'text-secondary': purchaseModalStep !== 0,
@@ -24,24 +25,20 @@
</template> </template>
<span <span
:class="{ :class="{
'text-secondary': 'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 1 : 0),
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 1 : 0), 'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 1 : 0),
'font-bold':
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 1 : 0),
}" }"
> >
{{ product.metadata.type === 'pyro' ? 'Billing' : 'Plan' }} {{ productType === 'pyro' ? 'Billing' : 'Plan' }}
<span class="hidden sm:inline">{{ <span class="hidden sm:inline">{{
product.metadata.type === 'pyro' ? 'interval' : 'selection' productType === 'pyro' ? 'interval' : 'selection'
}}</span> }}</span>
</span> </span>
<ChevronRightIcon class="h-5 w-5 text-secondary" /> <ChevronRightIcon class="h-5 w-5 text-secondary" />
<span <span
:class="{ :class="{
'text-secondary': 'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 2 : 1),
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 2 : 1), 'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 2 : 1),
'font-bold':
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 2 : 1),
}" }"
> >
Payment Payment
@@ -49,20 +46,18 @@
<ChevronRightIcon class="h-5 w-5 text-secondary" /> <ChevronRightIcon class="h-5 w-5 text-secondary" />
<span <span
:class="{ :class="{
'text-secondary': 'text-secondary': purchaseModalStep !== (productType === 'pyro' && !projectId ? 3 : 2),
purchaseModalStep !== (product.metadata.type === 'pyro' && !projectId ? 3 : 2), 'font-bold': purchaseModalStep === (productType === 'pyro' && !projectId ? 3 : 2),
'font-bold':
purchaseModalStep === (product.metadata.type === 'pyro' && !projectId ? 3 : 2),
}" }"
> >
Review Review
</span> </span>
</div> </div>
<div <div
v-if="product.metadata.type === 'pyro' && !projectId && purchaseModalStep === 0" v-if="productType === 'pyro' && !projectId && purchaseModalStep === 0"
class="md:w-[600px] flex flex-col gap-4" class="md:w-[600px] flex flex-col gap-4"
> >
<div> <div v-if="!existingSubscription">
<p class="my-2 text-lg font-bold">Configure your server</p> <p class="my-2 text-lg font-bold">Configure your server</p>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<input v-model="serverName" placeholder="Server name" class="input" maxlength="48" /> <input v-model="serverName" placeholder="Server name" class="input" maxlength="48" />
@@ -105,13 +100,20 @@
</div> </div>
<div v-if="customServer"> <div v-if="customServer">
<div class="flex gap-2 items-center"> <div class="flex gap-2 items-center">
<p class="my-2 text-lg font-bold">Configure your RAM</p> <p class="my-2 text-lg font-bold">
<template v-if="existingSubscription">Upgrade your RAM</template>
<template v-else>Configure your RAM</template>
</p>
<IssuesIcon <IssuesIcon
v-if="customServerConfig.ramInGb < 4" v-if="customServerConfig.ramInGb < 4"
v-tooltip="'This might not be enough resources for your Minecraft server.'" v-tooltip="'This might not be enough resources for your Minecraft server.'"
class="h-6 w-6 text-orange" class="h-6 w-6 text-orange"
/> />
</div> </div>
<p v-if="existingPlan" class="mt-1 mb-2 text-secondary">
Your current plan has <strong>{{ existingPlan.metadata.ram / 1024 }} GB RAM</strong> and
<strong>{{ existingPlan.metadata.cpu }} vCPUs</strong>.
</p>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div class="flex w-full gap-2 items-center"> <div class="flex w-full gap-2 items-center">
<Slider <Slider
@@ -166,6 +168,16 @@
> >
<div> <div>
<p class="my-2 text-lg font-bold">Choose billing interval</p> <p class="my-2 text-lg font-bold">Choose billing interval</p>
<div v-if="existingPlan" class="flex flex-col gap-3 mb-4 text-secondary">
<p class="m-0">
The prices below reflect the new <strong>renewal cost</strong> of your upgraded
subscription.
</p>
<p class="m-0">
Today, you will be charged a prorated amount for the remainder of your current billing
cycle.
</p>
</div>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div <div
v-for="([interval, rawPrice], index) in Object.entries(price.prices.intervals)" v-for="([interval, rawPrice], index) in Object.entries(price.prices.intervals)"
@@ -228,7 +240,10 @@
v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 3 : 2)" v-if="purchaseModalStep === (mutatedProduct.metadata.type === 'pyro' && !projectId ? 3 : 2)"
class="md:w-[650px]" class="md:w-[650px]"
> >
<div v-if="mutatedProduct.metadata.type === 'pyro'" class="r-4 rounded-xl bg-bg p-4 mb-4"> <div
v-if="mutatedProduct.metadata.type === 'pyro' && !existingSubscription"
class="r-4 rounded-xl bg-bg p-4 mb-4"
>
<p class="my-2 text-lg font-bold text-primary">Server details</p> <p class="my-2 text-lg font-bold text-primary">Server details</p>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<img <img
@@ -248,12 +263,19 @@
<div class="r-4 rounded-xl bg-bg p-4"> <div class="r-4 rounded-xl bg-bg p-4">
<p class="my-2 text-lg font-bold text-primary">Purchase details</p> <p class="my-2 text-lg font-bold text-primary">Purchase details</p>
<div class="mb-2 flex justify-between"> <div class="mb-2 flex justify-between">
<span class="text-secondary" <span class="text-secondary">
>{{ mutatedProduct.metadata.type === 'midas' ? 'Modrinth+' : 'Modrinth Servers' }} {{ mutatedProduct.metadata.type === 'midas' ? 'Modrinth+' : 'Modrinth Servers' }}
{{ selectedPlan }}</span {{
> existingPlan
<span class="text-secondary text-end"> ? `(${dayjs(renewalDate).diff(dayjs(), 'days')} days prorated)`
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }} / : selectedPlan
}}
</span>
<span v-if="existingPlan" class="text-secondary text-end">
{{ formatPrice(locale, total - tax, price.currency_code) }}
</span>
<span v-else class="text-secondary text-end">
{{ formatPrice(locale, total - tax, price.currency_code) }} /
{{ selectedPlan }} {{ selectedPlan }}
</span> </span>
</div> </div>
@@ -266,7 +288,7 @@
<div class="mt-4 flex justify-between border-0 border-t border-solid border-code-bg pt-4"> <div class="mt-4 flex justify-between border-0 border-t border-solid border-code-bg pt-4">
<span class="text-lg font-bold">Today's total</span> <span class="text-lg font-bold">Today's total</span>
<span class="text-lg font-extrabold text-primary text-end"> <span class="text-lg font-extrabold text-primary text-end">
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }} {{ formatPrice(locale, total, price.currency_code) }}
</span> </span>
</div> </div>
</div> </div>
@@ -363,7 +385,8 @@
<br /> <br />
You'll be charged You'll be charged
{{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }} / {{ formatPrice(locale, price.prices.intervals[selectedPlan], price.currency_code) }} /
{{ selectedPlan }} plus applicable taxes starting today, until you cancel. {{ selectedPlan }} plus applicable taxes starting
{{ existingPlan ? dayjs(renewalDate).format('MMMM D, YYYY') : 'today' }}, until you cancel.
<br /> <br />
You can cancel anytime from your settings page. You can cancel anytime from your settings page.
</p> </p>
@@ -389,12 +412,19 @@
:disabled=" :disabled="
paymentLoading || paymentLoading ||
(mutatedProduct.metadata.type === 'pyro' && !projectId && !serverName) || (mutatedProduct.metadata.type === 'pyro' && !projectId && !serverName) ||
customAllowedToContinue customNotAllowedToContinue ||
upgradeNotAllowedToContinue
" "
@click="nextStep" @click="nextStep"
> >
<RightArrowIcon /> <template v-if="customServer && customLoading">
{{ mutatedProduct.metadata.type === 'pyro' && !projectId ? 'Next' : 'Select' }} <SpinnerIcon class="animate-spin" />
Checking availability...
</template>
<template v-else>
<RightArrowIcon />
{{ mutatedProduct.metadata.type === 'pyro' && !projectId ? 'Next' : 'Select' }}
</template>
</button> </button>
</template> </template>
<template <template
@@ -468,6 +498,7 @@
import { ref, computed, nextTick, reactive, watch } from 'vue' import { ref, computed, nextTick, reactive, watch } from 'vue'
import NewModal from '../modal/NewModal.vue' import NewModal from '../modal/NewModal.vue'
import { import {
SpinnerIcon,
CardIcon, CardIcon,
CheckCircleIcon, CheckCircleIcon,
ChevronRightIcon, ChevronRightIcon,
@@ -487,6 +518,7 @@ import { useVIntl, defineMessages } from '@vintl/vintl'
import { Multiselect } from 'vue-multiselect' import { Multiselect } from 'vue-multiselect'
import Checkbox from '../base/Checkbox.vue' import Checkbox from '../base/Checkbox.vue'
import Slider from '../base/Slider.vue' import Slider from '../base/Slider.vue'
import dayjs from 'dayjs'
import Admonition from '../base/Admonition.vue' import Admonition from '../base/Admonition.vue'
const { locale, formatMessage } = useVIntl() const { locale, formatMessage } = useVIntl()
@@ -562,8 +594,25 @@ const props = defineProps({
required: false, required: false,
default: '', default: '',
}, },
existingSubscription: {
type: Object,
required: false,
default: null,
},
existingPlan: {
type: Object,
required: false,
default: null,
},
renewalDate: {
type: String,
required: false,
default: null,
},
}) })
const productType = computed(() => (props.customServer ? 'pyro' : props.product.metadata.type))
const messages = defineMessages({ const messages = defineMessages({
paymentMethodCardDisplay: { paymentMethodCardDisplay: {
id: 'omorphia.component.purchase_modal.payment_method_card_display', id: 'omorphia.component.purchase_modal.payment_method_card_display',
@@ -645,7 +694,7 @@ const total = ref()
const serverName = ref(props.serverName || '') const serverName = ref(props.serverName || '')
const serverLoader = ref('Vanilla') const serverLoader = ref('Vanilla')
const eulaAccepted = ref(false) const eulaAccepted = ref(!!props.existingSubscription)
const mutatedProduct = ref({ ...props.product }) const mutatedProduct = ref({ ...props.product })
const customMinRam = ref(0) const customMinRam = ref(0)
@@ -653,11 +702,15 @@ const customMaxRam = ref(0)
const customMatchingProduct = ref() const customMatchingProduct = ref()
const customOutOfStock = ref(false) const customOutOfStock = ref(false)
const customLoading = ref(true) const customLoading = ref(true)
const customAllowedToContinue = computed( const customNotAllowedToContinue = computed(
() => () =>
props.customServer && props.customServer &&
!props.existingSubscription &&
(!customMatchingProduct.value || customLoading.value || customOutOfStock.value), (!customMatchingProduct.value || customLoading.value || customOutOfStock.value),
) )
const upgradeNotAllowedToContinue = computed(
() => props.existingSubscription && (customOutOfStock.value || customLoading.value),
)
const customServerConfig = reactive({ const customServerConfig = reactive({
ramInGb: 4, ramInGb: 4,
@@ -670,7 +723,9 @@ const updateCustomServerProduct = () => {
(product) => product.metadata.ram === customServerConfig.ram, (product) => product.metadata.ram === customServerConfig.ram,
) )
if (customMatchingProduct.value) mutatedProduct.value = { ...customMatchingProduct.value } if (customMatchingProduct.value) {
mutatedProduct.value = { ...customMatchingProduct.value }
}
} }
let updateCustomServerStockTimeout = null let updateCustomServerStockTimeout = null
@@ -682,25 +737,38 @@ const updateCustomServerStock = async () => {
updateCustomServerStockTimeout = setTimeout(async () => { updateCustomServerStockTimeout = setTimeout(async () => {
if (props.fetchCapacityStatuses) { if (props.fetchCapacityStatuses) {
const capacityStatus = await props.fetchCapacityStatuses(mutatedProduct.value) if (props.existingSubscription) {
if (capacityStatus.custom?.available === 0) { if (mutatedProduct.value) {
customOutOfStock.value = true const capacityStatus = await props.fetchCapacityStatuses(
props.existingSubscription.metadata.id,
mutatedProduct.value,
)
customOutOfStock.value = capacityStatus.custom?.available === 0
console.log(capacityStatus)
}
} else { } else {
customOutOfStock.value = false const capacityStatus = await props.fetchCapacityStatuses(mutatedProduct.value)
customOutOfStock.value = capacityStatus.custom?.available === 0
} }
customLoading.value = false
} else { } else {
console.error('No fetchCapacityStatuses function provided.') console.error('No fetchCapacityStatuses function provided.')
customOutOfStock.value = true customOutOfStock.value = true
} }
customLoading.value = false
}, 300) }, 300)
} }
if (props.customServer) { function updateRamValues() {
const ramValues = props.product.map((product) => product.metadata.ram / 1024) const ramValues = props.product.map((product) => product.metadata.ram / 1024)
customMinRam.value = Math.min(...ramValues) customMinRam.value = Math.min(...ramValues)
customMaxRam.value = Math.max(...ramValues) customMaxRam.value = Math.max(...ramValues)
customServerConfig.ramInGb = customMinRam.value
}
if (props.customServer) {
updateRamValues()
const updateProductAndStock = () => { const updateProductAndStock = () => {
updateCustomServerProduct() updateCustomServerProduct()
updateCustomServerStock() updateCustomServerStock()
@@ -880,16 +948,25 @@ async function refreshPayment(confirmationId, paymentMethodId) {
id: paymentMethodId, id: paymentMethodId,
} }
const result = await props.sendBillingRequest({ const result = await props.sendBillingRequest(
charge: { props.existingSubscription
type: 'new', ? {
product_id: mutatedProduct.value.id, interval: selectedPlan.value,
interval: selectedPlan.value, cancelled: false,
}, product: mutatedProduct.value.id,
existing_payment_intent: paymentIntentId.value, payment_method: paymentMethodId,
metadata: metadata.value, }
...base, : {
}) charge: {
type: 'new',
product_id: mutatedProduct.value.id,
interval: selectedPlan.value,
},
existing_payment_intent: paymentIntentId.value,
metadata: metadata.value,
...base,
},
)
if (!paymentIntentId.value) { if (!paymentIntentId.value) {
paymentIntentId.value = result.payment_intent_id paymentIntentId.value = result.payment_intent_id
@@ -903,10 +980,14 @@ async function refreshPayment(confirmationId, paymentMethodId) {
if (confirmationId) { if (confirmationId) {
confirmationToken.value = confirmationId confirmationToken.value = confirmationId
inputtedPaymentMethod.value = result.payment_method if (result.payment_method) {
inputtedPaymentMethod.value = result.payment_method
}
} }
selectedPaymentMethod.value = result.payment_method if (result.payment_method) {
selectedPaymentMethod.value = result.payment_method
}
} catch (err) { } catch (err) {
props.onError(err) props.onError(err)
} }
@@ -930,9 +1011,13 @@ async function submitPayment() {
defineExpose({ defineExpose({
show: () => { show: () => {
if (props.customServer) {
updateRamValues()
}
stripe = Stripe(props.publishableKey) stripe = Stripe(props.publishableKey)
selectedPlan.value = 'yearly' selectedPlan.value = props.existingSubscription ? props.existingSubscription.interval : 'yearly'
serverName.value = props.serverName || '' serverName.value = props.serverName || ''
serverLoader.value = 'Vanilla' serverLoader.value = 'Vanilla'

View File

@@ -28,7 +28,7 @@
class="hidden sm:flex" class="hidden sm:flex"
:class="{ 'cursor-help': dateTooltip }" :class="{ 'cursor-help': dateTooltip }"
> >
{{ relativeDate }} {{ future ? formatMessage(messages.justNow) : relativeDate }}
</div> </div>
<div v-else-if="entry.version" :class="{ 'cursor-help': dateTooltip }"> <div v-else-if="entry.version" :class="{ 'cursor-help': dateTooltip }">
{{ longDate }} {{ longDate }}
@@ -66,11 +66,8 @@ const props = withDefaults(
) )
const currentDate = ref(dayjs()) const currentDate = ref(dayjs())
const recent = computed( const recent = computed(() => props.entry.date.isAfter(currentDate.value.subtract(1, 'week')))
() => const future = computed(() => props.entry.date.isAfter(currentDate.value))
props.entry.date.isAfter(currentDate.value.subtract(1, 'week')) &&
props.entry.date.isBefore(currentDate.value),
)
const dateTooltip = computed(() => props.entry.date.format('MMMM D, YYYY [at] h:mm A')) const dateTooltip = computed(() => props.entry.date.format('MMMM D, YYYY [at] h:mm A'))
const relativeDate = computed(() => props.entry.date.fromNow()) const relativeDate = computed(() => props.entry.date.fromNow())
@@ -94,6 +91,10 @@ const messages = defineMessages({
id: 'changelog.product.api', id: 'changelog.product.api',
defaultMessage: 'API', defaultMessage: 'API',
}, },
justNow: {
id: 'changelog.justNow',
defaultMessage: 'Just now',
},
}) })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@@ -9,6 +9,7 @@ export { default as ButtonStyled } from './base/ButtonStyled.vue'
export { default as Card } from './base/Card.vue' export { default as Card } from './base/Card.vue'
export { default as Checkbox } from './base/Checkbox.vue' export { default as Checkbox } from './base/Checkbox.vue'
export { default as Chips } from './base/Chips.vue' export { default as Chips } from './base/Chips.vue'
export { default as Collapsible } from './base/Collapsible.vue'
export { default as ContentPageHeader } from './base/ContentPageHeader.vue' export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
export { default as CopyCode } from './base/CopyCode.vue' export { default as CopyCode } from './base/CopyCode.vue'
export { default as DoubleIcon } from './base/DoubleIcon.vue' export { default as DoubleIcon } from './base/DoubleIcon.vue'

View File

@@ -10,7 +10,7 @@
<label v-if="hasToType" for="confirmation" class="confirmation-label"> <label v-if="hasToType" for="confirmation" class="confirmation-label">
<span> <span>
<strong>To verify, type</strong> <strong>To verify, type</strong>
<em class="confirmation-text">{{ confirmationText }}</em> <em class="confirmation-text"> {{ confirmationText }} </em>
<strong>below:</strong> <strong>below:</strong>
</span> </span>
</label> </label>

View File

@@ -32,6 +32,9 @@
"button.upload-image": { "button.upload-image": {
"defaultMessage": "Upload image" "defaultMessage": "Upload image"
}, },
"changelog.justNow": {
"defaultMessage": "Just now"
},
"changelog.product.api": { "changelog.product.api": {
"defaultMessage": "API" "defaultMessage": "API"
}, },

View File

@@ -10,6 +10,85 @@ export type VersionEntry = {
} }
const VERSIONS: VersionEntry[] = [ const VERSIONS: VersionEntry[] = [
{
date: `2025-02-25T10:20:00-08:00`,
product: 'servers',
body: `### Improvements
- Fixed server upgrades being allowed when out of stock, despite warning.`,
},
{
date: `2025-02-25T10:20:00-08:00`,
product: 'web',
body: `### Improvements
- Moved Minecraft brand disclaimer to bottom of footer.
- Improved clarity of the ongoing revenue period footnote on the Revenue page.
- Fixed collections without a summary being unable to be edited.`,
},
{
date: `2025-02-21T13:30:00-08:00`,
product: 'web',
body: `### Improvements
- Collections are now sorted by creation date. (Contributed by [worldwidepixel](https://github.com/modrinth/code/pull/3286))
- Collections are no longer required to have summaries. (Contributed by [Erb3](https://github.com/modrinth/code/pull/3281))
- Fixed padding issue on revenue page.
- Fixed last modified date on Rewards Program Info page. (Contributed by [IMB11](https://github.com/modrinth/code/pull/3287))`,
},
{
date: `2025-02-20T18:15:00-08:00`,
product: 'web',
body: `### Improvements
- Revenue page has been updated to more clearly display pending revenue and when it will be available to withdraw. (Contributed by [IMB11](https://github.com/modrinth/code/pull/3250))
- Footer will now be forced to the bottom of the page on short pages.
- Styling fixes to moderation checklist proof form.`,
},
{
date: `2025-02-19T22:20:00-08:00`,
product: 'web',
body: `### Added
- All-new site footer with more links, better organization, and a new aesthetic.
### Improvements
- Added Dallas location to Modrinth Servers landing page.
- Updated staff moderation checklist to be more visually consistent and more dynamic.`,
},
{
date: `2025-02-18T14:30:00-08:00`,
product: 'servers',
body: `### Added
- Links will now be detected in console line viewer modal.
### Improvements
- Initial loading of pages in the server panel are now up to 400% faster.
- Syncing and uploading new server icons no longer requires a full page refresh.
- Fix a case where opening the platform modal, closing it, and reopening it would cause the loader version to be unselected.
- Prevents an issue where, if crash log analysis fails, the Overview page would unrender.
- Suspended server listings now have a copy ID button.
- Fixed bugs from Modrinth Servers February Release.`,
},
{
date: `2025-02-16T19:10:00-08:00`,
product: 'web',
body: `### Improvements
- Fixed spacing issue on confirmation modals.`,
},
{
date: `2025-02-16T19:10:00-08:00`,
product: 'servers',
body: `### Improvements
- Check for availability before allowing a server upgrade.`,
},
{
date: `2025-02-12T19:10:00-08:00`,
product: 'web',
body: `### Improvements
- Servers out of stock link now links to Modrinth Discord instead of support page.`,
},
{
date: `2025-02-12T19:10:00-08:00`,
product: 'servers',
body: `### Added
- Added server upgrades to switch to a larger plan as an option in billing settings.`,
},
{ {
date: `2025-02-12T12:10:00-08:00`, date: `2025-02-12T12:10:00-08:00`,
product: 'web', product: 'web',
@@ -44,6 +123,7 @@ const VERSIONS: VersionEntry[] = [
{ {
date: `2025-02-10T08:00:00-08:00`, date: `2025-02-10T08:00:00-08:00`,
product: 'servers', product: 'servers',
version: `February Release`,
body: `### Added body: `### Added
- You can now search and filter through your server's console in the Overview tab, jump to specific results to see the log in context, select them, and copy them. - You can now search and filter through your server's console in the Overview tab, jump to specific results to see the log in context, select them, and copy them.
- You can now drag and select any number of lines in the console, copy them. and view them formatted. - You can now drag and select any number of lines in the console, copy them. and view them formatted.
@@ -94,9 +174,25 @@ Contributed by [IMB11](https://github.com/modrinth/code/pull/1301).`,
{ {
date: `2025-01-10T09:00:00-08:00`, date: `2025-01-10T09:00:00-08:00`,
product: 'servers', product: 'servers',
body: `### Improvements version: 'January Release',
body: `### Added
- Added drag & drop upload support for mod and plugin files on the content page.
- Added a button to upload files to the content page.
- Added extra info (file name, author) to each mod on the content page.
- Show number of mods in search box.
- Adds a "No mods/plugins found for your query!" message if nothing is found, with a button to show everything again.
### Improvements
- The content page layout has been enhanced, now showing the file name and author of each installed item. - The content page layout has been enhanced, now showing the file name and author of each installed item.
- You can now upload directly from the content page, instead of having to go to the Files page.`, - You can now upload directly from the content page, instead of having to go to the Files page.
- Auto-backup now lists options in a dropdown instead of number input.
- Auto-backup 'Save changes' button now disables when no changes are made and backups are off.
- Servers dropdowns now have rounded corners on the last elements for consistency.
- Added support for more suspension reasons.
- Will now show resubscribe button on servers when payment status is "failed" instead of just "cancelled".
- Tweak button styles for consistency.
- Only scroll to the top of the mod/plugin list when searching if already scrolled down.
- Tweak content page mobile UI.`,
}, },
{ {
date: `2025-01-10T09:00:00-08:00`, date: `2025-01-10T09:00:00-08:00`,
@@ -104,6 +200,16 @@ Contributed by [IMB11](https://github.com/modrinth/code/pull/1301).`,
body: `### Improvements body: `### Improvements
- Tags on project pages are now clickable to view other projects with that tag (Contributed by [Neddo](https://github.com/modrinth/code/pull/3126)) - Tags on project pages are now clickable to view other projects with that tag (Contributed by [Neddo](https://github.com/modrinth/code/pull/3126))
- You can now send someone a link to the download interface with a specific version and loader selected, like so: https://modrinth.com/mod/sodium?version=1.21.2&loader=quilt#download (Contributed by [AwakenedRedstone](https://github.com/modrinth/code/pull/3138))`, - You can now send someone a link to the download interface with a specific version and loader selected, like so: https://modrinth.com/mod/sodium?version=1.21.2&loader=quilt#download (Contributed by [AwakenedRedstone](https://github.com/modrinth/code/pull/3138))`,
},
{
date: `2024-12-26T22:05:00-08:00`,
product: 'servers',
body: `### Added
- Added ability for users to clean install modpacks when switching versions.
### Improvements
- New status bar in ServerListing that shows suspension reasons/upgrade status.
- Displays a new screen for servers that are being upgraded.`,
}, },
{ {
date: `2024-12-25T14:00:00-08:00`, date: `2024-12-25T14:00:00-08:00`,
@@ -144,6 +250,52 @@ Contributed by [IMB11](https://github.com/modrinth/code/pull/1301).`,
- Fixed “Database is locked” errors on devices with slow disks. - Fixed “Database is locked” errors on devices with slow disks.
- Fixed a few edge cases where API downtime could lead to an invalid state.`, - Fixed a few edge cases where API downtime could lead to an invalid state.`,
}, },
{
date: `2024-12-21T16:00:00-08:00`,
product: 'servers',
body: `### Added
- Drag and drop anything in the file manager.
- Added file upload queue status bar.
- Added support for parallel file uploads to upload multiple files faster.
- Added ability to cancel in-progress file uploads.
- Creation dates are now displayed for files.
- Can now sort by most recently created files
- YAML and TOML files now support syntax highlighting
- Find and replace support in files editor
### Improvements
- Files list renders up to 200% faster.
- Image viewer performance improvements, improved UI, and better handling of large-to-display images.
- UI inconsistency fixes.
- When reinstalling the loader, the current Minecraft version is automatically selected.
- Allow user to clean install modpacks on the modpack search page.
- Fixed 'Change platform' button leading to the wrong page on a vanilla server.`,
},
{
date: `2024-12-11T22:18:45-08:00`,
product: 'servers',
version: `December Release`,
body: `### Added
- Expanded loader support to include **Paper** and **Purpur** servers, offering fully native plugin compatibility.
- A live chat button has been added to the bottom right of all server pages, making it easier for customers to contact our support team.
- Automatic backups are now *rolling*. This means older backups will be deleted to make space for new backups when a new one is being created. You can also now **lock** specific backups so that they don't get deleted by the automatic backup process.
- Users can now easily create backups before reinstalling a server with a different loader.
### Improvements
- The Platform options page has been completely redesigned to streamline user interactions and improve overall clarity.
- Suspended servers now display a clear "Suspended" status instead of a confusing "Connection lost" message, allowing users to easily check their billing information.
- The console has been internally reworked to improve responsiveness and prevent freezing during high-volume spam.
- Resolved CPU usage readings that previously exceeded 100% during high-load scenarios. CPU usage is now accurately normalized to a 0100% range across all cores.
- Corrected CPU limit settings for some servers, potentially improving performance by up to half a core.
- Fixed an issue preventing server reinstallation when at the maximum backup limit.
- Resolved installation and runtime problems with older Minecraft versions.
- Added missing dynamic system libraries to our images, ensuring compatibility with the vast majority of mods.
- Implemented several additional bug fixes and performance optimizations.
- Removed Herobrine.
### Known Issues
- Backups may occasionally take longer than expected or become stuck. If a backup is unresponsive, please submit a support inquiry, and we'll investigate further.`,
},
].map((x) => ({ ...x, date: dayjs(x.date) }) as VersionEntry) ].map((x) => ({ ...x, date: dayjs(x.date) }) as VersionEntry)
export function getChangelog() { export function getChangelog() {

View File

@@ -87,6 +87,17 @@ export const formatNumber = (number, abbreviate = true) => {
return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
} }
export function formatDate(
date: dayjs.Dayjs,
options: Intl.DateTimeFormatOptions = {
month: 'long',
day: 'numeric',
year: 'numeric',
},
): string {
return date.toDate().toLocaleDateString(undefined, options)
}
export function formatMoney(number, abbreviate = false) { export function formatMoney(number, abbreviate = false) {
const x = Number(number) const x = Number(number)
if (x >= 1000000 && abbreviate) { if (x >= 1000000 && abbreviate) {