You've already forked AstralRinth
forked from didirus/AstralRinth
Merge commit '90def724c28a2eacf173f4987e195dc14908f25d' into feature-clean
This commit is contained in:
@@ -1,7 +1,16 @@
|
||||
<script setup>
|
||||
import { XIcon, HammerIcon, LogInIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import {
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
XIcon,
|
||||
HammerIcon,
|
||||
LogInIcon,
|
||||
UpdatedIcon,
|
||||
CopyIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import { ref } from 'vue'
|
||||
import { ButtonStyled, Collapsible } from '@modrinth/ui'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
@@ -13,6 +22,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
const closable = ref(true)
|
||||
const errorCollapsed = ref(false)
|
||||
|
||||
const title = ref('An error occurred')
|
||||
const errorType = ref('unknown')
|
||||
@@ -118,6 +128,26 @@ async function repairInstance() {
|
||||
}
|
||||
loadingRepair.value = false
|
||||
}
|
||||
|
||||
const hasDebugInfo = computed(
|
||||
() =>
|
||||
errorType.value === 'directory_move' ||
|
||||
errorType.value === 'minecraft_auth' ||
|
||||
errorType.value === 'state_init' ||
|
||||
errorType.value === 'no_loader_version',
|
||||
)
|
||||
|
||||
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -244,16 +274,9 @@ async function repairInstance() {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ error.message ?? error }}
|
||||
{{ debugInfo }}
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
errorType === 'directory_move' ||
|
||||
errorType === 'minecraft_auth' ||
|
||||
errorType === 'state_init' ||
|
||||
errorType === 'no_loader_version'
|
||||
"
|
||||
>
|
||||
<template v-if="hasDebugInfo">
|
||||
<hr />
|
||||
<p>
|
||||
If nothing is working and you need help, visit
|
||||
@@ -261,16 +284,39 @@ async function repairInstance() {
|
||||
and start a chat using the widget in the bottom right and we will be more than happy to
|
||||
assist! Make sure to provide the following debug information to the agent:
|
||||
</p>
|
||||
<details>
|
||||
<summary>Debug information</summary>
|
||||
{{ error.message ?? error }}
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
|
||||
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="closable">
|
||||
<button @click="errorModal.hide()"><XIcon /> Close</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="hasDebugInfo">
|
||||
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
|
||||
<template v-else> <CopyIcon /> Copy debug info </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
|
||||
<button
|
||||
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
|
||||
@click="errorCollapsed = !errorCollapsed"
|
||||
>
|
||||
<span class="text-contrast font-extrabold m-0">Debug information:</span>
|
||||
<DropdownIcon
|
||||
class="h-5 w-5 text-secondary transition-transform"
|
||||
:class="{ 'rotate-180': !errorCollapsed }"
|
||||
/>
|
||||
</button>
|
||||
<Collapsible :collapsed="errorCollapsed">
|
||||
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { edit } from '@/helpers/profile'
|
||||
import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types'
|
||||
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -114,7 +114,6 @@ const messages = defineMessages({
|
||||
<Toggle
|
||||
id="fullscreen"
|
||||
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
|
||||
:checked="fullscreenSetting"
|
||||
:disabled="!overrideWindowSettings"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Toggle, ThemeSelector, TeleportDropdownMenu } from '@modrinth/ui'
|
||||
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { watch, ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { getOS } from '@/helpers/utils'
|
||||
|
||||
const themeStore = useTheming()
|
||||
@@ -46,7 +46,6 @@ watch(
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="themeStore.advancedRendering"
|
||||
:checked="themeStore.advancedRendering"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
themeStore.advancedRendering = e
|
||||
@@ -61,16 +60,7 @@ watch(
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
|
||||
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="native-decorations"
|
||||
:model-value="settings.native_decorations"
|
||||
:checked="settings.native_decorations"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.native_decorations = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
@@ -78,16 +68,7 @@ watch(
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
|
||||
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="minimize-launcher"
|
||||
:model-value="settings.hide_on_process_start"
|
||||
:checked="settings.hide_on_process_start"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.hide_on_process_start = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
@@ -111,7 +92,6 @@ watch(
|
||||
<Toggle
|
||||
id="toggle-sidebar"
|
||||
:model-value="settings.toggle_sidebar"
|
||||
:checked="settings.toggle_sidebar"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.toggle_sidebar = e
|
||||
|
||||
@@ -57,16 +57,7 @@ watch(
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
id="fullscreen"
|
||||
:model-value="settings.force_fullscreen"
|
||||
:checked="settings.force_fullscreen"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.force_fullscreen = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
|
||||
@@ -37,7 +37,6 @@ watch(
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="getStoreValue(option)"
|
||||
:checked="getStoreValue(option)"
|
||||
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,17 +30,8 @@ watch(
|
||||
option, you opt out and ads will no longer be shown based on your interests.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="personalized-ads"
|
||||
:model-value="settings.personalized_ads"
|
||||
:checked="settings.personalized_ads"
|
||||
:disabled="!settings.personalized_ads"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.personalized_ads = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- AstralRinth disabled element by default -->
|
||||
<Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-4">
|
||||
@@ -52,17 +43,8 @@ watch(
|
||||
longer be collected.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="opt-out-analytics"
|
||||
:model-value="settings.telemetry"
|
||||
:checked="settings.telemetry"
|
||||
:disabled="!settings.telemetry"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.telemetry = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<!-- AstralRinth disabled element by default -->
|
||||
<Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-4">
|
||||
@@ -77,10 +59,6 @@ watch(
|
||||
as those added by mods. (app restart required to take effect)
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="disable-discord-rpc"
|
||||
v-model="settings.discord_rpc"
|
||||
:checked="settings.discord_rpc"
|
||||
/>
|
||||
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -179,7 +179,6 @@
|
||||
<Toggle
|
||||
class="!mx-2"
|
||||
:model-value="!item.data.disabled"
|
||||
:checked="!item.data.disabled"
|
||||
@update:model-value="toggleDisableMod(item.data)"
|
||||
/>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "AstralRinth App",
|
||||
"version": "0.9.301",
|
||||
"version": "0.9.302",
|
||||
"mainBinaryName": "AstralRinth App",
|
||||
"identifier": "AstralRinthApp",
|
||||
"plugins": {
|
||||
|
||||
@@ -133,6 +133,19 @@
|
||||
"sidebar"
|
||||
/ 100%;
|
||||
|
||||
.normal-page__ultimate-sidebar {
|
||||
grid-area: ultimate-sidebar;
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
z-index: 100;
|
||||
max-width: calc(100% - 2rem);
|
||||
|
||||
> div {
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
&.sidebar {
|
||||
grid-template:
|
||||
@@ -156,6 +169,45 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1400px) {
|
||||
&.ultimate-sidebar {
|
||||
max-width: calc(80rem + 0.75rem + 600px);
|
||||
|
||||
grid-template:
|
||||
"header header ultimate-sidebar" auto
|
||||
"content sidebar ultimate-sidebar" auto
|
||||
"content dummy ultimate-sidebar" 1fr
|
||||
/ 1fr 18.75rem auto;
|
||||
|
||||
.normal-page__header {
|
||||
max-width: 80rem;
|
||||
}
|
||||
|
||||
.normal-page__ultimate-sidebar {
|
||||
position: sticky;
|
||||
top: 4.5rem;
|
||||
bottom: unset;
|
||||
right: unset;
|
||||
z-index: unset;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
height: calc(100vh - 4.5rem * 2);
|
||||
|
||||
> div {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.alt-layout {
|
||||
grid-template:
|
||||
"ultimate-sidebar header header" auto
|
||||
"ultimate-sidebar sidebar content" auto
|
||||
"ultimate-sidebar dummy content" 1fr
|
||||
/ auto 18.75rem 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.normal-page__sidebar {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="additional-information" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Summary
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span class="text-lg font-semibold text-contrast"> Summary </span>
|
||||
<span>A sentence or two that describes your collection.</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
@@ -52,8 +49,8 @@
|
||||
</NewModal>
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon, PlusIcon } from "@modrinth/assets";
|
||||
import { NewModal, ButtonStyled } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
|
||||
const router = useNativeRouter();
|
||||
|
||||
@@ -78,7 +75,7 @@ async function create() {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
projects: props.projectIds,
|
||||
},
|
||||
apiVersion: 3,
|
||||
|
||||
@@ -1,329 +1,366 @@
|
||||
<template>
|
||||
<div class="card moderation-checklist">
|
||||
<h1>Moderation checklist</h1>
|
||||
<div v-if="done">
|
||||
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
|
||||
<div
|
||||
class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
|
||||
:class="collapsed ? `sm:max-w-[300px]` : 'sm:max-w-[600px]'"
|
||||
>
|
||||
<div class="flex grow-0 items-center gap-2">
|
||||
<h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast">
|
||||
<ScaleIcon class="text-orange" /> Moderation
|
||||
</h1>
|
||||
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||||
<button v-tooltip="`Exit moderation`" @click="exitModeration">
|
||||
<CrossIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button v-tooltip="collapsed ? `Expand` : `Collapse`" @click="emit('toggleCollapsed')">
|
||||
<DropdownIcon class="transition-transform" :class="{ 'rotate-180': collapsed }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else-if="generatedMessage">
|
||||
<p>
|
||||
Enter your moderation message here. Remember to check the Moderation tab to answer any
|
||||
questions an author might have!
|
||||
</p>
|
||||
<div class="markdown-editor-spacing">
|
||||
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
|
||||
<Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
|
||||
<div class="my-4 h-[1px] w-full bg-divider" />
|
||||
<div v-if="done">
|
||||
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
|
||||
<h2 v-if="modPackData">
|
||||
Modpack permissions
|
||||
<template v-if="modPackIndex + 1 <= modPackData.length">
|
||||
({{ modPackIndex + 1 }} / {{ modPackData.length }})
|
||||
</template>
|
||||
</h2>
|
||||
<div v-if="!modPackData">Loading data...</div>
|
||||
<div v-else-if="modPackData.length === 0">
|
||||
<p>All permissions obtained. You may skip this step!</p>
|
||||
<div v-else-if="generatedMessage">
|
||||
<p>
|
||||
Enter your moderation message here. Remember to check the Moderation tab to answer any
|
||||
questions an author might have!
|
||||
</p>
|
||||
<div class="markdown-editor-spacing">
|
||||
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!modPackData[modPackIndex]">
|
||||
<p>All permission checks complete!</p>
|
||||
<div class="input-group modpack-buttons">
|
||||
<button class="btn" @click="modPackIndex -= 1">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
|
||||
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
|
||||
Modpack permissions
|
||||
<template v-if="modPackIndex + 1 <= modPackData.length">
|
||||
({{ modPackIndex + 1 }} / {{ modPackData.length }})
|
||||
</template>
|
||||
</h2>
|
||||
<div v-if="!modPackData">Loading data...</div>
|
||||
<div v-else-if="modPackData.length === 0">
|
||||
<p>All permissions obtained. You may skip this step!</p>
|
||||
</div>
|
||||
<div v-else-if="!modPackData[modPackIndex]">
|
||||
<p>All permission checks complete!</p>
|
||||
<div class="input-group modpack-buttons">
|
||||
<ButtonStyled>
|
||||
<button @click="modPackIndex -= 1">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="modPackData[modPackIndex].type === 'unknown'">
|
||||
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].status = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="modPackData[modPackIndex].status !== 'unidentified'"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<label for="proof">
|
||||
<span class="label__title">Proof</span>
|
||||
</label>
|
||||
<input
|
||||
id="proof"
|
||||
v-model="modPackData[modPackIndex].proof"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter proof of status..."
|
||||
/>
|
||||
<label for="link">
|
||||
<span class="label__title">Link</span>
|
||||
</label>
|
||||
<input
|
||||
id="link"
|
||||
v-model="modPackData[modPackIndex].url"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter link of project..."
|
||||
/>
|
||||
<label for="title">
|
||||
<span class="label__title">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="modPackData[modPackIndex].title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter title of project..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
|
||||
<p>
|
||||
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
|
||||
:href="modPackData[modPackIndex].url"
|
||||
target="_blank"
|
||||
class="text-link"
|
||||
>{{ modPackData[modPackIndex].url }}</a
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].status = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
|
||||
"
|
||||
>
|
||||
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
|
||||
Does this project provide identification and permission for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
|
||||
Does this project provide attribution for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else>
|
||||
Does this project provide proof of permission for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in filePermissionTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].approved === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].approved = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="blue">
|
||||
<button :disabled="!modPackData[modPackIndex].status" @click="modPackIndex += 1">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next project
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="modPackData[modPackIndex].type === 'unknown'">
|
||||
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].status = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="modPackData[modPackIndex].status !== 'unidentified'">
|
||||
<div class="universal-labels"></div>
|
||||
<label for="proof">
|
||||
<span class="label__title">Proof</span>
|
||||
</label>
|
||||
<input
|
||||
id="proof"
|
||||
v-model="modPackData[modPackIndex].proof"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter proof of status..."
|
||||
/>
|
||||
<label for="link">
|
||||
<span class="label__title">Link</span>
|
||||
</label>
|
||||
<input
|
||||
id="link"
|
||||
v-model="modPackData[modPackIndex].url"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter link of project..."
|
||||
/>
|
||||
<label for="title">
|
||||
<span class="label__title">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="modPackData[modPackIndex].title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter title of project..."
|
||||
/>
|
||||
<h2 class="m-0 mb-2 text-lg font-extrabold">{{ steps[currentStepIndex].question }}</h2>
|
||||
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
|
||||
<strong>Guidance:</strong>
|
||||
<ul class="mb-3 mt-2 leading-tight">
|
||||
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
|
||||
{{ rule }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template
|
||||
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
|
||||
>
|
||||
<strong>Reject things like:</strong>
|
||||
<ul class="mb-3 mt-2 leading-tight">
|
||||
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
|
||||
{{ example }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template
|
||||
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
|
||||
>
|
||||
<strong>Exceptions:</strong>
|
||||
<ul class="mb-3 mt-2 leading-tight">
|
||||
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
|
||||
{{ exception }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-if="steps[currentStepIndex].id === 'title'">
|
||||
<strong>Title:</strong> {{ project.title }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'slug'">
|
||||
<strong>Slug:</strong> {{ project.slug }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'summary'">
|
||||
<strong>Summary:</strong> {{ project.description }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'links'">
|
||||
<template v-if="project.issues_url">
|
||||
<strong>Issues: </strong>
|
||||
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
|
||||
<p>
|
||||
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
|
||||
:href="modPackData[modPackIndex].url"
|
||||
target="_blank"
|
||||
class="text-link"
|
||||
>{{ modPackData[modPackIndex].url }}</a
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].status = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="project.source_url">
|
||||
<strong>Source: </strong>
|
||||
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.wiki_url">
|
||||
<strong>Wiki: </strong>
|
||||
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.discord_url">
|
||||
<strong>Discord: </strong>
|
||||
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
|
||||
<br />
|
||||
</template>
|
||||
<template v-for="(donation, index) in project.donation_urls" :key="index">
|
||||
<strong>{{ donation.platform }}: </strong>
|
||||
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
|
||||
<br />
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'categories'">
|
||||
<strong>Categories:</strong>
|
||||
<Categories
|
||||
:categories="project.categories.concat(project.additional_categories)"
|
||||
:type="project.actualProjectType"
|
||||
class="categories"
|
||||
/>
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'side-types'">
|
||||
<strong>Client side:</strong> {{ project.client_side }} <br />
|
||||
<strong>Server side:</strong> {{ project.server_side }}
|
||||
</p>
|
||||
<div class="options input-group">
|
||||
<button
|
||||
v-for="(option, index) in steps[currentStepIndex].options"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected':
|
||||
selectedOptions[steps[currentStepIndex].id] &&
|
||||
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
|
||||
}"
|
||||
@click="toggleOption(steps[currentStepIndex].id, option)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
|
||||
selectedOptions[steps[currentStepIndex].id] &&
|
||||
selectedOptions[steps[currentStepIndex].id].length > 0
|
||||
"
|
||||
class="inputs universal-labels"
|
||||
>
|
||||
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
|
||||
Does this project provide identification and permission for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
|
||||
Does this project provide attribution for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else>
|
||||
Does this project provide proof of permission for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in filePermissionTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].approved === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].approved = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
<div
|
||||
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
|
||||
(x) => x.fillers && x.fillers.length > 0,
|
||||
)"
|
||||
:key="index"
|
||||
>
|
||||
<div v-for="(filler, idx) in option.fillers" :key="idx">
|
||||
<label :for="filler.id">
|
||||
<span class="label__title">
|
||||
{{ filler.question }}
|
||||
<span v-if="filler.required" class="required">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="filler.large" class="markdown-editor-spacing">
|
||||
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
|
||||
</div>
|
||||
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto">
|
||||
<div
|
||||
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="!done">
|
||||
<button aria-label="Skip" @click="goToNextProject">
|
||||
<ExitIcon aria-hidden="true" />
|
||||
<template v-if="futureProjects.length > 0">Skip</template>
|
||||
<template v-else>Exit</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="currentStepIndex > 0">
|
||||
<button @click="previousPage() && !done">
|
||||
<LeftArrowIcon aria-hidden="true" /> Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="currentStepIndex < steps.length - 1 && !done" color="brand">
|
||||
<button @click="nextPage()"><RightArrowIcon aria-hidden="true" /> Next</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="!generatedMessage" color="brand">
|
||||
<button :disabled="loadingMessage" @click="generateMessage">
|
||||
<UpdatedIcon aria-hidden="true" /> Generate message
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-if="generatedMessage && !done">
|
||||
<ButtonStyled color="green">
|
||||
<button @click="sendMessage(project.requested_status ?? 'approved')">
|
||||
<CheckIcon aria-hidden="true" /> Approve
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled color="red">
|
||||
<button @click="sendMessage('rejected')">
|
||||
<CrossIcon aria-hidden="true" /> Reject
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<OverflowMenu
|
||||
class="btn-dropdown-animation"
|
||||
:options="[
|
||||
{
|
||||
id: 'withhold',
|
||||
color: 'danger',
|
||||
action: () => sendMessage('withheld'),
|
||||
hoverFilled: true,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<DropdownIcon style="rotate: 180deg" />
|
||||
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
|
||||
Next project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group modpack-buttons">
|
||||
<button class="btn" :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-blue"
|
||||
:disabled="!modPackData[modPackIndex].status"
|
||||
@click="modPackIndex += 1"
|
||||
>
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h2>{{ steps[currentStepIndex].question }}</h2>
|
||||
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
|
||||
<strong>Rules guidance:</strong>
|
||||
<ul>
|
||||
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
|
||||
{{ rule }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template
|
||||
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
|
||||
>
|
||||
<strong>Examples of what to reject:</strong>
|
||||
<ul>
|
||||
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
|
||||
{{ example }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template
|
||||
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
|
||||
>
|
||||
<strong>Exceptions:</strong>
|
||||
<ul>
|
||||
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
|
||||
{{ exception }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-if="steps[currentStepIndex].id === 'title'">
|
||||
<strong>Title:</strong> {{ project.title }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'slug'"><strong>Slug:</strong> {{ project.slug }}</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'summary'">
|
||||
<strong>Summary:</strong> {{ project.description }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'links'">
|
||||
<template v-if="project.issues_url">
|
||||
<strong>Issues: </strong>
|
||||
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.source_url">
|
||||
<strong>Source: </strong>
|
||||
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.wiki_url">
|
||||
<strong>Wiki: </strong>
|
||||
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.discord_url">
|
||||
<strong>Discord: </strong>
|
||||
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
|
||||
<br />
|
||||
</template>
|
||||
<template v-for="(donation, index) in project.donation_urls" :key="index">
|
||||
<strong>{{ donation.platform }}: </strong>
|
||||
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
|
||||
<br />
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'categories'">
|
||||
<strong>Categories:</strong>
|
||||
<Categories
|
||||
:categories="project.categories.concat(project.additional_categories)"
|
||||
:type="project.actualProjectType"
|
||||
class="categories"
|
||||
/>
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'side-types'">
|
||||
<strong>Client side:</strong> {{ project.client_side }} <br />
|
||||
<strong>Server side:</strong> {{ project.server_side }}
|
||||
</p>
|
||||
<div class="options input-group">
|
||||
<button
|
||||
v-for="(option, index) in steps[currentStepIndex].options"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected':
|
||||
selectedOptions[steps[currentStepIndex].id] &&
|
||||
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
|
||||
}"
|
||||
@click="toggleOption(steps[currentStepIndex].id, option)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
selectedOptions[steps[currentStepIndex].id] &&
|
||||
selectedOptions[steps[currentStepIndex].id].length > 0
|
||||
"
|
||||
class="inputs universal-labels"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
|
||||
(x) => x.fillers && x.fillers.length > 0,
|
||||
)"
|
||||
:key="index"
|
||||
>
|
||||
<div v-for="(filler, idx) in option.fillers" :key="idx">
|
||||
<label :for="filler.id">
|
||||
<span class="label__title">
|
||||
{{ filler.question }}
|
||||
<span v-if="filler.required" class="required">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="filler.large" class="markdown-editor-spacing">
|
||||
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
|
||||
</div>
|
||||
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group modpack-buttons">
|
||||
<button v-if="!done" class="btn skip-btn" aria-label="Skip" @click="goToNextProject">
|
||||
<ExitIcon aria-hidden="true" />
|
||||
<template v-if="futureProjects.length > 0">Skip</template>
|
||||
<template v-else>Exit</template>
|
||||
</button>
|
||||
<button v-if="currentStepIndex > 0" class="btn" @click="previousPage() && !done">
|
||||
<LeftArrowIcon aria-hidden="true" /> Previous
|
||||
</button>
|
||||
<button
|
||||
v-if="currentStepIndex < steps.length - 1 && !done"
|
||||
class="btn btn-primary"
|
||||
@click="nextPage()"
|
||||
>
|
||||
<RightArrowIcon aria-hidden="true" /> Next
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!generatedMessage"
|
||||
class="btn btn-primary"
|
||||
:disabled="loadingMessage"
|
||||
@click="generateMessage"
|
||||
>
|
||||
<UpdatedIcon aria-hidden="true" /> Generate message
|
||||
</button>
|
||||
<template v-if="generatedMessage && !done">
|
||||
<button class="btn btn-green" @click="sendMessage(project.requested_status ?? 'approved')">
|
||||
<CheckIcon aria-hidden="true" /> Approve
|
||||
</button>
|
||||
<div class="joined-buttons">
|
||||
<button class="btn btn-danger" @click="sendMessage('rejected')">
|
||||
<CrossIcon aria-hidden="true" /> Reject
|
||||
</button>
|
||||
<OverflowMenu
|
||||
class="btn btn-danger btn-dropdown-animation icon-only"
|
||||
:options="[
|
||||
{
|
||||
id: 'withhold',
|
||||
color: 'danger',
|
||||
action: () => sendMessage('withheld'),
|
||||
hoverFilled: true,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<DropdownIcon style="rotate: 180deg" />
|
||||
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</template>
|
||||
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
|
||||
Next project
|
||||
</button>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -337,8 +374,9 @@ import {
|
||||
XIcon as CrossIcon,
|
||||
EyeOffIcon,
|
||||
ExitIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { MarkdownEditor, OverflowMenu } from "@modrinth/ui";
|
||||
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
|
||||
import Categories from "~/components/ui/search/Categories.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -355,8 +393,14 @@ const props = defineProps({
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["exit", "toggleCollapsed"]);
|
||||
|
||||
const steps = computed(() =>
|
||||
[
|
||||
{
|
||||
@@ -1008,6 +1052,20 @@ async function sendMessage(status) {
|
||||
|
||||
const router = useNativeRouter();
|
||||
|
||||
async function exitModeration() {
|
||||
await router.push({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: props.project.id,
|
||||
},
|
||||
state: {
|
||||
showChecklist: false,
|
||||
},
|
||||
});
|
||||
emit("exit");
|
||||
}
|
||||
|
||||
async function goToNextProject() {
|
||||
const project = props.futureProjects[0];
|
||||
|
||||
@@ -1031,23 +1089,8 @@ async function goToNextProject() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.moderation-checklist {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 100vw;
|
||||
z-index: 100;
|
||||
border: 1px solid var(--color-bg-inverted);
|
||||
width: 600px;
|
||||
|
||||
.skip-btn {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.next-project {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.modpack-buttons {
|
||||
margin-top: 1rem;
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.option-selected {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
|
||||
<ReportInfo
|
||||
v-for="report in reports.filter(
|
||||
(x) =>
|
||||
@@ -17,7 +16,6 @@
|
||||
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
||||
</template>
|
||||
<script setup>
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
||||
import { addReportMessage } from "~/helpers/threads.js";
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ const fetchSettings = async () => {
|
||||
initialSettings.value = settings as { interval: number; enabled: boolean };
|
||||
autoBackupEnabled.value = settings?.enabled ?? false;
|
||||
autoBackupInterval.value = settings?.interval || 6;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error fetching backup settings:", error);
|
||||
addNotification({
|
||||
@@ -114,6 +115,7 @@ const fetchSettings = async () => {
|
||||
text: "Failed to load backup settings",
|
||||
type: "error",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
isLoadingSettings.value = false;
|
||||
}
|
||||
@@ -155,8 +157,10 @@ const saveSettings = async () => {
|
||||
|
||||
defineExpose({
|
||||
show: async () => {
|
||||
await fetchSettings();
|
||||
modal.value?.show();
|
||||
const success = await fetchSettings();
|
||||
if (success) {
|
||||
modal.value?.show();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -54,7 +54,8 @@ const locations = ref([
|
||||
{ name: "New York", lat: 40.7128, lng: -74.006, active: true, clicked: false },
|
||||
{ name: "Los Angeles", lat: 34.0522, lng: -118.2437, active: true, clicked: false },
|
||||
{ name: "Miami", lat: 25.7617, lng: -80.1918, active: true, clicked: false },
|
||||
{ name: "Seattle", lat: 47.608013, lng: -122.3321, active: true, clicked: false },
|
||||
{ name: "Spokane", lat: 47.667309, lng: -117.411922, active: true, clicked: false },
|
||||
{ name: "Dallas", lat: 32.78372, lng: -96.7947, active: true, clicked: false },
|
||||
// Future Locations
|
||||
// { name: "London", lat: 51.5074, lng: -0.1278, active: false, clicked: false },
|
||||
// { name: "Frankfurt", lat: 50.1109, lng: 8.6821, active: false, clicked: false },
|
||||
|
||||
80
apps/frontend/src/components/ui/servers/OverviewLoading.vue
Normal file
80
apps/frontend/src/components/ui/servers/OverviewLoading.vue
Normal 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>
|
||||
@@ -1,23 +1,25 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal">
|
||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
|
||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p>
|
||||
|
||||
<p class="m-0">
|
||||
Are you sure you want to <span class="lowercase">{{ confirmActionText }}</span> the
|
||||
server?
|
||||
</p>
|
||||
<UiCheckbox
|
||||
v-model="powerDontAskAgainCheckbox"
|
||||
v-model="dontAskAgain"
|
||||
label="Don't ask me again"
|
||||
class="text-sm"
|
||||
:disabled="!currentPendingAction"
|
||||
:disabled="!powerAction"
|
||||
/>
|
||||
<div class="flex flex-row gap-4">
|
||||
<ButtonStyled type="standard" color="brand" @click="confirmAction">
|
||||
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
|
||||
<button>
|
||||
<CheckIcon class="h-5 w-5" />
|
||||
{{ currentPendingActionFriendly }} server
|
||||
{{ confirmActionText }} server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled @click="closePowerModal">
|
||||
<ButtonStyled @click="resetPowerAction">
|
||||
<button>
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
@@ -29,7 +31,7 @@
|
||||
|
||||
<NewModal
|
||||
ref="detailsModal"
|
||||
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`"
|
||||
:header="`All of ${serverName || 'Server'} info`"
|
||||
@close="closeDetailsModal"
|
||||
>
|
||||
<UiServersServerInfoLabels
|
||||
@@ -51,75 +53,74 @@
|
||||
<UiServersPanelSpinner class="size-5" /> Installing...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-else class="contents">
|
||||
|
||||
<template v-else>
|
||||
<ButtonStyled v-if="showStopButton" type="transparent">
|
||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer">
|
||||
<button :disabled="!canTakeAction" @click="initiateAction('stop')">
|
||||
<div class="flex gap-1">
|
||||
<StopCircleIcon class="h-5 w-5" />
|
||||
<span>{{ stopButtonText }}</span>
|
||||
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction">
|
||||
<div v-if="isStartingOrRestarting" class="grid place-content-center">
|
||||
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||
<div v-if="isTransitionState" class="grid place-content-center">
|
||||
<UiServersIconsLoadingIcon />
|
||||
</div>
|
||||
<div v-else class="contents">
|
||||
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" />
|
||||
</div>
|
||||
<span>
|
||||
{{ actionButtonText }}
|
||||
</span>
|
||||
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
|
||||
<span>{{ primaryActionText }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown options -->
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
:options="[
|
||||
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
|
||||
{ id: 'allServers', action: () => router.push('/servers/manage') },
|
||||
{ id: 'details', action: () => showDetailsModal() },
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #kill>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
<span>Kill server</span>
|
||||
</template>
|
||||
<template #allServers>
|
||||
<ServerIcon class="h-5 w-5" />
|
||||
<span>All servers</span>
|
||||
</template>
|
||||
<template #details>
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu :options="[...menuOptions]">
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #kill>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
<span>Kill server</span>
|
||||
</template>
|
||||
<template #allServers>
|
||||
<ServerIcon class="h-5 w-5" />
|
||||
<span>All servers</span>
|
||||
</template>
|
||||
<template #details>
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import {
|
||||
PlayIcon,
|
||||
UpdatedIcon,
|
||||
StopCircleIcon,
|
||||
SlashIcon,
|
||||
MoreVerticalIcon,
|
||||
XIcon,
|
||||
CheckIcon,
|
||||
ServerIcon,
|
||||
InfoIcon,
|
||||
MoreVerticalIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
type ServerAction = "start" | "stop" | "restart" | "kill";
|
||||
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
|
||||
|
||||
interface PowerAction {
|
||||
action: ServerAction;
|
||||
nextState: ServerState;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isOnline: boolean;
|
||||
isActioning: boolean;
|
||||
@@ -130,183 +131,142 @@ const props = defineProps<{
|
||||
uptimeSeconds: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: ServerAction): void;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const serverId = router.currentRoute.value.params.id;
|
||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
powerDontAskAgain: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: "start" | "restart" | "stop" | "kill"): void;
|
||||
}>();
|
||||
const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
|
||||
const powerAction = ref<PowerAction | null>(null);
|
||||
const dontAskAgain = ref(false);
|
||||
const startingDelay = ref(false);
|
||||
|
||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
|
||||
const ServerState = {
|
||||
Stopped: "Stopped",
|
||||
Starting: "Starting",
|
||||
Running: "Running",
|
||||
Stopping: "Stopping",
|
||||
Restarting: "Restarting",
|
||||
} as const;
|
||||
|
||||
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
|
||||
|
||||
const currentPendingAction = ref<string | null>(null);
|
||||
const currentPendingState = ref<ServerStateType | null>(null);
|
||||
const powerDontAskAgainCheckbox = ref(false);
|
||||
|
||||
const currentState = ref<ServerStateType>(
|
||||
props.isOnline ? ServerState.Running : ServerState.Stopped,
|
||||
);
|
||||
|
||||
const isStartingDelay = ref(false);
|
||||
const showStopButton = computed(
|
||||
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
|
||||
);
|
||||
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
|
||||
const canTakeAction = computed(
|
||||
() =>
|
||||
!props.isActioning &&
|
||||
!isStartingDelay.value &&
|
||||
currentState.value !== ServerState.Starting &&
|
||||
currentState.value !== ServerState.Stopping,
|
||||
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
|
||||
);
|
||||
|
||||
const isStartingOrRestarting = computed(
|
||||
() =>
|
||||
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
|
||||
const isRunning = computed(() => serverState.value === "running");
|
||||
const isTransitionState = computed(() =>
|
||||
["starting", "stopping", "restarting"].includes(serverState.value),
|
||||
);
|
||||
const isStoppingState = computed(() => serverState.value === "stopping");
|
||||
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
|
||||
|
||||
const isStopping = computed(() => currentState.value === ServerState.Stopping);
|
||||
|
||||
const actionButtonText = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case ServerState.Starting:
|
||||
return "Starting...";
|
||||
case ServerState.Restarting:
|
||||
return "Restarting...";
|
||||
case ServerState.Running:
|
||||
return "Restart";
|
||||
case ServerState.Stopping:
|
||||
return "Stopping...";
|
||||
default:
|
||||
return "Start";
|
||||
}
|
||||
const primaryActionText = computed(() => {
|
||||
const states: Record<ServerState, string> = {
|
||||
starting: "Starting...",
|
||||
restarting: "Restarting...",
|
||||
running: "Restart",
|
||||
stopping: "Stopping...",
|
||||
stopped: "Start",
|
||||
};
|
||||
return states[serverState.value];
|
||||
});
|
||||
|
||||
const currentPendingActionFriendly = computed(() => {
|
||||
switch (currentPendingAction.value) {
|
||||
case "start":
|
||||
return "Start";
|
||||
case "restart":
|
||||
return "Restart";
|
||||
case "stop":
|
||||
return "Stop";
|
||||
case "kill":
|
||||
return "Kill";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const confirmActionText = computed(() => {
|
||||
if (!powerAction.value) return "";
|
||||
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
|
||||
});
|
||||
|
||||
const stopButtonText = computed(() =>
|
||||
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop",
|
||||
);
|
||||
const menuOptions = computed(() => [
|
||||
...(props.isInstalling
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "kill",
|
||||
label: "Kill server",
|
||||
icon: SlashIcon,
|
||||
action: () => initiateAction("kill"),
|
||||
},
|
||||
]),
|
||||
{
|
||||
id: "allServers",
|
||||
label: "All servers",
|
||||
icon: ServerIcon,
|
||||
action: () => router.push("/servers/manage"),
|
||||
},
|
||||
{
|
||||
id: "details",
|
||||
label: "Details",
|
||||
icon: InfoIcon,
|
||||
action: () => detailsModal.value?.show(),
|
||||
},
|
||||
]);
|
||||
|
||||
const createPendingAction = () => {
|
||||
function initiateAction(action: ServerAction) {
|
||||
if (!canTakeAction.value) return;
|
||||
if (currentState.value === ServerState.Running) {
|
||||
currentPendingAction.value = "restart";
|
||||
currentPendingState.value = ServerState.Restarting;
|
||||
showPowerModal();
|
||||
} else {
|
||||
runAction("start", ServerState.Starting);
|
||||
|
||||
const stateMap: Record<ServerAction, ServerState> = {
|
||||
start: "starting",
|
||||
stop: "stopping",
|
||||
restart: "restarting",
|
||||
kill: "stopping",
|
||||
};
|
||||
|
||||
if (action === "start") {
|
||||
emit("action", action);
|
||||
serverState.value = stateMap[action];
|
||||
startingDelay.value = true;
|
||||
setTimeout(() => (startingDelay.value = false), 5000);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
createPendingAction();
|
||||
};
|
||||
powerAction.value = { action, nextState: stateMap[action] };
|
||||
|
||||
const showPowerModal = () => {
|
||||
if (userPreferences.value.powerDontAskAgain) {
|
||||
runAction(
|
||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
||||
currentPendingState.value!,
|
||||
);
|
||||
executePowerAction();
|
||||
} else {
|
||||
confirmActionModal.value?.show();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const confirmAction = () => {
|
||||
if (powerDontAskAgainCheckbox.value) {
|
||||
function handlePrimaryAction() {
|
||||
initiateAction(isRunning.value ? "restart" : "start");
|
||||
}
|
||||
|
||||
function executePowerAction() {
|
||||
if (!powerAction.value) return;
|
||||
|
||||
const { action, nextState } = powerAction.value;
|
||||
emit("action", action);
|
||||
serverState.value = nextState;
|
||||
|
||||
if (dontAskAgain.value) {
|
||||
userPreferences.value.powerDontAskAgain = true;
|
||||
}
|
||||
runAction(
|
||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
||||
currentPendingState.value!,
|
||||
);
|
||||
closePowerModal();
|
||||
};
|
||||
|
||||
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
|
||||
emit("action", action);
|
||||
currentState.value = serverState;
|
||||
|
||||
if (action === "start") {
|
||||
isStartingDelay.value = true;
|
||||
setTimeout(() => {
|
||||
isStartingDelay.value = false;
|
||||
}, 5000);
|
||||
startingDelay.value = true;
|
||||
setTimeout(() => (startingDelay.value = false), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const stopServer = () => {
|
||||
if (!canTakeAction.value) return;
|
||||
currentPendingAction.value = "stop";
|
||||
currentPendingState.value = ServerState.Stopping;
|
||||
showPowerModal();
|
||||
};
|
||||
resetPowerAction();
|
||||
}
|
||||
|
||||
const killServer = () => {
|
||||
currentPendingAction.value = "kill";
|
||||
currentPendingState.value = ServerState.Stopping;
|
||||
showPowerModal();
|
||||
};
|
||||
|
||||
const closePowerModal = () => {
|
||||
function resetPowerAction() {
|
||||
confirmActionModal.value?.hide();
|
||||
currentPendingAction.value = null;
|
||||
powerDontAskAgainCheckbox.value = false;
|
||||
};
|
||||
powerAction.value = null;
|
||||
dontAskAgain.value = false;
|
||||
}
|
||||
|
||||
const closeDetailsModal = () => {
|
||||
function closeDetailsModal() {
|
||||
detailsModal.value?.hide();
|
||||
};
|
||||
|
||||
const showDetailsModal = () => {
|
||||
detailsModal.value?.show();
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isOnline,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
currentState.value = ServerState.Running;
|
||||
} else {
|
||||
currentState.value = ServerState.Stopped;
|
||||
}
|
||||
},
|
||||
(online) => (serverState.value = online ? "running" : "stopped"),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
closeDetailsModal();
|
||||
},
|
||||
() => closeDetailsModal(),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,66 +1,72 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-label="`Server is ${getStatusText}`"
|
||||
:aria-label="`Server is ${getStatusText(state)}`"
|
||||
class="relative inline-flex select-none items-center"
|
||||
@mouseenter="isExpanded = true"
|
||||
@mouseleave="isExpanded = false"
|
||||
>
|
||||
<div
|
||||
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`"
|
||||
:class="[
|
||||
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
|
||||
getStatusClass(state).main,
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`"
|
||||
:class="[
|
||||
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
|
||||
getStatusClass(state).bg,
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${
|
||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0'
|
||||
}`"
|
||||
:class="[
|
||||
'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
|
||||
getStatusClass(state).bg,
|
||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
|
||||
]"
|
||||
>
|
||||
<div class="h-3 w-3 rounded-full"></div>
|
||||
<span
|
||||
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out"
|
||||
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`"
|
||||
:class="[
|
||||
'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
|
||||
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
|
||||
]"
|
||||
>
|
||||
{{ getStatusText }}
|
||||
{{ getStatusText(state) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import type { ServerState } from "~/types/servers";
|
||||
|
||||
const props = defineProps<{
|
||||
const STATUS_CLASSES = {
|
||||
running: { main: "bg-brand", bg: "bg-bg-green" },
|
||||
stopped: { main: "", bg: "" },
|
||||
crashed: { main: "bg-brand-red", bg: "bg-bg-red" },
|
||||
unknown: { main: "", bg: "" },
|
||||
} as const;
|
||||
|
||||
const STATUS_TEXTS = {
|
||||
running: "Running",
|
||||
stopped: "",
|
||||
crashed: "Crashed",
|
||||
unknown: "Unknown",
|
||||
} as const;
|
||||
|
||||
defineProps<{
|
||||
state: ServerState;
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const getStatusClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case "running":
|
||||
return { main: "bg-brand", bg: "bg-bg-green" };
|
||||
case "stopped":
|
||||
return { main: "", bg: "" };
|
||||
case "crashed":
|
||||
return { main: "bg-brand-red", bg: "bg-bg-red" };
|
||||
default:
|
||||
return { main: "", bg: "" };
|
||||
}
|
||||
});
|
||||
function getStatusClass(state: ServerState) {
|
||||
return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
|
||||
}
|
||||
|
||||
const getStatusText = computed(() => {
|
||||
switch (props.state) {
|
||||
case "running":
|
||||
return "Running";
|
||||
case "stopped":
|
||||
return "";
|
||||
case "crashed":
|
||||
return "Crashed";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
});
|
||||
function getStatusText(state: ServerState) {
|
||||
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown;
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -260,7 +260,25 @@
|
||||
</div>
|
||||
<NewModal ref="viewLogModal" class="z-[9999]" header="Viewing selected logs">
|
||||
<div class="text-contrast">
|
||||
<pre class="select-text overflow-x-auto whitespace-pre font-mono">{{ selectedLog }}</pre>
|
||||
<pre
|
||||
class="select-text overflow-x-auto whitespace-pre rounded-lg bg-bg font-mono"
|
||||
v-html="processedLogWithLinks"
|
||||
></pre>
|
||||
<div v-if="detectedLinks.length" class="border-contrast/20 mt-4 border-t pt-4">
|
||||
<h2>Detected Links</h2>
|
||||
<ul class="flex flex-col gap-2">
|
||||
<li v-for="(link, index) in detectedLinks" :key="index">
|
||||
<a
|
||||
:href="link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm text-blue hover:underline"
|
||||
>
|
||||
{{ link }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</div>
|
||||
@@ -272,6 +290,7 @@ import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
|
||||
import { useDebounceFn } from "@vueuse/core";
|
||||
import { NewModal } from "@modrinth/ui";
|
||||
import ButtonStyled from "@modrinth/ui/src/components/base/ButtonStyled.vue";
|
||||
import DOMPurify from "dompurify";
|
||||
import { usePyroConsole } from "~/store/console.ts";
|
||||
|
||||
const { $cosmetics } = useNuxtApp();
|
||||
@@ -984,6 +1003,38 @@ const jumpToLine = (line: string, event?: MouseEvent) => {
|
||||
});
|
||||
};
|
||||
|
||||
const sanitizeUrl = (url: string): string => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return "#";
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return "#";
|
||||
}
|
||||
};
|
||||
|
||||
const detectedLinks = computed(() => {
|
||||
const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g;
|
||||
const matches = [...selectedLog.value.matchAll(urlRegex)].map((match) => match[0]);
|
||||
return matches.filter((url) => sanitizeUrl(url) !== "#");
|
||||
});
|
||||
|
||||
const processedLogWithLinks = computed(() => {
|
||||
const urlRegex = /(https?:\/\/[^\s,<]+(?=[,\s<]|$))/g;
|
||||
const sanitizedLog = DOMPurify.sanitize(selectedLog.value, {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
});
|
||||
|
||||
return sanitizedLog.replace(urlRegex, (url) => {
|
||||
const safeUrl = sanitizeUrl(url);
|
||||
if (safeUrl === "#") return url;
|
||||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer nofollow" class="text-blue hover:underline">${url}</a>`;
|
||||
});
|
||||
});
|
||||
|
||||
watch(
|
||||
() => pyroConsole.filteredOutput.value,
|
||||
() => {
|
||||
|
||||
@@ -337,7 +337,7 @@ watch(
|
||||
selectedLoaderVersions,
|
||||
(newVersions) => {
|
||||
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
|
||||
selectedLoaderVersion.value = String(newVersions[0]); // Ensure string type
|
||||
selectedLoaderVersion.value = String(newVersions[0]);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
@@ -516,8 +516,6 @@ const handleReinstall = async () => {
|
||||
|
||||
const onShow = () => {
|
||||
selectedMCVersion.value = props.server.general?.mc_version || "";
|
||||
selectedLoaderVersion.value = "";
|
||||
hardReset.value = false;
|
||||
};
|
||||
|
||||
const onHide = () => {
|
||||
@@ -528,13 +526,15 @@ const onHide = () => {
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
selectedMCVersion.value = "";
|
||||
selectedLoaderVersion.value = "";
|
||||
serverCheckError.value = "";
|
||||
paperVersions.value = {};
|
||||
purpurVersions.value = {};
|
||||
};
|
||||
|
||||
const show = (loader: Loaders) => {
|
||||
if (selectedLoader.value !== loader) {
|
||||
selectedLoaderVersion.value = "";
|
||||
}
|
||||
selectedLoader.value = loader;
|
||||
selectedMCVersion.value = props.server.general?.mc_version || "";
|
||||
versionSelectModal.value?.show();
|
||||
|
||||
@@ -69,11 +69,13 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" />
|
||||
Your server has been suspended due to a billing issue. Please visit your billing settings or
|
||||
contact Modrinth Support for more information.
|
||||
<div class="flex flex-row gap-2">
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
|
||||
update your billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div
|
||||
v-if="uptimeSeconds || uptimeSeconds !== 0"
|
||||
v-tooltip="`Online for ${verboseUptime}`"
|
||||
class="flex min-w-0 flex-row items-center gap-4"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-4"
|
||||
data-pyro-uptime
|
||||
>
|
||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||
|
||||
@@ -20,7 +20,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
// Developer flags
|
||||
developerMode: false,
|
||||
showVersionFilesInTable: false,
|
||||
// showAdsWithPlus: false,
|
||||
// showAdsWithPlus: false,
|
||||
alwaysShowChecklistAsPopup: true,
|
||||
|
||||
// Feature toggles
|
||||
projectTypesPrimaryNav: false,
|
||||
|
||||
@@ -10,19 +10,111 @@ interface PyroFetchOptions {
|
||||
url?: string;
|
||||
token?: string;
|
||||
};
|
||||
retry?: boolean;
|
||||
retry?: number | boolean;
|
||||
}
|
||||
|
||||
async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> {
|
||||
class PyroServerError extends Error {
|
||||
public readonly errors: Map<string, Error> = new Map();
|
||||
public readonly timestamp: number = Date.now();
|
||||
|
||||
constructor(message?: string) {
|
||||
super(message || "Multiple errors occurred");
|
||||
this.name = "PyroServerError";
|
||||
}
|
||||
|
||||
addError(module: string, error: Error) {
|
||||
this.errors.set(module, error);
|
||||
this.message = this.buildErrorMessage();
|
||||
}
|
||||
|
||||
hasErrors() {
|
||||
return this.errors.size > 0;
|
||||
}
|
||||
|
||||
private buildErrorMessage(): string {
|
||||
return Array.from(this.errors.entries())
|
||||
.map(([_module, error]) => error.message)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
export class PyroServersFetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
public readonly originalError?: Error,
|
||||
public readonly module?: string,
|
||||
) {
|
||||
let errorMessage = message;
|
||||
let method = "GET";
|
||||
let path = "";
|
||||
|
||||
if (originalError instanceof FetchError) {
|
||||
const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/);
|
||||
if (matches) {
|
||||
method = matches[1];
|
||||
path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, "");
|
||||
}
|
||||
|
||||
const statusMessage = (() => {
|
||||
if (!statusCode) return "Unknown Error";
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return "Bad Request";
|
||||
case 401:
|
||||
return "Unauthorized";
|
||||
case 403:
|
||||
return "Forbidden";
|
||||
case 404:
|
||||
return "Not Found";
|
||||
case 408:
|
||||
return "Request Timeout";
|
||||
case 429:
|
||||
return "Too Many Requests";
|
||||
case 500:
|
||||
return "Internal Server Error";
|
||||
case 502:
|
||||
return "Bad Gateway";
|
||||
case 503:
|
||||
return "Service Unavailable";
|
||||
case 504:
|
||||
return "Gateway Timeout";
|
||||
default:
|
||||
return `HTTP ${statusCode}`;
|
||||
}
|
||||
})();
|
||||
|
||||
errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ""}`;
|
||||
} else {
|
||||
errorMessage = `${message}${statusCode ? ` (${statusCode})` : ""}${module ? ` in ${module}` : ""}`;
|
||||
}
|
||||
|
||||
super(errorMessage);
|
||||
this.name = "PyroServersFetchError";
|
||||
}
|
||||
}
|
||||
|
||||
async function PyroFetch<T>(
|
||||
path: string,
|
||||
options: PyroFetchOptions = {},
|
||||
module?: string,
|
||||
): Promise<T> {
|
||||
const config = useRuntimeConfig();
|
||||
const auth = await useAuth();
|
||||
const authToken = auth.value?.token;
|
||||
|
||||
if (!authToken) {
|
||||
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
|
||||
throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
|
||||
}
|
||||
|
||||
const { method = "GET", contentType = "application/json", body, version = 0, override } = options;
|
||||
const {
|
||||
method = "GET",
|
||||
contentType = "application/json",
|
||||
body,
|
||||
version = 0,
|
||||
override,
|
||||
retry = method === "GET" ? 3 : 0,
|
||||
} = options;
|
||||
|
||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||
/\/$/,
|
||||
@@ -30,9 +122,11 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
||||
);
|
||||
|
||||
if (!base) {
|
||||
throw new PyroFetchError(
|
||||
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
|
||||
10001,
|
||||
throw new PyroServersFetchError(
|
||||
"Configuration error: Missing PYRO_BASE_URL",
|
||||
500,
|
||||
undefined,
|
||||
module,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,9 +134,7 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
||||
? `https://${override.url}/${path.replace(/^\//, "")}`
|
||||
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
|
||||
|
||||
type HeadersRecord = Record<string, string>;
|
||||
|
||||
const headers: HeadersRecord = {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${override?.token ?? authToken}`,
|
||||
"Access-Control-Allow-Headers": "Authorization",
|
||||
"User-Agent": "Pyro/1.0 (https://pyro.host)",
|
||||
@@ -57,43 +149,47 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
||||
headers.Origin = window.location.origin;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||
timeout: 10000,
|
||||
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("[PyroServers/PyroFetch]:", error);
|
||||
if (error instanceof FetchError) {
|
||||
const statusCode = error.response?.status;
|
||||
const statusText = error.response?.statusText || "[no status text available]";
|
||||
const errorMessages: { [key: number]: string } = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
};
|
||||
const message =
|
||||
statusCode && statusCode in errorMessages
|
||||
? errorMessages[statusCode]
|
||||
: `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`;
|
||||
throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error);
|
||||
let attempts = 0;
|
||||
const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
attempts++;
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
const statusCode = error.response?.status;
|
||||
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
|
||||
|
||||
if (!isRetryable || attempts >= maxAttempts) {
|
||||
throw new PyroServersFetchError(error.message, statusCode, error, module);
|
||||
}
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new PyroServersFetchError(
|
||||
"Unexpected error during fetch operation",
|
||||
undefined,
|
||||
error as Error,
|
||||
module,
|
||||
);
|
||||
}
|
||||
throw new PyroFetchError(
|
||||
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.",
|
||||
undefined,
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
|
||||
throw lastError || new Error("Maximum retry attempts reached");
|
||||
}
|
||||
|
||||
const internalServerRefrence = ref<any>(null);
|
||||
@@ -271,100 +367,96 @@ const constructServerProperties = (properties: any): string => {
|
||||
};
|
||||
|
||||
const processImage = async (iconUrl: string | undefined) => {
|
||||
const image = ref<string | null>(null);
|
||||
const sharedImage = useState<string | undefined>(
|
||||
`server-icon-${internalServerRefrence.value.serverId}`,
|
||||
() => undefined,
|
||||
);
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
||||
try {
|
||||
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
if (import.meta.client) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(fileData);
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => {
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
internalServerRefrence.value.general.image = dataURL;
|
||||
image.value = dataURL;
|
||||
sharedImage.value = dataURL; // Store in useState
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
||||
sharedImage.value = undefined;
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
if (sharedImage.value) {
|
||||
return sharedImage.value;
|
||||
}
|
||||
|
||||
if (image.value === null && iconUrl) {
|
||||
console.log("iconUrl", iconUrl);
|
||||
try {
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
||||
try {
|
||||
const response = await fetch(iconUrl);
|
||||
const file = await response.blob();
|
||||
const originalfile = new File([file], "server-icon-original.png", {
|
||||
type: "image/png",
|
||||
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
retry: false,
|
||||
});
|
||||
if (import.meta.client) {
|
||||
const scaledFile = await new Promise<File>((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
ctx?.drawImage(img, 0, 0, 64, 64);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const data = new File([blob], "server-icon.png", { type: "image/png" });
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error("Canvas toBlob failed"));
|
||||
}
|
||||
}, "image/png");
|
||||
};
|
||||
img.onerror = reject;
|
||||
});
|
||||
if (scaledFile) {
|
||||
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: scaledFile,
|
||||
override: auth,
|
||||
});
|
||||
|
||||
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: originalfile,
|
||||
override: auth,
|
||||
if (fileData instanceof Blob) {
|
||||
if (import.meta.client) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
sharedImage.value = dataURL;
|
||||
resolve(dataURL);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(fileData);
|
||||
});
|
||||
return dataURL;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
||||
console.log("[PYROSERVERS] No server icon found");
|
||||
} else {
|
||||
console.error(error);
|
||||
if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
|
||||
try {
|
||||
const response = await fetch(iconUrl);
|
||||
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||
const file = await response.blob();
|
||||
const originalFile = new File([file], "server-icon-original.png", { type: "image/png" });
|
||||
|
||||
if (import.meta.client) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
ctx?.drawImage(img, 0, 0, 64, 64);
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
|
||||
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: scaledFile,
|
||||
override: auth,
|
||||
});
|
||||
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: originalFile,
|
||||
override: auth,
|
||||
});
|
||||
}
|
||||
}, "image/png");
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
sharedImage.value = dataURL;
|
||||
resolve(dataURL);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
return dataURL;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process external icon:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process server icon:", error);
|
||||
}
|
||||
return image.value;
|
||||
|
||||
sharedImage.value = undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// ------------------ GENERAL ------------------ //
|
||||
@@ -564,10 +656,14 @@ const reinstallContent = async (replace: string, projectId: string, versionId: s
|
||||
|
||||
const createBackup = async (backupName: string) => {
|
||||
try {
|
||||
const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, {
|
||||
method: "POST",
|
||||
body: { name: backupName },
|
||||
})) as { id: string };
|
||||
const response = await PyroFetch<{ id: string }>(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { name: backupName },
|
||||
},
|
||||
);
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
return response.id;
|
||||
} catch (error) {
|
||||
console.error("Error creating backup:", error);
|
||||
@@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => {
|
||||
method: "POST",
|
||||
body: { name: newName },
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error renaming backup:", error);
|
||||
throw error;
|
||||
@@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error deleting backup:", error);
|
||||
throw error;
|
||||
@@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => {
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error restoring backup:", error);
|
||||
throw error;
|
||||
@@ -644,12 +743,10 @@ const getAutoBackup = async () => {
|
||||
|
||||
const lockBackup = async (backupId: string) => {
|
||||
try {
|
||||
return await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
|
||||
method: "POST",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error locking backup:", error);
|
||||
throw error;
|
||||
@@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => {
|
||||
|
||||
const unlockBackup = async (backupId: string) => {
|
||||
try {
|
||||
return await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
|
||||
method: "POST",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error locking backup:", error);
|
||||
console.error("Error unlocking backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
||||
try {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 401) {
|
||||
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
|
||||
await internalServerRefrence.value.refresh(["fs"]);
|
||||
return await requestFn();
|
||||
}
|
||||
@@ -947,17 +1042,18 @@ const modules: any = {
|
||||
general: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
const data = await PyroFetch<General>(`servers/${serverId}`);
|
||||
// TODO: temp hack to fix hydration error
|
||||
const data = await PyroFetch<General>(`servers/${serverId}`, {}, "general");
|
||||
if (data.upstream?.project_id) {
|
||||
const res = await $fetch(
|
||||
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
|
||||
);
|
||||
data.project = res as Project;
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
data.image = (await processImage(data.project?.icon_url)) ?? undefined;
|
||||
}
|
||||
|
||||
const motd = await getMotd();
|
||||
if (motd === "A Minecraft Server") {
|
||||
await setMotd(
|
||||
@@ -967,8 +1063,19 @@ const modules: any = {
|
||||
data.motd = motd;
|
||||
return data;
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
server_id: serverId,
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
updateName,
|
||||
@@ -982,16 +1089,23 @@ const modules: any = {
|
||||
content: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`);
|
||||
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`, {}, "content");
|
||||
return {
|
||||
data:
|
||||
internalServerRefrence.value.error === undefined
|
||||
? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? ""))
|
||||
: [],
|
||||
data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")),
|
||||
};
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
data: [],
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
install: installContent,
|
||||
@@ -1001,10 +1115,22 @@ const modules: any = {
|
||||
backups: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return { data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`) };
|
||||
return {
|
||||
data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`, {}, "backups"),
|
||||
};
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
data: [],
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
create: createBackup,
|
||||
@@ -1020,10 +1146,26 @@ const modules: any = {
|
||||
network: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return { allocations: await PyroFetch<Allocation[]>(`servers/${serverId}/allocations`) };
|
||||
return {
|
||||
allocations: await PyroFetch<Allocation[]>(
|
||||
`servers/${serverId}/allocations`,
|
||||
{},
|
||||
"network",
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
allocations: [],
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
reserveAllocation,
|
||||
@@ -1035,10 +1177,19 @@ const modules: any = {
|
||||
startup: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return await PyroFetch<Startup>(`servers/${serverId}/startup`);
|
||||
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
update: updateStartupSettings,
|
||||
@@ -1046,20 +1197,39 @@ const modules: any = {
|
||||
ws: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`);
|
||||
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
fs: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`) };
|
||||
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
auth: undefined,
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
listDirContents,
|
||||
@@ -1367,12 +1537,44 @@ type FSFunctions = {
|
||||
downloadFile: (path: string, raw?: boolean) => Promise<any>;
|
||||
};
|
||||
|
||||
type GeneralModule = General & GeneralFunctions;
|
||||
type ContentModule = { data: Mod[] } & ContentFunctions;
|
||||
type BackupsModule = { data: Backup[] } & BackupFunctions;
|
||||
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
|
||||
type StartupModule = Startup & StartupFunctions;
|
||||
export type FSModule = { auth: JWTAuth } & FSFunctions;
|
||||
type ModuleError = {
|
||||
error: PyroServersFetchError;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type GeneralModule = General &
|
||||
GeneralFunctions & {
|
||||
error?: ModuleError;
|
||||
};
|
||||
|
||||
type ContentModule = {
|
||||
data: Mod[];
|
||||
error?: ModuleError;
|
||||
} & ContentFunctions;
|
||||
|
||||
type BackupsModule = {
|
||||
data: Backup[];
|
||||
error?: ModuleError;
|
||||
} & BackupFunctions;
|
||||
|
||||
type NetworkModule = {
|
||||
allocations: Allocation[];
|
||||
error?: ModuleError;
|
||||
} & NetworkFunctions;
|
||||
|
||||
type StartupModule = Startup &
|
||||
StartupFunctions & {
|
||||
error?: ModuleError;
|
||||
};
|
||||
|
||||
type WSModule = JWTAuth & {
|
||||
error?: ModuleError;
|
||||
};
|
||||
|
||||
type FSModule = {
|
||||
auth: JWTAuth;
|
||||
error?: ModuleError;
|
||||
} & FSFunctions;
|
||||
|
||||
type ModulesMap = {
|
||||
general: GeneralModule;
|
||||
@@ -1380,7 +1582,7 @@ type ModulesMap = {
|
||||
backups: BackupsModule;
|
||||
network: NetworkModule;
|
||||
startup: StartupModule;
|
||||
ws: JWTAuth;
|
||||
ws: WSModule;
|
||||
fs: FSModule;
|
||||
};
|
||||
|
||||
@@ -1401,6 +1603,7 @@ export type Server<T extends avaliableModules> = {
|
||||
preserveInstallState?: boolean;
|
||||
},
|
||||
) => Promise<void>;
|
||||
loadModules: (modulesToLoad: avaliableModules) => Promise<void>;
|
||||
setError: (error: Error) => void;
|
||||
error?: Error;
|
||||
serverId: string;
|
||||
@@ -1419,58 +1622,92 @@ export const usePyroServer = async (serverId: string, includedModules: avaliable
|
||||
return;
|
||||
}
|
||||
|
||||
const modulesToRefresh = refreshModules || includedModules;
|
||||
const promises: Promise<void>[] = [];
|
||||
const modulesToRefresh = [...new Set(refreshModules || includedModules)];
|
||||
const serverError = new PyroServerError();
|
||||
|
||||
const uniqueModules = [...new Set(modulesToRefresh)];
|
||||
const modulePromises = modulesToRefresh.map(async (module) => {
|
||||
try {
|
||||
const mods = modules[module];
|
||||
if (!mods?.get) return;
|
||||
|
||||
for (const module of uniqueModules) {
|
||||
const mods = modules[module];
|
||||
if (mods.get) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const data = await mods.get(serverId);
|
||||
if (data) {
|
||||
if (module === "general" && options?.preserveConnection) {
|
||||
const updatedData = {
|
||||
...server[module],
|
||||
...data,
|
||||
};
|
||||
if (server[module]?.image) {
|
||||
updatedData.image = server[module].image;
|
||||
}
|
||||
if (server[module]?.motd) {
|
||||
updatedData.motd = server[module].motd;
|
||||
}
|
||||
if (options.preserveInstallState && server[module]?.status === "installing") {
|
||||
updatedData.status = "installing";
|
||||
}
|
||||
server[module] = updatedData;
|
||||
} else {
|
||||
server[module] = { ...server[module], ...data };
|
||||
}
|
||||
}
|
||||
})(),
|
||||
);
|
||||
const data = await mods.get(serverId);
|
||||
if (!data) return;
|
||||
|
||||
if (module === "general" && options?.preserveConnection) {
|
||||
server[module] = {
|
||||
...server[module],
|
||||
...data,
|
||||
image: server[module]?.image || data.image,
|
||||
motd: server[module]?.motd || data.motd,
|
||||
status:
|
||||
options.preserveInstallState && server[module]?.status === "installing"
|
||||
? "installing"
|
||||
: data.status,
|
||||
};
|
||||
} else {
|
||||
server[module] = { ...server[module], ...data };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to refresh module ${module}:`, error);
|
||||
if (error instanceof Error) {
|
||||
serverError.addError(module, error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.allSettled(modulePromises);
|
||||
|
||||
if (serverError.hasErrors()) {
|
||||
if (server.error && server.error instanceof PyroServerError) {
|
||||
serverError.errors.forEach((error, module) => {
|
||||
(server.error as PyroServerError).addError(module, error);
|
||||
});
|
||||
} else {
|
||||
server.setError(serverError);
|
||||
}
|
||||
}
|
||||
},
|
||||
loadModules: async (modulesToLoad: avaliableModules) => {
|
||||
const newModules = modulesToLoad.filter((module) => !server[module]);
|
||||
if (newModules.length === 0) return;
|
||||
|
||||
await Promise.all(promises);
|
||||
newModules.forEach((module) => {
|
||||
server[module] = modules[module];
|
||||
});
|
||||
|
||||
await server.refresh(newModules);
|
||||
},
|
||||
setError: (error: Error) => {
|
||||
server.error = error;
|
||||
if (!server.error) {
|
||||
server.error = error;
|
||||
} else if (error instanceof PyroServerError) {
|
||||
if (!(server.error instanceof PyroServerError)) {
|
||||
const newError = new PyroServerError();
|
||||
newError.addError("previous", server.error);
|
||||
server.error = newError;
|
||||
}
|
||||
error.errors.forEach((err, module) => {
|
||||
(server.error as PyroServerError).addError(module, err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
serverId,
|
||||
});
|
||||
|
||||
for (const module of includedModules) {
|
||||
const mods = modules[module];
|
||||
server[module] = mods;
|
||||
}
|
||||
const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module));
|
||||
const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module));
|
||||
|
||||
initialModules.forEach((module) => {
|
||||
server[module] = modules[module];
|
||||
});
|
||||
|
||||
internalServerRefrence.value = server;
|
||||
await server.refresh(initialModules);
|
||||
|
||||
await server.refresh();
|
||||
if (deferredModules.length > 0) {
|
||||
await server.loadModules(deferredModules);
|
||||
}
|
||||
|
||||
return server as Server<typeof includedModules>;
|
||||
};
|
||||
|
||||
@@ -294,12 +294,19 @@
|
||||
<template #moderation> <ModerationIcon aria-hidden="true" /> Moderation </template>
|
||||
<template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template>
|
||||
</OverflowMenu>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<nuxt-link to="/auth/sign-in">
|
||||
<LogInIcon aria-hidden="true" />
|
||||
Sign in
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<template v-else>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link to="/auth/sign-in">
|
||||
<LogInIcon aria-hidden="true" />
|
||||
Sign in
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link v-tooltip="'Settings'" to="/settings">
|
||||
<SettingsIcon aria-label="Settings" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
<header class="mobile-navigation mobile-only">
|
||||
@@ -466,102 +473,95 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<main class="min-h-[calc(100vh-4.5rem-310.59px)]">
|
||||
<ModalCreation v-if="auth.user" ref="modal_creation" />
|
||||
<CollectionCreateModal ref="modal_collection_creation" />
|
||||
<OrganizationCreateModal ref="modal_organization_creation" />
|
||||
<slot id="main" />
|
||||
</main>
|
||||
<footer>
|
||||
<div class="logo-info" role="region" aria-label="Modrinth information">
|
||||
<BrandTextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base mx-auto mb-4 lg:mx-0"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<p class="mb-4">
|
||||
<IntlFormatted :message-id="footerMessages.openSource">
|
||||
<template #github-link="{ children }">
|
||||
<a
|
||||
:target="$external()"
|
||||
href="https://github.com/modrinth"
|
||||
class="text-link"
|
||||
rel="noopener"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
{{ config.public.branch }}@<a
|
||||
:target="$external()"
|
||||
:href="
|
||||
'https://github.com/' +
|
||||
config.public.owner +
|
||||
'/' +
|
||||
config.public.slug +
|
||||
'/tree/' +
|
||||
config.public.hash
|
||||
"
|
||||
class="text-link"
|
||||
rel="noopener"
|
||||
>{{ config.public.hash.substring(0, 7) }}</a
|
||||
<footer
|
||||
class="footer-brand-background experimental-styles-within mt-6 border-0 border-t-[1px] border-solid"
|
||||
>
|
||||
<div class="mx-auto flex max-w-screen-xl flex-col gap-6 p-6 pb-12 sm:px-12 md:py-12">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 text-primary md:grid-cols-[1fr_2fr] lg:grid-cols-[auto_auto_auto_auto_auto]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 md:items-start"
|
||||
role="region"
|
||||
aria-label="Modrinth information"
|
||||
>
|
||||
</p>
|
||||
<p>© Rinth, Inc.</p>
|
||||
</div>
|
||||
<div class="links links-1" role="region" aria-label="Legal">
|
||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.companyTitle) }}</h4>
|
||||
<nuxt-link to="/legal/terms"> {{ formatMessage(footerMessages.terms) }}</nuxt-link>
|
||||
<nuxt-link to="/legal/privacy"> {{ formatMessage(footerMessages.privacy) }}</nuxt-link>
|
||||
<nuxt-link to="/legal/rules"> {{ formatMessage(footerMessages.rules) }}</nuxt-link>
|
||||
<a :target="$external()" href="https://careers.modrinth.com">
|
||||
{{ formatMessage(footerMessages.careers) }}
|
||||
<span v-if="false" class="count-bubble">0</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="links links-2" role="region" aria-label="Resources">
|
||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.resourcesTitle) }}</h4>
|
||||
<a :target="$external()" href="https://support.modrinth.com">
|
||||
{{ formatMessage(footerMessages.support) }}
|
||||
</a>
|
||||
<a :target="$external()" href="https://blog.modrinth.com">
|
||||
{{ formatMessage(footerMessages.blog) }}
|
||||
</a>
|
||||
<a :target="$external()" href="https://docs.modrinth.com">
|
||||
{{ formatMessage(footerMessages.docs) }}
|
||||
</a>
|
||||
<a :target="$external()" href="https://status.modrinth.com">
|
||||
{{ formatMessage(footerMessages.status) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="links links-3" role="region" aria-label="Interact">
|
||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.interactTitle) }}</h4>
|
||||
<a rel="noopener" :target="$external()" href="https://discord.modrinth.com"> Discord </a>
|
||||
<a rel="noopener" :target="$external()" href="https://x.com/modrinth"> X (Twitter) </a>
|
||||
<a rel="noopener" :target="$external()" href="https://floss.social/@modrinth"> Mastodon </a>
|
||||
<a rel="noopener" :target="$external()" href="https://crowdin.com/project/modrinth">
|
||||
Crowdin
|
||||
</a>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<nuxt-link class="btn btn-outline btn-primary" to="/app">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.getModrinthApp) }}
|
||||
</nuxt-link>
|
||||
<button class="iconified-button raised-button" @click="changeTheme">
|
||||
<MoonIcon v-if="$theme.active === 'light'" aria-hidden="true" />
|
||||
<SunIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.changeTheme) }}
|
||||
</button>
|
||||
<nuxt-link class="iconified-button raised-button" to="/settings">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="not-affiliated-notice">
|
||||
{{ formatMessage(footerMessages.legalDisclaimer) }}
|
||||
<BrandTextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
|
||||
<ButtonStyled
|
||||
v-for="(social, index) in socialLinks"
|
||||
:key="`footer-social-${index}`"
|
||||
circular
|
||||
type="transparent"
|
||||
>
|
||||
<a
|
||||
v-tooltip="social.label"
|
||||
:href="social.href"
|
||||
target="_blank"
|
||||
:rel="`noopener${social.rel ? ` ${social.rel}` : ''}`"
|
||||
>
|
||||
<component :is="social.icon" class="h-5 w-5" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="mt-auto flex flex-wrap justify-center gap-3 md:flex-col">
|
||||
<p class="m-0">
|
||||
<IntlFormatted :message-id="footerMessages.openSource">
|
||||
<template #github-link="{ children }">
|
||||
<a
|
||||
href="https://github.com/modrinth/code"
|
||||
class="text-brand hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<p class="m-0">© 2025 Rinth, Inc.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:contents">
|
||||
<div
|
||||
v-for="group in footerLinks"
|
||||
:key="group.label"
|
||||
class="flex flex-col items-center gap-3 sm:items-start"
|
||||
>
|
||||
<h3 class="m-0 text-base text-contrast">{{ group.label }}</h3>
|
||||
<template v-for="item in group.links" :key="item.label">
|
||||
<nuxt-link
|
||||
v-if="item.href.startsWith('/')"
|
||||
:to="item.href"
|
||||
class="w-fit hover:underline"
|
||||
>
|
||||
{{ item.label }}
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else
|
||||
:href="item.href"
|
||||
class="w-fit hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center text-center text-xs font-medium text-secondary opacity-50">
|
||||
{{ formatMessage(footerMessages.legalDisclaimer) }}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -599,6 +599,12 @@ import {
|
||||
GlassesIcon,
|
||||
PaintBrushIcon,
|
||||
PackageOpenIcon,
|
||||
DiscordIcon,
|
||||
BlueskyIcon,
|
||||
TumblrIcon,
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
GitHubIcon,
|
||||
XIcon as CrossIcon,
|
||||
ScaleIcon as ModerationIcon,
|
||||
BellIcon as NotificationIcon,
|
||||
@@ -708,50 +714,6 @@ const footerMessages = defineMessages({
|
||||
id: "layout.footer.open-source",
|
||||
defaultMessage: "Modrinth is <github-link>open source</github-link>.",
|
||||
},
|
||||
companyTitle: {
|
||||
id: "layout.footer.company.title",
|
||||
defaultMessage: "Company",
|
||||
},
|
||||
terms: {
|
||||
id: "layout.footer.company.terms",
|
||||
defaultMessage: "Terms",
|
||||
},
|
||||
privacy: {
|
||||
id: "layout.footer.company.privacy",
|
||||
defaultMessage: "Privacy",
|
||||
},
|
||||
rules: {
|
||||
id: "layout.footer.company.rules",
|
||||
defaultMessage: "Rules",
|
||||
},
|
||||
careers: {
|
||||
id: "layout.footer.company.careers",
|
||||
defaultMessage: "Careers",
|
||||
},
|
||||
resourcesTitle: {
|
||||
id: "layout.footer.resources.title",
|
||||
defaultMessage: "Resources",
|
||||
},
|
||||
support: {
|
||||
id: "layout.footer.resources.support",
|
||||
defaultMessage: "Support",
|
||||
},
|
||||
blog: {
|
||||
id: "layout.footer.resources.blog",
|
||||
defaultMessage: "Blog",
|
||||
},
|
||||
docs: {
|
||||
id: "layout.footer.resources.docs",
|
||||
defaultMessage: "Docs",
|
||||
},
|
||||
status: {
|
||||
id: "layout.footer.resources.status",
|
||||
defaultMessage: "Status",
|
||||
},
|
||||
interactTitle: {
|
||||
id: "layout.footer.interact.title",
|
||||
defaultMessage: "Interact",
|
||||
},
|
||||
legalDisclaimer: {
|
||||
id: "layout.footer.legal-disclaimer",
|
||||
defaultMessage:
|
||||
@@ -1023,6 +985,194 @@ const { cycle: changeTheme } = useTheme();
|
||||
function hideStagingBanner() {
|
||||
cosmetics.value.hideStagingBanner = true;
|
||||
}
|
||||
|
||||
const socialLinks = [
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.discord", defaultMessage: "Discord" }),
|
||||
),
|
||||
href: "https://discord.modrinth.com",
|
||||
icon: DiscordIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.bluesky", defaultMessage: "Bluesky" }),
|
||||
),
|
||||
href: "https://bsky.app/profile/modrinth.com",
|
||||
icon: BlueskyIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.mastodon", defaultMessage: "Mastodon" }),
|
||||
),
|
||||
href: "https://floss.social/@modrinth",
|
||||
icon: MastodonIcon,
|
||||
rel: "me",
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
|
||||
),
|
||||
href: "https://tumblr.com/modrinth",
|
||||
icon: TumblrIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
||||
href: "https://x.com/modrinth",
|
||||
icon: TwitterIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
|
||||
),
|
||||
href: "https://github.com/modrinth",
|
||||
icon: GitHubIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const footerLinks = [
|
||||
{
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
|
||||
links: [
|
||||
{
|
||||
href: "https://blog.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/news/changelog",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.changelog", defaultMessage: "Changelog" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://status.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.status", defaultMessage: "Status" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://careers.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.careers", defaultMessage: "Careers" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/cmp-info",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.about.rewards-program",
|
||||
defaultMessage: "Rewards Program",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.products", defaultMessage: "Products" }),
|
||||
),
|
||||
links: [
|
||||
{
|
||||
href: "/plus",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.products.plus", defaultMessage: "Modrinth+" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/app",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.products.app", defaultMessage: "Modrinth App" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/servers",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.products.servers",
|
||||
defaultMessage: "Modrinth Servers",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.resources", defaultMessage: "Resources" }),
|
||||
),
|
||||
links: [
|
||||
{
|
||||
href: "https://support.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.resources.help-center",
|
||||
defaultMessage: "Help Center",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://crowdin.com/project/modrinth",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.resources.translate", defaultMessage: "Translate" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://github.com/modrinth/code/issues",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.resources.report-issues",
|
||||
defaultMessage: "Report issues",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://docs.modrinth.com/api/",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.resources.api-docs",
|
||||
defaultMessage: "API documentation",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.legal", defaultMessage: "Legal" })),
|
||||
links: [
|
||||
{
|
||||
href: "/legal/rules",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.legal.rules", defaultMessage: "Content Rules" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/terms",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.legal.terms-of-use", defaultMessage: "Terms of Use" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/privacy",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.legal.privacy-policy",
|
||||
defaultMessage: "Privacy Policy",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/security",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.legal.security-notice",
|
||||
defaultMessage: "Security Notice",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -1037,127 +1187,9 @@ function hideStagingBanner() {
|
||||
min-height: calc(100vh - var(--spacing-card-bg));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
margin-bottom: calc(var(--size-mobile-navbar-height) + 2rem);
|
||||
}
|
||||
|
||||
main {
|
||||
grid-area: main;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin: 6rem 0 2rem 0;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
grid-template:
|
||||
"logo-info logo-info logo-info" auto
|
||||
"links-1 links-2 links-3" auto
|
||||
"buttons buttons buttons" auto
|
||||
"notice notice notice" auto
|
||||
/ 1fr 1fr 1fr;
|
||||
max-width: 1280px;
|
||||
|
||||
.logo-info {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 15rem;
|
||||
margin-bottom: 1rem;
|
||||
grid-area: logo-info;
|
||||
|
||||
.text-logo {
|
||||
width: 10rem;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h4 {
|
||||
color: var(--color-text-dark);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
&.links-1 {
|
||||
grid-area: links-1;
|
||||
}
|
||||
|
||||
&.links-2 {
|
||||
grid-area: links-2;
|
||||
}
|
||||
|
||||
&.links-3 {
|
||||
grid-area: links-3;
|
||||
}
|
||||
|
||||
.count-bubble {
|
||||
font-size: 1rem;
|
||||
border-radius: 5rem;
|
||||
background: var(--color-brand);
|
||||
color: var(--color-text-inverted);
|
||||
padding: 0 0.35rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
grid-area: buttons;
|
||||
|
||||
button,
|
||||
a {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.not-affiliated-notice {
|
||||
grid-area: notice;
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
margin-top: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
display: grid;
|
||||
margin-inline: auto;
|
||||
grid-template:
|
||||
"logo-info links-1 links-2 links-3 buttons" auto
|
||||
"notice notice notice notice notice" auto;
|
||||
text-align: unset;
|
||||
|
||||
.logo-info {
|
||||
margin-right: 4rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-right: 4rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: unset;
|
||||
margin-left: 0;
|
||||
|
||||
button,
|
||||
a {
|
||||
margin-right: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.not-affiliated-notice {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -1445,5 +1477,10 @@ function hideStagingBanner() {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-brand-background {
|
||||
background: var(--brand-gradient-strong-bg);
|
||||
border-color: var(--brand-gradient-border);
|
||||
}
|
||||
</style>
|
||||
<style src="vue-multiselect/dist/vue-multiselect.css"></style>
|
||||
|
||||
@@ -287,45 +287,90 @@
|
||||
"layout.banner.verify-email.title": {
|
||||
"message": "For security purposes, please verify your email address on Modrinth."
|
||||
},
|
||||
"layout.footer.company.careers": {
|
||||
"layout.footer.about": {
|
||||
"message": "About"
|
||||
},
|
||||
"layout.footer.about.blog": {
|
||||
"message": "Blog"
|
||||
},
|
||||
"layout.footer.about.careers": {
|
||||
"message": "Careers"
|
||||
},
|
||||
"layout.footer.company.privacy": {
|
||||
"message": "Privacy"
|
||||
"layout.footer.about.changelog": {
|
||||
"message": "Changelog"
|
||||
},
|
||||
"layout.footer.company.rules": {
|
||||
"message": "Rules"
|
||||
"layout.footer.about.rewards-program": {
|
||||
"message": "Rewards Program"
|
||||
},
|
||||
"layout.footer.company.terms": {
|
||||
"message": "Terms"
|
||||
"layout.footer.about.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"layout.footer.company.title": {
|
||||
"message": "Company"
|
||||
},
|
||||
"layout.footer.interact.title": {
|
||||
"message": "Interact"
|
||||
"layout.footer.legal": {
|
||||
"message": "Legal"
|
||||
},
|
||||
"layout.footer.legal-disclaimer": {
|
||||
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
||||
},
|
||||
"layout.footer.legal.privacy-policy": {
|
||||
"message": "Privacy Policy"
|
||||
},
|
||||
"layout.footer.legal.rules": {
|
||||
"message": "Content Rules"
|
||||
},
|
||||
"layout.footer.legal.security-notice": {
|
||||
"message": "Security Notice"
|
||||
},
|
||||
"layout.footer.legal.terms-of-use": {
|
||||
"message": "Terms of Use"
|
||||
},
|
||||
"layout.footer.open-source": {
|
||||
"message": "Modrinth is <github-link>open source</github-link>."
|
||||
},
|
||||
"layout.footer.resources.blog": {
|
||||
"message": "Blog"
|
||||
"layout.footer.products": {
|
||||
"message": "Products"
|
||||
},
|
||||
"layout.footer.resources.docs": {
|
||||
"message": "Docs"
|
||||
"layout.footer.products.app": {
|
||||
"message": "Modrinth App"
|
||||
},
|
||||
"layout.footer.resources.status": {
|
||||
"message": "Status"
|
||||
"layout.footer.products.plus": {
|
||||
"message": "Modrinth+"
|
||||
},
|
||||
"layout.footer.resources.support": {
|
||||
"message": "Support"
|
||||
"layout.footer.products.servers": {
|
||||
"message": "Modrinth Servers"
|
||||
},
|
||||
"layout.footer.resources.title": {
|
||||
"layout.footer.resources": {
|
||||
"message": "Resources"
|
||||
},
|
||||
"layout.footer.resources.api-docs": {
|
||||
"message": "API documentation"
|
||||
},
|
||||
"layout.footer.resources.help-center": {
|
||||
"message": "Help Center"
|
||||
},
|
||||
"layout.footer.resources.report-issues": {
|
||||
"message": "Report issues"
|
||||
},
|
||||
"layout.footer.resources.translate": {
|
||||
"message": "Translate"
|
||||
},
|
||||
"layout.footer.social.bluesky": {
|
||||
"message": "Bluesky"
|
||||
},
|
||||
"layout.footer.social.discord": {
|
||||
"message": "Discord"
|
||||
},
|
||||
"layout.footer.social.github": {
|
||||
"message": "GitHub"
|
||||
},
|
||||
"layout.footer.social.mastodon": {
|
||||
"message": "Mastodon"
|
||||
},
|
||||
"layout.footer.social.tumblr": {
|
||||
"message": "Tumblr"
|
||||
},
|
||||
"layout.footer.social.x": {
|
||||
"message": "X"
|
||||
},
|
||||
"layout.menu-toggle.action": {
|
||||
"message": "Toggle menu"
|
||||
},
|
||||
|
||||
@@ -460,6 +460,10 @@
|
||||
class="new-page sidebar"
|
||||
:class="{
|
||||
'alt-layout': cosmetics.leftContentLayout,
|
||||
'ultimate-sidebar':
|
||||
showModerationChecklist &&
|
||||
!collapsedModerationChecklist &&
|
||||
!flags.alwaysShowChecklistAsPopup,
|
||||
}"
|
||||
>
|
||||
<div class="normal-page__header relative my-4">
|
||||
@@ -674,7 +678,7 @@
|
||||
:auth="auth"
|
||||
:tags="tags"
|
||||
/>
|
||||
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="mb-4">
|
||||
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="my-4">
|
||||
{{ project.title }} has been archived. {{ project.title }} will not receive any further
|
||||
updates unless the author decides to unarchive the project.
|
||||
</MessageBanner>
|
||||
@@ -805,13 +809,18 @@
|
||||
@delete-version="deleteVersion"
|
||||
/>
|
||||
</div>
|
||||
<div class="normal-page__ultimate-sidebar">
|
||||
<ModerationChecklist
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
:project="project"
|
||||
:future-projects="futureProjects"
|
||||
:reset-project="resetProject"
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModerationChecklist
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
:project="project"
|
||||
:future-projects="futureProjects"
|
||||
:reset-project="resetProject"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
@@ -1431,6 +1440,7 @@ async function copyId() {
|
||||
const collapsedChecklist = ref(false);
|
||||
|
||||
const showModerationChecklist = ref(false);
|
||||
const collapsedModerationChecklist = ref(false);
|
||||
const futureProjects = ref([]);
|
||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||
showModerationChecklist.value = true;
|
||||
|
||||
@@ -8,21 +8,25 @@
|
||||
<span class="label__subdescription">
|
||||
The description must clearly and honestly describe the purpose and function of the
|
||||
project. See section 2.1 of the
|
||||
<nuxt-link to="/legal/rules" class="text-link" target="_blank">Content Rules</nuxt-link>
|
||||
<nuxt-link class="text-link" target="_blank" to="/legal/rules">Content Rules</nuxt-link>
|
||||
for the full requirements.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownEditor
|
||||
v-model="description"
|
||||
:disabled="
|
||||
!currentMember ||
|
||||
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !==
|
||||
TeamMemberPermission.EDIT_BODY
|
||||
"
|
||||
:on-image-upload="onUploadHandler"
|
||||
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
|
||||
/>
|
||||
<div class="input-group markdown-disclaimer">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges"
|
||||
class="iconified-button brand-button"
|
||||
type="button"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
@@ -33,91 +37,50 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import { SaveIcon } from "@modrinth/assets";
|
||||
import { MarkdownEditor } from "@modrinth/ui";
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import SaveIcon from "~/assets/images/utils/save.svg?component";
|
||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
|
||||
import { computed, ref } from "vue";
|
||||
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Chips,
|
||||
SaveIcon,
|
||||
MarkdownEditor,
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
allMembers: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
patchProject: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {
|
||||
this.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: "Patch project function not found",
|
||||
type: "error",
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
description: this.project.body,
|
||||
bodyViewMode: "source",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
patchData() {
|
||||
const data = {};
|
||||
const props = defineProps<{
|
||||
project: Project;
|
||||
allMembers: TeamMember[];
|
||||
currentMember: TeamMember | undefined;
|
||||
patchProject: (payload: object, quiet?: boolean) => object;
|
||||
}>();
|
||||
|
||||
if (this.description !== this.project.body) {
|
||||
data.body = this.description;
|
||||
}
|
||||
const description = ref(props.project.body);
|
||||
|
||||
return data;
|
||||
},
|
||||
hasChanges() {
|
||||
return Object.keys(this.patchData).length > 0;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.EDIT_BODY = 1 << 3;
|
||||
},
|
||||
methods: {
|
||||
renderHighlightedString,
|
||||
saveChanges() {
|
||||
if (this.hasChanges) {
|
||||
this.patchProject(this.patchData);
|
||||
}
|
||||
},
|
||||
async onUploadHandler(file) {
|
||||
const response = await useImageUpload(file, {
|
||||
context: "project",
|
||||
projectID: this.project.id,
|
||||
});
|
||||
return response.url;
|
||||
},
|
||||
},
|
||||
const patchRequestPayload = computed(() => {
|
||||
const payload: {
|
||||
body?: string;
|
||||
} = {};
|
||||
|
||||
if (description.value !== props.project.body) {
|
||||
payload.body = description.value;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(patchRequestPayload.value).length > 0;
|
||||
});
|
||||
|
||||
function saveChanges() {
|
||||
props.patchProject(patchRequestPayload.value);
|
||||
}
|
||||
|
||||
async function onUploadHandler(file: File) {
|
||||
const response = await useImageUpload(file, {
|
||||
context: "project",
|
||||
projectID: props.project.id,
|
||||
});
|
||||
|
||||
return response.url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -640,7 +640,6 @@ import Badge from "~/components/ui/Badge.vue";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||
import Categories from "~/components/ui/search/Categories.vue";
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||
import FileInput from "~/components/ui/FileInput.vue";
|
||||
|
||||
@@ -663,6 +662,7 @@ import Modal from "~/components/ui/Modal.vue";
|
||||
import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component";
|
||||
|
||||
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
MarkdownEditor,
|
||||
@@ -670,7 +670,6 @@ export default defineNuxtComponent({
|
||||
FileInput,
|
||||
Checkbox,
|
||||
ChevronRightIcon,
|
||||
Chips,
|
||||
Categories,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
|
||||
@@ -40,12 +40,7 @@
|
||||
</span>
|
||||
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
||||
</label>
|
||||
<Toggle
|
||||
id="unprovision"
|
||||
:model-value="unprovision"
|
||||
:checked="unprovision"
|
||||
@update:model-value="() => (unprovision = !unprovision)"
|
||||
/>
|
||||
<Toggle id="unprovision" v-model="unprovision" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
@@ -114,7 +109,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
|
||||
import { Badge, ButtonStyled, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { CheckIcon, XIcon } from "@modrinth/assets";
|
||||
import { products } from "~/generated/state.json";
|
||||
|
||||
61
apps/frontend/src/pages/admin/user_email.vue
Normal file
61
apps/frontend/src/pages/admin/user_email.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="normal-page no-sidebar">
|
||||
<h1>User account request</h1>
|
||||
<div class="normal-page__content">
|
||||
<div class="card flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="name">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
User email
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="userEmail"
|
||||
type="email"
|
||||
maxlength="64"
|
||||
:placeholder="`Enter user email...`"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="getUserFromEmail">
|
||||
<MailIcon aria-hidden="true" />
|
||||
Get user account
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { MailIcon } from "@modrinth/assets";
|
||||
|
||||
const userEmail = ref("");
|
||||
|
||||
async function getUserFromEmail() {
|
||||
startLoading();
|
||||
|
||||
try {
|
||||
const result = await useBaseFetch(`user_email?email=${encodeURIComponent(userEmail.value)}`, {
|
||||
method: "GET",
|
||||
apiVersion: 3,
|
||||
});
|
||||
|
||||
await navigateTo(`/user/${result.username}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
addNotification({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: err.data.description,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
stopLoading();
|
||||
}
|
||||
</script>
|
||||
@@ -365,29 +365,29 @@
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
BoxIcon,
|
||||
CalendarIcon,
|
||||
EditIcon,
|
||||
XIcon,
|
||||
SaveIcon,
|
||||
UploadIcon,
|
||||
TrashIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
GridIcon,
|
||||
ImageIcon,
|
||||
ListIcon,
|
||||
UpdatedIcon,
|
||||
LibraryIcon,
|
||||
BoxIcon,
|
||||
LinkIcon,
|
||||
ListIcon,
|
||||
LockIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
UpdatedIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
PopoutMenu,
|
||||
FileInput,
|
||||
DropdownSelect,
|
||||
Avatar,
|
||||
Button,
|
||||
commonMessages,
|
||||
ConfirmModal,
|
||||
DropdownSelect,
|
||||
FileInput,
|
||||
PopoutMenu,
|
||||
} from "@modrinth/ui";
|
||||
|
||||
import { isAdmin } from "@modrinth/utils";
|
||||
@@ -651,7 +651,7 @@ async function saveChanges() {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
name: name.value,
|
||||
description: summary.value,
|
||||
description: summary.value || null,
|
||||
status: visibility.value,
|
||||
new_projects: newProjectIds,
|
||||
},
|
||||
|
||||
@@ -49,7 +49,9 @@
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-for="collection in orderedCollections"
|
||||
v-for="collection in orderedCollections.sort(
|
||||
(a, b) => new Date(b.created) - new Date(a.created),
|
||||
)"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="universal-card recessed collection"
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button } from "@modrinth/ui";
|
||||
import { Button, Chips } from "@modrinth/ui";
|
||||
import { HistoryIcon } from "@modrinth/assets";
|
||||
import {
|
||||
fetchExtraNotificationData,
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
markAsRead,
|
||||
} from "~/helpers/notifications.js";
|
||||
import NotificationItem from "~/components/ui/NotificationItem.vue";
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import CheckCheckIcon from "~/assets/images/utils/check-check.svg?component";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import Pagination from "~/components/ui/Pagination.vue";
|
||||
|
||||
@@ -1,39 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="experimental-styles-within">
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">Revenue</h2>
|
||||
<div v-if="userBalance.available >= minWithdraw">
|
||||
<p>
|
||||
You have
|
||||
<strong>{{ $formatMoney(userBalance.available) }}</strong>
|
||||
available to withdraw. <strong>{{ $formatMoney(userBalance.pending) }}</strong> of your
|
||||
balance is <nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
|
||||
</p>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Available now</div>
|
||||
<div class="value">
|
||||
{{ $formatMoney(userBalance.available) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">
|
||||
Total pending
|
||||
<nuxt-link
|
||||
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
|
||||
class="align-middle text-link"
|
||||
to="/legal/cmp-info#pending"
|
||||
>
|
||||
<UnknownIcon />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="value">
|
||||
{{ $formatMoney(userBalance.pending) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<h3 class="label m-0">
|
||||
Available soon
|
||||
<nuxt-link
|
||||
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
|
||||
class="align-middle text-link"
|
||||
to="/legal/cmp-info#pending"
|
||||
>
|
||||
<UnknownIcon />
|
||||
</nuxt-link>
|
||||
</h3>
|
||||
<ul class="m-0 list-none p-0">
|
||||
<li
|
||||
v-for="date in availableSoonDateKeys"
|
||||
:key="date"
|
||||
class="flex items-center justify-between border-0 border-solid border-b-divider p-0 [&:not(:last-child)]:mb-1 [&:not(:last-child)]:border-b-[1px] [&:not(:last-child)]:pb-1"
|
||||
>
|
||||
<span
|
||||
v-tooltip="
|
||||
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1
|
||||
? `Revenue period is ongoing. \nThis amount is not yet finalized.`
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'cursor-help':
|
||||
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1,
|
||||
}"
|
||||
class="inline-flex items-center gap-1 font-bold"
|
||||
>
|
||||
{{ $formatMoney(availableSoonDates[date]) }}
|
||||
<template
|
||||
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
|
||||
>
|
||||
<InProgressIcon />
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ formatDate(dayjs(date)) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>
|
||||
You have made
|
||||
<strong>{{ $formatMoney(userBalance.available) }}</strong
|
||||
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
|
||||
<strong>{{ $formatMoney(userBalance.pending) }}</strong> of your balance is
|
||||
<nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
|
||||
</p>
|
||||
<div class="input-group mt-4">
|
||||
<nuxt-link
|
||||
v-if="userBalance.available >= minWithdraw"
|
||||
class="iconified-button brand-button"
|
||||
to="/dashboard/revenue/withdraw"
|
||||
>
|
||||
<TransferIcon /> Withdraw
|
||||
</nuxt-link>
|
||||
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
|
||||
<nuxt-link
|
||||
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
|
||||
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
|
||||
class="iconified-button brand-button"
|
||||
to="/dashboard/revenue/withdraw"
|
||||
>
|
||||
<TransferIcon /> Withdraw
|
||||
</nuxt-link>
|
||||
</span>
|
||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon /> View transfer history
|
||||
<HistoryIcon />
|
||||
View transfer history
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p>
|
||||
<p class="text-sm text-secondary">
|
||||
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
|
||||
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more
|
||||
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
|
||||
information on how the rewards system works, see our information page
|
||||
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>.
|
||||
<nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
|
||||
</p>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
@@ -46,12 +102,13 @@
|
||||
{{ auth.user.payout_data.paypal_address }}
|
||||
</p>
|
||||
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
|
||||
<XIcon /> Disconnect account
|
||||
<XIcon />
|
||||
Disconnect account
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
|
||||
<a class="btn mt-4" :href="`${getAuthUrl('paypal')}&token=${auth.token}`">
|
||||
<a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
|
||||
<PayPalIcon />
|
||||
Sign in with PayPal
|
||||
</a>
|
||||
@@ -60,7 +117,8 @@
|
||||
<p>
|
||||
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
|
||||
visit
|
||||
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>.
|
||||
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
|
||||
.
|
||||
</p>
|
||||
<h3>Venmo</h3>
|
||||
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
|
||||
@@ -68,18 +126,32 @@
|
||||
<input
|
||||
id="venmo"
|
||||
v-model="auth.user.payout_data.venmo_handle"
|
||||
autocomplete="off"
|
||||
class="mt-4"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="@example"
|
||||
autocomplete="off"
|
||||
type="search"
|
||||
/>
|
||||
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button>
|
||||
<button class="btn btn-secondary" @click="updateVenmo">
|
||||
<SaveIcon />
|
||||
Save information
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from "@modrinth/assets";
|
||||
import {
|
||||
HistoryIcon,
|
||||
InProgressIcon,
|
||||
PayPalIcon,
|
||||
SaveIcon,
|
||||
TransferIcon,
|
||||
UnknownIcon,
|
||||
XIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatDate } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { computed } from "vue";
|
||||
|
||||
const auth = await useAuth();
|
||||
const minWithdraw = ref(0.01);
|
||||
@@ -88,6 +160,33 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
|
||||
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
|
||||
);
|
||||
|
||||
const deadlineEnding = computed(() => {
|
||||
let deadline = dayjs().subtract(2, "month").endOf("month").add(60, "days");
|
||||
if (deadline.isBefore(dayjs().startOf("day"))) {
|
||||
deadline = dayjs().subtract(1, "month").endOf("month").add(60, "days");
|
||||
}
|
||||
return deadline;
|
||||
});
|
||||
|
||||
const availableSoonDates = computed(() => {
|
||||
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
|
||||
const dates = Object.keys(userBalance.value.dates)
|
||||
.filter((date) => {
|
||||
const dateObj = dayjs(date);
|
||||
return (
|
||||
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, "month"))
|
||||
);
|
||||
})
|
||||
.sort((a, b) => dayjs(a).diff(dayjs(b)));
|
||||
|
||||
return dates.reduce((acc, date) => {
|
||||
acc[date] = userBalance.value.dates[date];
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value));
|
||||
|
||||
async function updateVenmo() {
|
||||
startLoading();
|
||||
try {
|
||||
@@ -118,4 +217,16 @@ strong {
|
||||
color: var(--color-text-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.disabled-cursor-wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabled-link {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-display {
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Rewards Program Information</h1>
|
||||
<p><em>Last modified: Sep 12, 2024</em></p>
|
||||
<p><em>Last modified: Feb 20, 2025</em></p>
|
||||
<p>
|
||||
This page was created for transparency for how the rewards program works on Modrinth. Feel
|
||||
free to join our Discord or email
|
||||
@@ -82,42 +82,41 @@
|
||||
<p>
|
||||
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
|
||||
revenue is immediately available to withdraw. We pay creators as soon as we receive the money
|
||||
from our ad providers, which is 60 days after the last day of each month. This table outlines
|
||||
some example dates of how NET 60 payments are made:
|
||||
from our ad providers, which is 60 days after the last day of each month.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To understand when revenue becomes available, you can use this calculator to estimate when
|
||||
revenue earned on a specific date will be available for withdrawal. Please be advised that all
|
||||
dates within this calculator are represented at 00:00 UTC.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Payment available date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>January 1st</td>
|
||||
<td>March 31st</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>January 15th</td>
|
||||
<td>March 31st</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>March 3rd</td>
|
||||
<td>May 30th</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>June 30th</td>
|
||||
<td>August 29th</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>July 14th</td>
|
||||
<td>September 29th</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>October 12th</td>
|
||||
<td>December 30th</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tr>
|
||||
<th>Timeline</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Revenue earned on</td>
|
||||
<td>
|
||||
<input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
|
||||
<noscript
|
||||
>(JavaScript must be enabled for the date picker to function, example date: 2024-07-15)
|
||||
</noscript>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>End of the month</td>
|
||||
<td>{{ formatDate(endOfMonthDate) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NET 60 policy applied</td>
|
||||
<td>+ 60 days</td>
|
||||
</tr>
|
||||
<tr class="final-result">
|
||||
<td>Available for withdrawal</td>
|
||||
<td>{{ formatDate(withdrawalDate) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3>How do I know Modrinth is being transparent about revenue?</h3>
|
||||
<p>
|
||||
@@ -127,12 +126,40 @@
|
||||
revenue distribution system</a
|
||||
>. We also have an
|
||||
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
|
||||
to query exact daily revenue for the site.
|
||||
to query exact daily revenue for the site - so far, Modrinth has generated
|
||||
<strong>{{ formatMoney(platformRevenue) }}</strong> in revenue.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Revenue</th>
|
||||
<th>Creator Revenue (75%)</th>
|
||||
<th>Modrinth's Cut (25%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in platformRevenueData" :key="item.time">
|
||||
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
|
||||
<td>{{ formatMoney(item.revenue) }}</td>
|
||||
<td>{{ formatMoney(item.creator_revenue) }}</td>
|
||||
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<small
|
||||
>Modrinth's total revenue in the previous 5 days, for the entire dataset, use the
|
||||
aforementioned
|
||||
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>.</small
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script lang="ts" setup>
|
||||
import dayjs from "dayjs";
|
||||
import { computed, ref } from "vue";
|
||||
import { formatDate, formatMoney } from "@modrinth/utils";
|
||||
|
||||
const description =
|
||||
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
|
||||
|
||||
@@ -142,4 +169,18 @@ useSeoMeta({
|
||||
ogTitle: "Rewards Program Information",
|
||||
ogDescription: description,
|
||||
});
|
||||
|
||||
const rawSelectedDate = ref(dayjs().format("YYYY-MM-DD"));
|
||||
const selectedDate = computed(() => dayjs(rawSelectedDate.value));
|
||||
const endOfMonthDate = computed(() => selectedDate.value.endOf("month"));
|
||||
const withdrawalDate = computed(() => endOfMonthDate.value.add(60, "days"));
|
||||
|
||||
const { data: transparencyInformation } = await useAsyncData("payout/platform_revenue", () =>
|
||||
useBaseFetch("payout/platform_revenue", {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
const platformRevenue = transparencyInformation.value.all_time;
|
||||
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
|
||||
</script>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import { Chips } from "@modrinth/ui";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import UnknownIcon from "~/assets/images/utils/unknown.svg?component";
|
||||
import EyeIcon from "~/assets/images/utils/eye.svg?component";
|
||||
|
||||
@@ -258,7 +258,8 @@
|
||||
<button
|
||||
v-if="
|
||||
result.installed ||
|
||||
server.content.data.find((x) => x.project_id === result.project_id) ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find((x) => x.project_id === result.project_id)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
"
|
||||
disabled
|
||||
@@ -376,7 +377,9 @@ async function updateServerContext() {
|
||||
if (!auth.value.user) {
|
||||
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
|
||||
} else if (route.query.sid !== null) {
|
||||
server.value = await usePyroServer(route.query.sid, ["general", "content"]);
|
||||
server.value = await usePyroServer(route.query.sid, ["general", "content"], {
|
||||
waitForModules: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,8 +498,8 @@ async function serverInstall(project) {
|
||||
) ?? versions[0];
|
||||
|
||||
if (projectType.value.id === "modpack") {
|
||||
await server.value.general?.reinstall(
|
||||
route.query.sid,
|
||||
await server.value.general.reinstall(
|
||||
server.value.serverId,
|
||||
false,
|
||||
project.project_id,
|
||||
version.id,
|
||||
@@ -504,7 +507,7 @@ async function serverInstall(project) {
|
||||
eraseDataOnInstall.value,
|
||||
);
|
||||
project.installed = true;
|
||||
navigateTo(`/servers/manage/${route.query.sid}/options/loader`);
|
||||
navigateTo(`/servers/manage/${server.value.serverId}/options/loader`);
|
||||
} else if (projectType.value.id === "mod") {
|
||||
await server.value.content.install("mod", version.project_id, version.id);
|
||||
await server.value.refresh(["content"]);
|
||||
|
||||
@@ -456,9 +456,10 @@
|
||||
Where are Modrinth Servers located? Can I choose a region?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Currently, Modrinth Servers are located in New York, Los Angeles, Seattle, and
|
||||
Miami. More regions are coming soon! Your server's location is currently chosen
|
||||
algorithmically, but you will be able to choose a region in the future.
|
||||
Currently, Modrinth Servers are located throughout the United States in New York,
|
||||
Los Angelas, Dallas, Miami, and Spokane. More regions are coming soon! Your server's
|
||||
location is currently chosen algorithmically, but you will be able to choose a
|
||||
region in the future.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -539,9 +540,9 @@
|
||||
<p
|
||||
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
|
||||
>
|
||||
With strategically placed servers in New York, Los Angeles, Seattle, and Miami, we
|
||||
ensure low latency connections for players across North America. Each location is
|
||||
equipped with high-performance hardware and DDoS protection.
|
||||
With strategically placed servers in New York, California, Texas, Florida, and
|
||||
Washington, we ensure low latency connections for players across North America.
|
||||
Each location is equipped with high-performance hardware and DDoS protection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -640,15 +641,15 @@
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="blue" size="large">
|
||||
<NuxtLink
|
||||
<a
|
||||
v-if="!loggedOut && isSmallAtCapacity"
|
||||
:to="outOfStockUrl"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-blue !font-medium !text-blue"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
|
||||
</NuxtLink>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-blue !font-medium !text-blue"
|
||||
@@ -703,15 +704,15 @@
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<NuxtLink
|
||||
<a
|
||||
v-if="!loggedOut && isMediumAtCapacity"
|
||||
:to="outOfStockUrl"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-green !font-medium !text-green"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
|
||||
</NuxtLink>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-green !font-medium !text-green"
|
||||
@@ -757,15 +758,15 @@
|
||||
</h2>
|
||||
</div>
|
||||
<ButtonStyled color="brand" size="large">
|
||||
<NuxtLink
|
||||
<a
|
||||
v-if="!loggedOut && isLargeAtCapacity"
|
||||
:to="outOfStockUrl"
|
||||
:href="outOfStockUrl"
|
||||
target="_blank"
|
||||
class="flex items-center gap-2 !bg-highlight-purple !font-medium !text-purple"
|
||||
>
|
||||
Out of Stock
|
||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
|
||||
</NuxtLink>
|
||||
</a>
|
||||
<button
|
||||
v-else
|
||||
class="!bg-highlight-purple !font-medium !text-purple"
|
||||
@@ -871,7 +872,7 @@ const deletingSpeed = 25;
|
||||
const pauseTime = 2000;
|
||||
|
||||
const loggedOut = computed(() => !auth.value.user);
|
||||
const outOfStockUrl = "https://support.modrinth.com";
|
||||
const outOfStockUrl = "https://discord.modrinth.com";
|
||||
|
||||
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
|
||||
try {
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Upgrading</h1>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
Your server's hardware is currently being upgraded and will be back online shortly!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,17 +47,18 @@
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<LockIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Suspended</h1>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
{{
|
||||
serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
serverData.suspension_reason === "cancelled"
|
||||
? "Your subscription has been cancelled."
|
||||
: serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
}}
|
||||
<br />
|
||||
This is most likely due to a billing issue. Please check your billing information and
|
||||
contact Modrinth support if you believe this is an error.
|
||||
Contact Modrinth support if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
||||
@@ -66,7 +67,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error && server.error.message.includes('Forbidden')"
|
||||
v-else-if="
|
||||
server.general?.error?.error.statusCode === 403 ||
|
||||
server.general?.error?.error.statusCode === 404
|
||||
"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
@@ -82,14 +86,15 @@
|
||||
this is an error, please contact Modrinth support.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
|
||||
<button class="mt-6 !w-full">Go back to all servers</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error && server.error.message.includes('Service Unavailable')"
|
||||
v-else-if="server.general?.error?.error.statusCode === 503"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
@@ -141,7 +146,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error"
|
||||
v-else-if="server.general?.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
@@ -164,7 +169,7 @@
|
||||
temporary network issue. You'll be reconnected automatically.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
@@ -228,7 +233,7 @@
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,7 +368,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtPage
|
||||
:route="route"
|
||||
:is-connected="isConnected"
|
||||
@@ -425,21 +429,25 @@ const createdAt = ref(
|
||||
const route = useNativeRoute();
|
||||
const router = useRouter();
|
||||
const serverId = route.params.id as string;
|
||||
const server = await usePyroServer(serverId, [
|
||||
"general",
|
||||
"content",
|
||||
"backups",
|
||||
"network",
|
||||
"startup",
|
||||
"ws",
|
||||
"fs",
|
||||
]);
|
||||
|
||||
const server = await usePyroServer(serverId, ["general", "ws"]);
|
||||
|
||||
const loadModulesPromise = Promise.resolve().then(() => {
|
||||
if (server.general?.status === "suspended") {
|
||||
return;
|
||||
}
|
||||
return server.loadModules(["content", "backups", "network", "startup", "fs"]);
|
||||
});
|
||||
|
||||
provide("modulesLoaded", loadModulesPromise);
|
||||
|
||||
watch(
|
||||
() => server.error,
|
||||
(newError) => {
|
||||
() => [server.general?.error, server.ws?.error],
|
||||
([generalError, wsError]) => {
|
||||
if (server.general?.status === "suspended") return;
|
||||
if (newError && !newError.message.includes("Forbidden")) {
|
||||
|
||||
const error = generalError?.error || wsError?.error;
|
||||
if (error && error.statusCode !== 403) {
|
||||
startPolling();
|
||||
}
|
||||
},
|
||||
@@ -450,11 +458,9 @@ const errorMessage = ref("An unexpected error occurred.");
|
||||
const errorLog = ref("");
|
||||
const errorLogFile = ref("");
|
||||
const serverData = computed(() => server.general);
|
||||
const error = ref<Error | null>(null);
|
||||
const isConnected = ref(false);
|
||||
const isWSAuthIncorrect = ref(false);
|
||||
const pyroConsole = usePyroConsole();
|
||||
console.log("||||||||||||||||||||||| console", pyroConsole.output);
|
||||
const cpuData = ref<number[]>([]);
|
||||
const ramData = ref<number[]>([]);
|
||||
const isActioning = ref(false);
|
||||
@@ -465,6 +471,7 @@ const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>();
|
||||
const uptimeSeconds = ref(0);
|
||||
const firstConnect = ref(true);
|
||||
const copied = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
const initialConsoleMessage = [
|
||||
" __________________________________________________",
|
||||
@@ -665,6 +672,26 @@ const newLoader = ref<string | null>(null);
|
||||
const newLoaderVersion = ref<string | null>(null);
|
||||
const newMCVersion = ref<string | null>(null);
|
||||
|
||||
const onReinstall = (potentialArgs: any) => {
|
||||
if (!serverData.value) return;
|
||||
|
||||
serverData.value.status = "installing";
|
||||
|
||||
if (potentialArgs?.loader) {
|
||||
newLoader.value = potentialArgs.loader;
|
||||
}
|
||||
if (potentialArgs?.lVersion) {
|
||||
newLoaderVersion.value = potentialArgs.lVersion;
|
||||
}
|
||||
if (potentialArgs?.mVersion) {
|
||||
newMCVersion.value = potentialArgs.mVersion;
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
errorTitle.value = "Error";
|
||||
errorMessage.value = "An unexpected error occurred.";
|
||||
};
|
||||
|
||||
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||
switch (data.result) {
|
||||
case "ok": {
|
||||
@@ -738,26 +765,6 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||
}
|
||||
};
|
||||
|
||||
const onReinstall = (potentialArgs: any) => {
|
||||
if (!serverData.value) return;
|
||||
|
||||
serverData.value.status = "installing";
|
||||
|
||||
if (potentialArgs?.loader) {
|
||||
newLoader.value = potentialArgs.loader;
|
||||
}
|
||||
if (potentialArgs?.lVersion) {
|
||||
newLoaderVersion.value = potentialArgs.lVersion;
|
||||
}
|
||||
if (potentialArgs?.mVersion) {
|
||||
newMCVersion.value = potentialArgs.mVersion;
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
errorTitle.value = "Error";
|
||||
errorMessage.value = "An unexpected error occurred.";
|
||||
};
|
||||
|
||||
const updateStats = (currentStats: Stats["current"]) => {
|
||||
isConnected.value = true;
|
||||
stats.value = {
|
||||
@@ -924,6 +931,10 @@ const cleanup = () => {
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
if (server.general?.status === "suspended") {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
if (server.error) {
|
||||
if (!server.error.message.includes("Forbidden")) {
|
||||
startPolling();
|
||||
@@ -991,7 +1002,7 @@ definePageMeta({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
@keyframes server-action-buttons-anim {
|
||||
0% {
|
||||
opacity: 0;
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div v-if="data" class="contents">
|
||||
<div
|
||||
v-if="server.backups?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's backups. Here's what went wrong:
|
||||
</p>
|
||||
<p>
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="contents">
|
||||
<LazyUiServersBackupCreateModal
|
||||
ref="createBackupModal"
|
||||
:server="server"
|
||||
@@ -241,6 +265,7 @@ import {
|
||||
BoxIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
@@ -297,33 +322,37 @@ const showbackupSettingsModal = () => {
|
||||
backupSettingsModal.value?.show();
|
||||
};
|
||||
|
||||
const handleBackupCreated = (payload: { success: boolean; message: string }) => {
|
||||
const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupRenamed = (payload: { success: boolean; message: string }) => {
|
||||
const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupRestored = (payload: { success: boolean; message: string }) => {
|
||||
const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupDeleted = (payload: { success: boolean; message: string }) => {
|
||||
const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
@@ -387,8 +416,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
if (hasOngoingBackups) {
|
||||
refreshInterval.value = setInterval(() => {
|
||||
props.server.refresh(["backups"]);
|
||||
refreshInterval.value = setInterval(async () => {
|
||||
await props.server.refresh(["backups"]);
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,7 +10,30 @@
|
||||
@change-version="changeModVersion($event)"
|
||||
/>
|
||||
|
||||
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
||||
<div
|
||||
v-if="server.content?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.content.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
||||
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
||||
<div class="relative flex h-full w-full flex-col">
|
||||
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
|
||||
@@ -322,6 +345,7 @@ import {
|
||||
WrenchIcon,
|
||||
ListIcon,
|
||||
FileIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
|
||||
@@ -189,6 +189,8 @@ const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -245,6 +247,8 @@ useHead({
|
||||
});
|
||||
|
||||
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
|
||||
await modulesLoaded;
|
||||
|
||||
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
|
||||
try {
|
||||
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
|
||||
@@ -719,7 +723,22 @@ const editFile = async (item: { name: string; type: string; path: string }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const initializeFileEdit = async () => {
|
||||
if (!route.query.editing || !props.server.fs) return;
|
||||
|
||||
const filePath = route.query.editing as string;
|
||||
await editFile({
|
||||
name: filePath.split("/").pop() || "",
|
||||
type: "file",
|
||||
path: filePath,
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await modulesLoaded;
|
||||
|
||||
await initializeFileEdit();
|
||||
|
||||
await import("ace-builds");
|
||||
await import("ace-builds/src-noconflict/mode-json");
|
||||
await import("ace-builds/src-noconflict/mode-yaml");
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isConnected && !isWsAuthIncorrect" />
|
||||
<UiServersOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
|
||||
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
|
||||
<h2>Could not connect to the server.</h2>
|
||||
<p>
|
||||
@@ -244,19 +244,31 @@ interface ErrorData {
|
||||
const inspectingError = ref<ErrorData | null>(null);
|
||||
|
||||
const inspectError = async () => {
|
||||
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
||||
// @ts-ignore
|
||||
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
content: log,
|
||||
}),
|
||||
})) as ErrorData;
|
||||
try {
|
||||
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
||||
if (!log) return;
|
||||
|
||||
inspectingError.value = analysis;
|
||||
// @ts-ignore
|
||||
const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
content: log,
|
||||
}),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
|
||||
inspectingError.value = response as ErrorData;
|
||||
} else {
|
||||
inspectingError.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to analyze logs:", error);
|
||||
inspectingError.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
@@ -266,7 +278,7 @@ const clearError = () => {
|
||||
watch(
|
||||
() => props.serverPowerState,
|
||||
(newVal) => {
|
||||
if (newVal === "crashed") {
|
||||
if (newVal === "crashed" && !props.powerStateDetails?.oom_killed) {
|
||||
inspectError();
|
||||
} else {
|
||||
clearError();
|
||||
@@ -274,7 +286,7 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
if (props.serverPowerState === "crashed") {
|
||||
if (props.serverPowerState === "crashed" && !props.powerStateDetails?.oom_killed) {
|
||||
inspectError();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="card flex flex-col gap-4">
|
||||
<label for="server-name-field" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-bold text-contrast">Server name</span>
|
||||
<span> Change your server's name. This name is only visible on Modrinth.</span>
|
||||
<span> This name is only visible on Modrinth.</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
@@ -64,10 +64,7 @@
|
||||
<div class="card flex flex-col gap-4">
|
||||
<label for="server-icon-field" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-bold text-contrast">Server icon</span>
|
||||
<span>
|
||||
Change your server's icon. Changes will be visible on the Minecraft server list and on
|
||||
Modrinth.
|
||||
</span>
|
||||
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<div
|
||||
@@ -91,20 +88,7 @@
|
||||
>
|
||||
<EditIcon class="h-8 w-8 text-contrast" />
|
||||
</div>
|
||||
<img
|
||||
v-if="icon"
|
||||
no-shadow
|
||||
alt="Server Icon"
|
||||
class="h-[6rem] w-[6rem] rounded-xl"
|
||||
:src="icon"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
no-shadow
|
||||
alt="Server Icon"
|
||||
class="h-[6rem] w-[6rem] rounded-xl"
|
||||
src="~/assets/images/servers/minecraft_server_icon.png"
|
||||
/>
|
||||
<UiServersServerIcon :image="icon" />
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
|
||||
@@ -234,67 +218,106 @@ const resetGeneral = () => {
|
||||
|
||||
const uploadFile = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
// down scale the image to 64x64
|
||||
if (!file) {
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "No file selected",
|
||||
text: "Please select a file to upload.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scaledFile = await new Promise<File>((resolve, reject) => {
|
||||
if (!file) {
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "No file selected",
|
||||
text: "Please select a file to upload.",
|
||||
});
|
||||
reject(new Error("No file selected"));
|
||||
return;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
ctx?.drawImage(img, 0, 0, 64, 64);
|
||||
// turn the downscaled image back to a png file
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const data = new File([blob], "server-icon.png", { type: "image/png" });
|
||||
resolve(data);
|
||||
resolve(new File([blob], "server-icon.png", { type: "image/png" }));
|
||||
} else {
|
||||
reject(new Error("Canvas toBlob failed"));
|
||||
}
|
||||
}, "image/png");
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
if (!file) return;
|
||||
if (data.value?.image) {
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||
}
|
||||
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
|
||||
await props.server.fs?.uploadFile("/server-icon-original.png", file);
|
||||
await props.server.refresh();
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server icon updated",
|
||||
text: "Your server icon was successfully changed.",
|
||||
});
|
||||
try {
|
||||
if (data.value?.image) {
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||
}
|
||||
|
||||
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
|
||||
await props.server.fs?.uploadFile("/server-icon-original.png", file);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => {
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
useState(`server-icon-${props.server.serverId}`).value = dataURL;
|
||||
if (data.value) data.value.image = dataURL;
|
||||
resolve();
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server icon updated",
|
||||
text: "Your server icon was successfully changed.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error uploading icon:", error);
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "Upload failed",
|
||||
text: "Failed to upload server icon.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetIcon = async () => {
|
||||
if (data.value?.image) {
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await reloadNuxtApp();
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server icon reset",
|
||||
text: "Your server icon was successfully reset.",
|
||||
});
|
||||
try {
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||
|
||||
useState(`server-icon-${props.server.serverId}`).value = undefined;
|
||||
if (data.value) data.value.image = undefined;
|
||||
|
||||
await props.server.refresh(["general"]);
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server icon reset",
|
||||
text: "Your server icon was successfully reset.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error resetting icon:", error);
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "Reset failed",
|
||||
text: "Failed to reset server icon.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -59,7 +59,29 @@
|
||||
/>
|
||||
|
||||
<div class="relative h-full w-full overflow-y-auto">
|
||||
<div v-if="data" class="flex h-full w-full flex-col justify-between gap-4">
|
||||
<div
|
||||
v-if="server.network?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's network settings. Here's what we know:
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.network.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Subdomain section -->
|
||||
<div class="card flex flex-col gap-4">
|
||||
@@ -155,7 +177,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="standard" color="brand" @click="showNewAllocationModal">
|
||||
<ButtonStyled type="standard" @click="showNewAllocationModal">
|
||||
<button class="!w-full sm:!w-auto">
|
||||
<PlusIcon />
|
||||
<span>New allocation</span>
|
||||
@@ -247,6 +269,7 @@ import {
|
||||
SaveIcon,
|
||||
InfoIcon,
|
||||
UploadIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
@@ -286,12 +309,11 @@ const addNewAllocation = async () => {
|
||||
|
||||
try {
|
||||
await props.server.network?.reserveAllocation(newAllocationName.value);
|
||||
await props.server.refresh(["network"]);
|
||||
|
||||
newAllocationModal.value?.hide();
|
||||
newAllocationName.value = "";
|
||||
|
||||
await props.server.refresh();
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
@@ -332,8 +354,8 @@ const confirmDeleteAllocation = async () => {
|
||||
if (allocationToDelete.value === null) return;
|
||||
|
||||
await props.server.network?.deleteAllocation(allocationToDelete.value);
|
||||
await props.server.refresh(["network"]);
|
||||
|
||||
await props.server.refresh();
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
@@ -349,12 +371,11 @@ const editAllocation = async () => {
|
||||
|
||||
try {
|
||||
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
|
||||
await props.server.refresh(["network"]);
|
||||
|
||||
editAllocationModal.value?.hide();
|
||||
newAllocationName.value = "";
|
||||
|
||||
await props.server.refresh();
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
|
||||
@@ -1,7 +1,27 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full select-none overflow-y-auto">
|
||||
<div v-if="server.fs?.error" class="flex w-full flex-col items-center justify-center gap-4 p-4">
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load properties</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't access your server's properties. Here's what we know:
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.fs.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="propsData && status === 'success'"
|
||||
v-else-if="propsData && status === 'success'"
|
||||
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
|
||||
>
|
||||
<div class="card flex flex-col gap-4">
|
||||
@@ -118,8 +138,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { EyeIcon, SearchIcon } from "@modrinth/assets";
|
||||
import { ref, watch, computed, inject } from "vue";
|
||||
import { EyeIcon, SearchIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import Fuse from "fuse.js";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
@@ -134,7 +154,9 @@ const isUpdating = ref(false);
|
||||
const searchInput = ref("");
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
|
||||
const { data: propsData, status } = await useAsyncData("ServerProperties", async () => {
|
||||
await modulesLoaded;
|
||||
const rawProps = await props.server.fs?.downloadFile("server.properties");
|
||||
if (!rawProps) return null;
|
||||
|
||||
|
||||
@@ -1,7 +1,33 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<div v-if="data" class="flex h-full w-full flex-col gap-4">
|
||||
<div class="rounded-2xl border-solid border-orange bg-bg-orange p-4 text-contrast">
|
||||
<div
|
||||
v-if="server.startup?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load startup settings</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's startup settings. Here's what we know:
|
||||
</p>
|
||||
<p>
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.startup.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['startup'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="flex h-full w-full flex-col gap-4">
|
||||
<div
|
||||
class="rounded-2xl border-[1px] border-solid border-orange bg-bg-orange p-4 text-contrast"
|
||||
>
|
||||
These settings are for advanced users. Changing them can break your server.
|
||||
</div>
|
||||
|
||||
@@ -84,7 +110,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UpdatedIcon } from "@modrinth/assets";
|
||||
import { UpdatedIcon, IssuesIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
@@ -109,13 +135,41 @@ const jdkBuildMap = [
|
||||
{ value: "graal", label: "GraalVM" },
|
||||
];
|
||||
|
||||
const invocation = ref(startupSettings.value?.invocation);
|
||||
const jdkVersion = ref(
|
||||
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "",
|
||||
const invocation = ref("");
|
||||
const jdkVersion = ref("");
|
||||
const jdkBuild = ref("");
|
||||
|
||||
const originalInvocation = ref("");
|
||||
const originalJdkVersion = ref("");
|
||||
const originalJdkBuild = ref("");
|
||||
|
||||
watch(
|
||||
startupSettings,
|
||||
(newSettings) => {
|
||||
if (newSettings) {
|
||||
invocation.value = newSettings.invocation;
|
||||
originalInvocation.value = newSettings.invocation;
|
||||
|
||||
const jdkVersionLabel =
|
||||
jdkVersionMap.find((v) => v.value === newSettings.jdk_version)?.label || "";
|
||||
jdkVersion.value = jdkVersionLabel;
|
||||
originalJdkVersion.value = jdkVersionLabel;
|
||||
|
||||
const jdkBuildLabel = jdkBuildMap.find((v) => v.value === newSettings.jdk_build)?.label || "";
|
||||
jdkBuild.value = jdkBuildLabel;
|
||||
originalJdkBuild.value = jdkBuildLabel;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const jdkBuild = ref(
|
||||
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "",
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
invocation.value !== originalInvocation.value ||
|
||||
jdkVersion.value !== originalJdkVersion.value ||
|
||||
jdkBuild.value !== originalJdkBuild.value,
|
||||
);
|
||||
|
||||
const isUpdating = ref(false);
|
||||
|
||||
const compatibleJavaVersions = computed(() => {
|
||||
@@ -139,15 +193,6 @@ const displayedJavaVersions = computed(() => {
|
||||
return showAllVersions.value ? jdkVersionMap.map((v) => v.label) : compatibleJavaVersions.value;
|
||||
});
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
invocation.value !== startupSettings.value?.invocation ||
|
||||
jdkVersion.value !==
|
||||
(jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "") ||
|
||||
jdkBuild.value !==
|
||||
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build || "")?.label,
|
||||
);
|
||||
|
||||
const saveStartup = async () => {
|
||||
try {
|
||||
isUpdating.value = true;
|
||||
@@ -155,14 +200,25 @@ const saveStartup = async () => {
|
||||
const jdkVersionKey = jdkVersionMap.find((v) => v.label === jdkVersion.value)?.value;
|
||||
const jdkBuildKey = jdkBuildMap.find((v) => v.label === jdkBuild.value)?.value;
|
||||
await props.server.startup?.update(invocationValue, jdkVersionKey as any, jdkBuildKey as any);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
await props.server.refresh(["startup"]);
|
||||
|
||||
if (props.server.startup) {
|
||||
invocation.value = props.server.startup.invocation;
|
||||
jdkVersion.value =
|
||||
jdkVersionMap.find((v) => v.value === props.server.startup?.jdk_version)?.label || "";
|
||||
jdkBuild.value =
|
||||
jdkBuildMap.find((v) => v.value === props.server.startup?.jdk_build)?.label || "";
|
||||
}
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server settings updated",
|
||||
text: "Your server settings were successfully changed.",
|
||||
});
|
||||
await props.server.refresh();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
addNotification({
|
||||
@@ -177,15 +233,13 @@ const saveStartup = async () => {
|
||||
};
|
||||
|
||||
const resetStartup = () => {
|
||||
invocation.value = startupSettings.value?.invocation;
|
||||
jdkVersion.value =
|
||||
jdkVersionMap.find((v) => v.value === startupSettings.value?.jdk_version)?.label || "";
|
||||
jdkBuild.value =
|
||||
jdkBuildMap.find((v) => v.value === startupSettings.value?.jdk_build)?.label || "";
|
||||
invocation.value = originalInvocation.value;
|
||||
jdkVersion.value = originalJdkVersion.value;
|
||||
jdkBuild.value = originalJdkBuild.value;
|
||||
};
|
||||
|
||||
const resetToDefault = () => {
|
||||
invocation.value = startupSettings.value?.original_invocation;
|
||||
invocation.value = startupSettings.value?.original_invocation ?? "";
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<section class="universal-card">
|
||||
<section class="universal-card experimental-styles-within">
|
||||
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
|
||||
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
|
||||
<div class="universal-card recessed">
|
||||
@@ -88,67 +88,69 @@
|
||||
v-if="midasCharge && midasCharge.status === 'failed'"
|
||||
class="ml-auto flex flex-row-reverse items-center gap-2"
|
||||
>
|
||||
<ButtonStyled v-if="midasCharge && midasCharge.status === 'failed'">
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
$refs.midasPurchaseModal.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
Update method
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:dropdown-id="`${baseId}-cancel-midas`"
|
||||
:options="[
|
||||
{
|
||||
id: 'cancel',
|
||||
action: () => {
|
||||
cancelSubscriptionId = midasSubscription.id;
|
||||
$refs.modalCancel.show();
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #cancel><XIcon /> Cancel</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ButtonStyled v-else-if="midasCharge && midasCharge.status !== 'cancelled'">
|
||||
<button
|
||||
v-if="midasCharge && midasCharge.status === 'failed'"
|
||||
class="iconified-button raised-button"
|
||||
class="ml-auto"
|
||||
@click="
|
||||
() => {
|
||||
purchaseModalStep = 0;
|
||||
$refs.purchaseModal.show();
|
||||
cancelSubscriptionId = midasSubscription.id;
|
||||
$refs.modalCancel.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
<UpdatedIcon />
|
||||
Update method
|
||||
<XIcon /> Cancel
|
||||
</button>
|
||||
<OverflowMenu
|
||||
class="btn icon-only transparent"
|
||||
:options="[
|
||||
{
|
||||
id: 'cancel',
|
||||
action: () => {
|
||||
cancelSubscriptionId = midasSubscription.id;
|
||||
$refs.modalCancel.show();
|
||||
},
|
||||
},
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #cancel><XIcon /> Cancel</template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
<button
|
||||
v-else-if="midasCharge && midasCharge.status !== 'cancelled'"
|
||||
class="iconified-button raised-button !ml-auto"
|
||||
@click="
|
||||
() => {
|
||||
cancelSubscriptionId = midasSubscription.id;
|
||||
$refs.modalCancel.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon /> Cancel
|
||||
</button>
|
||||
<button
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="midasCharge && midasCharge.status === 'cancelled'"
|
||||
class="btn btn-purple btn-large ml-auto"
|
||||
@click="cancelSubscription(midasSubscription.id, false)"
|
||||
color="purple"
|
||||
>
|
||||
<RightArrowIcon /> Resubscribe
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="btn btn-purple btn-large ml-auto"
|
||||
@click="
|
||||
() => {
|
||||
purchaseModalStep = 0;
|
||||
$refs.purchaseModal.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
Subscribe
|
||||
</button>
|
||||
<button class="ml-auto" @click="cancelSubscription(midasSubscription.id, false)">
|
||||
Resubscribe <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="purple" size="large">
|
||||
<button
|
||||
class="ml-auto"
|
||||
@click="
|
||||
() => {
|
||||
$refs.midasPurchaseModal.show();
|
||||
}
|
||||
"
|
||||
>
|
||||
Subscribe <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -282,25 +284,37 @@
|
||||
getPyroCharge(subscription).status !== 'cancelled' &&
|
||||
getPyroCharge(subscription).status !== 'failed'
|
||||
"
|
||||
type="standard"
|
||||
@click="showPyroCancelModal(subscription.id)"
|
||||
>
|
||||
<button class="text-contrast">
|
||||
<button @click="showPyroCancelModal(subscription.id)">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="
|
||||
getPyroCharge(subscription) &&
|
||||
getPyroCharge(subscription).status !== 'cancelled' &&
|
||||
getPyroCharge(subscription).status !== 'failed'
|
||||
"
|
||||
color="green"
|
||||
color-fill="text"
|
||||
>
|
||||
<button @click="showPyroUpgradeModal(subscription)">
|
||||
<ArrowBigUpDashIcon />
|
||||
Upgrade
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="
|
||||
getPyroCharge(subscription) &&
|
||||
(getPyroCharge(subscription).status === 'cancelled' ||
|
||||
getPyroCharge(subscription).status === 'failed')
|
||||
"
|
||||
type="standard"
|
||||
color="green"
|
||||
@click="resubscribePyro(subscription.id)"
|
||||
>
|
||||
<button class="text-contrast">Resubscribe</button>
|
||||
<button @click="resubscribePyro(subscription.id)">
|
||||
Resubscribe <RightArrowIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
@@ -312,7 +326,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="universal-card">
|
||||
<section class="universal-card experimental-styles-within">
|
||||
<ConfirmModal
|
||||
ref="modal_confirm"
|
||||
:title="formatMessage(deleteModalMessages.title)"
|
||||
@@ -321,7 +335,7 @@
|
||||
@proceed="removePaymentMethod(removePaymentMethodIndex)"
|
||||
/>
|
||||
<PurchaseModal
|
||||
ref="purchaseModal"
|
||||
ref="midasPurchaseModal"
|
||||
:product="midasProduct"
|
||||
:country="country"
|
||||
:publishable-key="config.public.stripePublishableKey"
|
||||
@@ -342,6 +356,38 @@
|
||||
:payment-methods="paymentMethods"
|
||||
:return-url="`${config.public.siteUrl}/settings/billing`"
|
||||
/>
|
||||
<PurchaseModal
|
||||
ref="pyroPurchaseModal"
|
||||
:product="upgradeProducts"
|
||||
:country="country"
|
||||
custom-server
|
||||
:existing-subscription="currentSubscription"
|
||||
:existing-plan="currentProduct"
|
||||
:publishable-key="config.public.stripePublishableKey"
|
||||
:send-billing-request="
|
||||
async (body) =>
|
||||
await useBaseFetch(`billing/subscription/${currentSubscription.id}`, {
|
||||
internal: true,
|
||||
method: `PATCH`,
|
||||
body: body,
|
||||
})
|
||||
"
|
||||
:renewal-date="currentSubRenewalDate"
|
||||
:on-error="
|
||||
(err) =>
|
||||
data.$notify({
|
||||
group: 'main',
|
||||
title: 'An error occurred',
|
||||
type: 'error',
|
||||
text: err.message ?? (err.data ? err.data.description : err),
|
||||
})
|
||||
"
|
||||
:fetch-capacity-statuses="fetchCapacityStatuses"
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods"
|
||||
:return-url="`${config.public.siteUrl}/servers/manage`"
|
||||
:server-name="`${auth?.user?.username}'s server`"
|
||||
/>
|
||||
<NewModal ref="addPaymentMethodModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
@@ -359,15 +405,19 @@
|
||||
<div id="address-element"></div>
|
||||
<div id="payment-element" class="mt-4"></div>
|
||||
</div>
|
||||
<div v-show="loadingPaymentMethodModal === 2" class="input-group push-right mt-auto pt-4">
|
||||
<button class="btn" @click="$refs.addPaymentMethodModal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
<button class="btn btn-primary" :disabled="loadingAddMethod" @click="submit">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.paymentMethodAdd) }}
|
||||
</button>
|
||||
<div v-show="loadingPaymentMethodModal === 2" class="input-group mt-auto pt-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="loadingAddMethod" @click="submit">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.paymentMethodAdd) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="$refs.addPaymentMethodModal.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
@@ -442,6 +492,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<OverflowMenu
|
||||
:dropdown-id="`${baseId}-payment-method-overflow-${index}`"
|
||||
class="btn icon-only transparent"
|
||||
:options="
|
||||
[
|
||||
@@ -493,6 +544,7 @@ import {
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
PlusIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
XIcon,
|
||||
CardIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -515,6 +567,10 @@ definePageMeta({
|
||||
middleware: "auth",
|
||||
});
|
||||
|
||||
const app = useNuxtApp();
|
||||
const auth = await useAuth();
|
||||
const baseId = useId();
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
@@ -704,7 +760,7 @@ const pyroSubscriptions = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
const purchaseModal = ref();
|
||||
const midasPurchaseModal = ref();
|
||||
const country = useUserCountry();
|
||||
const price = computed(() =>
|
||||
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
|
||||
@@ -896,6 +952,78 @@ const showPyroCancelModal = (subscriptionId) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pyroPurchaseModal = ref();
|
||||
const currentSubscription = ref(null);
|
||||
const currentProduct = ref(null);
|
||||
const upgradeProducts = ref([]);
|
||||
upgradeProducts.value.metadata = { type: "pyro" };
|
||||
|
||||
const currentSubRenewalDate = ref();
|
||||
|
||||
const showPyroUpgradeModal = async (subscription) => {
|
||||
currentSubscription.value = subscription;
|
||||
currentSubRenewalDate.value = getPyroCharge(subscription).due;
|
||||
currentProduct.value = getPyroProduct(subscription);
|
||||
upgradeProducts.value = products.filter(
|
||||
(p) =>
|
||||
p.metadata.type === "pyro" &&
|
||||
(!currentProduct.value || p.metadata.ram > currentProduct.value.metadata.ram),
|
||||
);
|
||||
upgradeProducts.value.metadata = { type: "pyro" };
|
||||
|
||||
await nextTick();
|
||||
|
||||
if (!currentProduct.value) {
|
||||
console.error("Could not find product for current subscription");
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: "Could not find product for current subscription",
|
||||
type: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pyroPurchaseModal.value) {
|
||||
console.error("pyroPurchaseModal ref is undefined");
|
||||
return;
|
||||
}
|
||||
|
||||
pyroPurchaseModal.value.show();
|
||||
};
|
||||
|
||||
async function fetchCapacityStatuses(serverId, product) {
|
||||
if (product) {
|
||||
try {
|
||||
return {
|
||||
custom: await usePyroFetch(`servers/${serverId}/upgrade-stock`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
cpu: product.metadata.cpu,
|
||||
memory_mb: product.metadata.ram,
|
||||
swap_mb: product.metadata.swap,
|
||||
storage_mb: product.metadata.storage,
|
||||
},
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error checking server capacities:", error);
|
||||
app.$notify({
|
||||
group: "main",
|
||||
title: "Error checking server capacities",
|
||||
text: error,
|
||||
type: "error",
|
||||
});
|
||||
return {
|
||||
custom: { available: 0 },
|
||||
small: { available: 0 },
|
||||
medium: { available: 0 },
|
||||
large: { available: 0 },
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const resubscribePyro = async (subscriptionId) => {
|
||||
try {
|
||||
await useBaseFetch(`billing/subscription/${subscriptionId}`, {
|
||||
|
||||
@@ -223,7 +223,9 @@
|
||||
</div>
|
||||
<div v-if="['collections'].includes(route.params.projectType)" class="collections-grid">
|
||||
<nuxt-link
|
||||
v-for="collection in collections"
|
||||
v-for="collection in collections.sort(
|
||||
(a, b) => new Date(b.created) - new Date(a.created),
|
||||
)"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="card collection-item"
|
||||
@@ -242,7 +244,12 @@
|
||||
{{ collection.description }}
|
||||
</div>
|
||||
<div class="stat-bar">
|
||||
<div class="stats"><BoxIcon /> {{ collection.projects?.length || 0 }} projects</div>
|
||||
<div class="stats">
|
||||
<BoxIcon />
|
||||
{{
|
||||
`${$formatNumber(collection.projects?.length || 0, false)} project${(collection.projects?.length || 0) !== 1 ? "s" : ""}`
|
||||
}}
|
||||
</div>
|
||||
<div class="stats">
|
||||
<template v-if="collection.status === 'listed'">
|
||||
<WorldIcon />
|
||||
@@ -638,12 +645,13 @@ export default defineNuxtComponent({
|
||||
grid-template-columns: repeat(1, 1fr);
|
||||
}
|
||||
|
||||
gap: var(--gap-lg);
|
||||
gap: var(--gap-md);
|
||||
|
||||
.collection-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-md);
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
|
||||
.description {
|
||||
@@ -692,7 +700,7 @@ export default defineNuxtComponent({
|
||||
|
||||
.title {
|
||||
color: var(--color-contrast);
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
font-size: var(--font-size-lg);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import dayjs from "dayjs";
|
||||
import quarterOfYear from "dayjs/plugin/quarterOfYear";
|
||||
import advanced from "dayjs/plugin/advancedFormat";
|
||||
import relativeTime from "dayjs/plugin/relativeTime";
|
||||
|
||||
dayjs.extend(quarterOfYear);
|
||||
dayjs.extend(advanced);
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
export default defineNuxtPlugin(() => {
|
||||
return {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
28
apps/labrinth/.sqlx/query-58fbda9daed27c5c466849a944ab3df193679cef17e8588ac3be79978b60bdab.json
generated
Normal file
28
apps/labrinth/.sqlx/query-58fbda9daed27c5c466849a944ab3df193679cef17e8588ac3be79978b60bdab.json
generated
Normal 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"
|
||||
}
|
||||
22
apps/labrinth/.sqlx/query-68619337ef34b588af21a40e5a60b54ce3a1dad45fb50bbc24a3ea34d2506578.json
generated
Normal file
22
apps/labrinth/.sqlx/query-68619337ef34b588af21a40e5a60b54ce3a1dad45fb50bbc24a3ea34d2506578.json
generated
Normal 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"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net\n ",
|
||||
"query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval, payment_platform, payment_platform_id, parent_charge_id, net)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval,\n payment_platform = EXCLUDED.payment_platform,\n payment_platform_id = EXCLUDED.payment_platform_id,\n parent_charge_id = EXCLUDED.parent_charge_id,\n net = EXCLUDED.net,\n price_id = EXCLUDED.price_id,\n amount = EXCLUDED.amount,\n currency_code = EXCLUDED.currency_code,\n charge_type = EXCLUDED.charge_type\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
@@ -24,5 +24,5 @@
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "933606e1ee3cd9a33e57eaf507ee8b7f966e8d3de5aaafadfe7ae30c12c925d2"
|
||||
"hash": "693194307c5c557b4e1e45d6b259057c8fa7b988e29261e29f5e66507dd96e59"
|
||||
}
|
||||
@@ -115,7 +115,11 @@ impl ChargeItem {
|
||||
payment_platform = EXCLUDED.payment_platform,
|
||||
payment_platform_id = EXCLUDED.payment_platform_id,
|
||||
parent_charge_id = EXCLUDED.parent_charge_id,
|
||||
net = EXCLUDED.net
|
||||
net = EXCLUDED.net,
|
||||
price_id = EXCLUDED.price_id,
|
||||
amount = EXCLUDED.amount,
|
||||
currency_code = EXCLUDED.currency_code,
|
||||
charge_type = EXCLUDED.charge_type
|
||||
"#,
|
||||
self.id.0,
|
||||
self.user_id.0,
|
||||
|
||||
@@ -176,7 +176,7 @@ pub struct Charge {
|
||||
pub net: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[derive(Serialize, Deserialize, Debug, Copy, Clone, PartialEq, Eq)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ChargeType {
|
||||
OneTime,
|
||||
|
||||
@@ -411,11 +411,7 @@ pub async fn edit_subscription(
|
||||
}
|
||||
|
||||
let interval = open_charge.due - Utc::now();
|
||||
let duration = PriceDuration::iterator()
|
||||
.min_by_key(|x| {
|
||||
(x.duration().num_seconds() - interval.num_seconds()).abs()
|
||||
})
|
||||
.unwrap_or(PriceDuration::Monthly);
|
||||
let duration = PriceDuration::Monthly;
|
||||
|
||||
let current_amount = match ¤t_price.prices {
|
||||
Price::OneTime { price } => *price,
|
||||
@@ -461,23 +457,6 @@ pub async fn edit_subscription(
|
||||
}
|
||||
|
||||
let charge_id = generate_charge_id(&mut transaction).await?;
|
||||
let mut charge = ChargeItem {
|
||||
id: charge_id,
|
||||
user_id: user.id.into(),
|
||||
price_id: product_price.id,
|
||||
amount: proration as i64,
|
||||
currency_code: current_price.currency_code.clone(),
|
||||
status: ChargeStatus::Processing,
|
||||
due: Utc::now(),
|
||||
last_attempt: None,
|
||||
type_: ChargeType::Proration,
|
||||
subscription_id: Some(subscription.id),
|
||||
subscription_interval: Some(duration),
|
||||
payment_platform: PaymentPlatform::Stripe,
|
||||
payment_platform_id: None,
|
||||
parent_charge_id: None,
|
||||
net: None,
|
||||
};
|
||||
|
||||
let customer_id = get_or_create_customer(
|
||||
user.id,
|
||||
@@ -504,6 +483,30 @@ pub async fn edit_subscription(
|
||||
"modrinth_user_id".to_string(),
|
||||
to_base62(user.id.0),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_charge_id".to_string(),
|
||||
to_base62(charge_id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_subscription_id".to_string(),
|
||||
to_base62(subscription.id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_price_id".to_string(),
|
||||
to_base62(product_price.id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_subscription_interval".to_string(),
|
||||
open_charge
|
||||
.subscription_interval
|
||||
.unwrap_or(PriceDuration::Monthly)
|
||||
.as_str()
|
||||
.to_string(),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_charge_type".to_string(),
|
||||
ChargeType::Proration.as_str().to_string(),
|
||||
);
|
||||
|
||||
intent.customer = Some(customer_id);
|
||||
intent.metadata = Some(metadata);
|
||||
@@ -529,9 +532,6 @@ pub async fn edit_subscription(
|
||||
stripe::PaymentIntent::create(&stripe_client, intent)
|
||||
.await?;
|
||||
|
||||
charge.payment_platform_id = Some(intent.id.to_string());
|
||||
charge.upsert(&mut transaction).await?;
|
||||
|
||||
Some((proration, 0, intent))
|
||||
}
|
||||
} else {
|
||||
@@ -938,6 +938,7 @@ pub async fn active_servers(
|
||||
struct ActiveServer {
|
||||
pub user_id: crate::models::ids::UserId,
|
||||
pub server_id: String,
|
||||
pub price_id: crate::models::ids::ProductPriceId,
|
||||
pub interval: PriceDuration,
|
||||
}
|
||||
|
||||
@@ -948,6 +949,7 @@ pub async fn active_servers(
|
||||
SubscriptionMetadata::Pyro { id } => ActiveServer {
|
||||
user_id: x.user_id.into(),
|
||||
server_id: id.clone(),
|
||||
price_id: x.price_id.into(),
|
||||
interval: x.interval,
|
||||
},
|
||||
})
|
||||
@@ -1139,7 +1141,7 @@ pub async fn initiate_payment(
|
||||
let country = user_country.as_deref().unwrap_or("US");
|
||||
let recommended_currency_code = infer_currency_code(country);
|
||||
|
||||
let (price, currency_code, interval, price_id, charge_id) =
|
||||
let (price, currency_code, interval, price_id, charge_id, charge_type) =
|
||||
match payment_request.charge {
|
||||
ChargeRequestType::Existing { id } => {
|
||||
let charge =
|
||||
@@ -1160,6 +1162,7 @@ pub async fn initiate_payment(
|
||||
charge.subscription_interval,
|
||||
charge.price_id,
|
||||
Some(id),
|
||||
charge.type_,
|
||||
)
|
||||
}
|
||||
ChargeRequestType::New {
|
||||
@@ -1256,6 +1259,11 @@ pub async fn initiate_payment(
|
||||
interval,
|
||||
price_item.id,
|
||||
None,
|
||||
if let Price::Recurring { .. } = price_item.prices {
|
||||
ChargeType::Subscription
|
||||
} else {
|
||||
ChargeType::OneTime
|
||||
},
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -1314,6 +1322,11 @@ pub async fn initiate_payment(
|
||||
);
|
||||
}
|
||||
|
||||
metadata.insert(
|
||||
"modrinth_charge_type".to_string(),
|
||||
charge_type.as_str().to_string(),
|
||||
);
|
||||
|
||||
if let Some(charge_id) = charge_id {
|
||||
metadata.insert(
|
||||
"modrinth_charge_id".to_string(),
|
||||
@@ -1399,10 +1412,15 @@ pub async fn stripe_webhook(
|
||||
pub user_subscription_item:
|
||||
Option<user_subscription_item::UserSubscriptionItem>,
|
||||
pub payment_metadata: Option<PaymentRequestMetadata>,
|
||||
#[allow(dead_code)]
|
||||
pub charge_type: ChargeType,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn get_payment_intent_metadata(
|
||||
payment_intent_id: PaymentIntentId,
|
||||
amount: i64,
|
||||
currency: String,
|
||||
metadata: HashMap<String, String>,
|
||||
pool: &PgPool,
|
||||
redis: &RedisPool,
|
||||
@@ -1445,6 +1463,15 @@ pub async fn stripe_webhook(
|
||||
break 'metadata;
|
||||
};
|
||||
|
||||
let charge_type = if let Some(charge_type) = metadata
|
||||
.get("modrinth_charge_type")
|
||||
.map(|x| ChargeType::from_string(x))
|
||||
{
|
||||
charge_type
|
||||
} else {
|
||||
break 'metadata;
|
||||
};
|
||||
|
||||
let (charge, price, product, subscription) = if let Some(
|
||||
mut charge,
|
||||
) =
|
||||
@@ -1549,8 +1576,8 @@ pub async fn stripe_webhook(
|
||||
break 'metadata;
|
||||
};
|
||||
|
||||
let (amount, subscription) = match &price.prices {
|
||||
Price::OneTime { price } => (*price, None),
|
||||
let subscription = match &price.prices {
|
||||
Price::OneTime { .. } => None,
|
||||
Price::Recurring { intervals } => {
|
||||
let interval = if let Some(interval) = metadata
|
||||
.get("modrinth_subscription_interval")
|
||||
@@ -1561,7 +1588,7 @@ pub async fn stripe_webhook(
|
||||
break 'metadata;
|
||||
};
|
||||
|
||||
if let Some(price) = intervals.get(&interval) {
|
||||
if intervals.get(&interval).is_some() {
|
||||
let subscription_id = if let Some(subscription_id) = metadata
|
||||
.get("modrinth_subscription_id")
|
||||
.and_then(|x| parse_base62(x).ok())
|
||||
@@ -1573,21 +1600,29 @@ pub async fn stripe_webhook(
|
||||
break 'metadata;
|
||||
};
|
||||
|
||||
let subscription = user_subscription_item::UserSubscriptionItem {
|
||||
id: subscription_id,
|
||||
user_id,
|
||||
price_id,
|
||||
interval,
|
||||
created: Utc::now(),
|
||||
status: SubscriptionStatus::Unprovisioned,
|
||||
metadata: None,
|
||||
let subscription = if let Some(mut subscription) = user_subscription_item::UserSubscriptionItem::get(subscription_id, pool).await? {
|
||||
subscription.status = SubscriptionStatus::Unprovisioned;
|
||||
subscription.price_id = price_id;
|
||||
subscription.interval = interval;
|
||||
|
||||
subscription
|
||||
} else {
|
||||
user_subscription_item::UserSubscriptionItem {
|
||||
id: subscription_id,
|
||||
user_id,
|
||||
price_id,
|
||||
interval,
|
||||
created: Utc::now(),
|
||||
status: SubscriptionStatus::Unprovisioned,
|
||||
metadata: None,
|
||||
}
|
||||
};
|
||||
|
||||
if charge_status != ChargeStatus::Failed {
|
||||
subscription.upsert(transaction).await?;
|
||||
}
|
||||
|
||||
(*price, Some(subscription))
|
||||
Some(subscription)
|
||||
} else {
|
||||
break 'metadata;
|
||||
}
|
||||
@@ -1598,16 +1633,12 @@ pub async fn stripe_webhook(
|
||||
id: charge_id,
|
||||
user_id,
|
||||
price_id,
|
||||
amount: amount as i64,
|
||||
currency_code: price.currency_code.clone(),
|
||||
amount,
|
||||
currency_code: currency,
|
||||
status: charge_status,
|
||||
due: Utc::now(),
|
||||
last_attempt: Some(Utc::now()),
|
||||
type_: if subscription.is_some() {
|
||||
ChargeType::Subscription
|
||||
} else {
|
||||
ChargeType::OneTime
|
||||
},
|
||||
type_: charge_type,
|
||||
subscription_id: subscription.as_ref().map(|x| x.id),
|
||||
subscription_interval: subscription
|
||||
.as_ref()
|
||||
@@ -1634,6 +1665,7 @@ pub async fn stripe_webhook(
|
||||
charge_item: charge,
|
||||
user_subscription_item: subscription,
|
||||
payment_metadata,
|
||||
charge_type,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1651,6 +1683,8 @@ pub async fn stripe_webhook(
|
||||
|
||||
let mut metadata = get_payment_intent_metadata(
|
||||
payment_intent.id,
|
||||
payment_intent.amount,
|
||||
payment_intent.currency.to_string().to_uppercase(),
|
||||
payment_intent.metadata,
|
||||
&pool,
|
||||
&redis,
|
||||
@@ -1899,6 +1933,8 @@ pub async fn stripe_webhook(
|
||||
let mut transaction = pool.begin().await?;
|
||||
get_payment_intent_metadata(
|
||||
payment_intent.id,
|
||||
payment_intent.amount,
|
||||
payment_intent.currency.to_string().to_uppercase(),
|
||||
payment_intent.metadata,
|
||||
&pool,
|
||||
&redis,
|
||||
@@ -1917,6 +1953,8 @@ pub async fn stripe_webhook(
|
||||
|
||||
let metadata = get_payment_intent_metadata(
|
||||
payment_intent.id,
|
||||
payment_intent.amount,
|
||||
payment_intent.currency.to_string().to_uppercase(),
|
||||
payment_intent.metadata,
|
||||
&pool,
|
||||
&redis,
|
||||
@@ -2320,6 +2358,10 @@ pub async fn task(
|
||||
"modrinth_charge_id".to_string(),
|
||||
to_base62(charge.id.0 as u64),
|
||||
);
|
||||
metadata.insert(
|
||||
"modrinth_charge_type".to_string(),
|
||||
charge.type_.as_str().to_string(),
|
||||
);
|
||||
|
||||
intent.metadata = Some(metadata);
|
||||
intent.customer = Some(customer.id);
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::queue::payouts::{make_aditude_request, PayoutsQueue};
|
||||
use crate::queue::session::AuthQueue;
|
||||
use crate::routes::ApiError;
|
||||
use actix_web::{delete, get, post, web, HttpRequest, HttpResponse};
|
||||
use chrono::{Datelike, Duration, TimeZone, Utc, Weekday};
|
||||
use chrono::{DateTime, Datelike, Duration, TimeZone, Utc, Weekday};
|
||||
use hex::ToHex;
|
||||
use hmac::{Hmac, Mac, NewMac};
|
||||
use reqwest::Method;
|
||||
@@ -763,6 +763,7 @@ pub async fn payment_methods(
|
||||
pub struct UserBalance {
|
||||
pub available: Decimal,
|
||||
pub pending: Decimal,
|
||||
pub dates: HashMap<DateTime<Utc>, Decimal>,
|
||||
}
|
||||
|
||||
#[get("balance")]
|
||||
@@ -791,27 +792,27 @@ async fn get_user_balance(
|
||||
user_id: crate::database::models::ids::UserId,
|
||||
pool: &PgPool,
|
||||
) -> Result<UserBalance, sqlx::Error> {
|
||||
let available = sqlx::query!(
|
||||
let payouts = sqlx::query!(
|
||||
"
|
||||
SELECT SUM(amount)
|
||||
SELECT date_available, SUM(amount) sum
|
||||
FROM payouts_values
|
||||
WHERE user_id = $1 AND date_available <= NOW()
|
||||
WHERE user_id = $1
|
||||
GROUP BY date_available
|
||||
ORDER BY date_available DESC
|
||||
",
|
||||
user_id.0
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let pending = sqlx::query!(
|
||||
"
|
||||
SELECT SUM(amount)
|
||||
FROM payouts_values
|
||||
WHERE user_id = $1 AND date_available > NOW()
|
||||
",
|
||||
user_id.0
|
||||
)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let available = payouts
|
||||
.iter()
|
||||
.filter(|x| x.date_available <= Utc::now())
|
||||
.fold(Decimal::ZERO, |acc, x| acc + x.sum.unwrap_or(Decimal::ZERO));
|
||||
let pending = payouts
|
||||
.iter()
|
||||
.filter(|x| x.date_available > Utc::now())
|
||||
.fold(Decimal::ZERO, |acc, x| acc + x.sum.unwrap_or(Decimal::ZERO));
|
||||
|
||||
let withdrawn = sqlx::query!(
|
||||
"
|
||||
@@ -824,12 +825,6 @@ async fn get_user_balance(
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let available = available
|
||||
.map(|x| x.sum.unwrap_or(Decimal::ZERO))
|
||||
.unwrap_or(Decimal::ZERO);
|
||||
let pending = pending
|
||||
.map(|x| x.sum.unwrap_or(Decimal::ZERO))
|
||||
.unwrap_or(Decimal::ZERO);
|
||||
let (withdrawn, fees) = withdrawn
|
||||
.map(|x| {
|
||||
(
|
||||
@@ -844,6 +839,10 @@ async fn get_user_balance(
|
||||
- withdrawn.round_dp(16)
|
||||
- fees.round_dp(16),
|
||||
pending,
|
||||
dates: payouts
|
||||
.iter()
|
||||
.map(|x| (x.date_available, x.sum.unwrap_or(Decimal::ZERO)))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ use crate::{
|
||||
pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
cfg.route("user", web::get().to(user_auth_get));
|
||||
cfg.route("users", web::get().to(users_get));
|
||||
cfg.route("user_email", web::get().to(admin_user_email));
|
||||
|
||||
cfg.service(
|
||||
web::scope("user")
|
||||
@@ -44,6 +45,62 @@ pub fn config(cfg: &mut web::ServiceConfig) {
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserEmailQuery {
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
pub async fn admin_user_email(
|
||||
req: HttpRequest,
|
||||
pool: web::Data<PgPool>,
|
||||
redis: web::Data<RedisPool>,
|
||||
session_queue: web::Data<AuthQueue>,
|
||||
email: web::Query<UserEmailQuery>,
|
||||
) -> Result<HttpResponse, ApiError> {
|
||||
let user = get_user_from_headers(
|
||||
&req,
|
||||
&**pool,
|
||||
&redis,
|
||||
&session_queue,
|
||||
Some(&[Scopes::SESSION_ACCESS]),
|
||||
)
|
||||
.await
|
||||
.map(|x| x.1)?;
|
||||
|
||||
if !user.role.is_admin() {
|
||||
return Err(ApiError::CustomAuthentication(
|
||||
"You do not have permission to get a user from their email!"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let user_id = sqlx::query!(
|
||||
"
|
||||
SELECT id FROM users
|
||||
WHERE LOWER(email) = LOWER($1)
|
||||
",
|
||||
email.email
|
||||
)
|
||||
.fetch_optional(&**pool)
|
||||
.await?
|
||||
.map(|x| x.id)
|
||||
.ok_or_else(|| {
|
||||
ApiError::InvalidInput(
|
||||
"The email provided is not associated with a user!".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let user =
|
||||
User::get_id(crate::database::models::UserId(user_id), &**pool, &redis)
|
||||
.await?;
|
||||
|
||||
if let Some(user) = user {
|
||||
Ok(HttpResponse::Ok().json(user))
|
||||
} else {
|
||||
Err(ApiError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn projects_list(
|
||||
req: HttpRequest,
|
||||
info: web::Path<(String,)>,
|
||||
|
||||
Reference in New Issue
Block a user