Compare commits
73 Commits
v0.9.3
...
cache-alia
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52451f85b5 | ||
|
|
d6c8af7ed5 | ||
|
|
4dd33a2f9e | ||
|
|
1009695a15 | ||
|
|
d3427375b0 | ||
|
|
5c8ed9a8ca | ||
|
|
9c5d817a8a | ||
|
|
d51a1c47c7 | ||
|
|
2b7378bd64 | ||
|
|
c1bb934fc6 | ||
|
|
0d223e3ab5 | ||
|
|
b704e0c8ed | ||
|
|
79279479b1 | ||
|
|
ee4d7c88f1 | ||
|
|
09023f2b49 | ||
|
|
36cfcc2093 | ||
|
|
c2d455f166 | ||
|
|
6859509eb5 | ||
|
|
e2de39ad83 | ||
|
|
ca2307e609 | ||
|
|
0c58b5b83d | ||
|
|
74a12bd606 | ||
|
|
9bb9e13ee8 | ||
|
|
19787a3f51 | ||
|
|
650ab71a83 | ||
|
|
90def724c2 | ||
|
|
f357275fd3 | ||
|
|
2c2a13b587 | ||
|
|
b3a664e0d4 | ||
|
|
3140dab99d | ||
|
|
a74b2da147 | ||
|
|
701fef08f8 | ||
|
|
37ecf75087 | ||
|
|
4c99e379f2 | ||
|
|
fc2c740843 | ||
|
|
1358336a76 | ||
|
|
a02eb5445b | ||
|
|
195cc9cee0 | ||
|
|
719b395b7b | ||
|
|
27fba4ba11 | ||
|
|
6667b620d1 | ||
|
|
c77f3395b2 | ||
|
|
067f471766 | ||
|
|
f75d824c92 | ||
|
|
c4f582e35b | ||
|
|
a8727d9a68 | ||
|
|
423dae5208 | ||
|
|
dc7d8d6018 | ||
|
|
db09ded836 | ||
|
|
df33ff7f60 | ||
|
|
ca63c09a0d | ||
|
|
9c2cd868a7 | ||
|
|
f6d64e8fde | ||
|
|
253e64884a | ||
|
|
a88593fec5 | ||
|
|
6c4548a303 | ||
|
|
4851f18079 | ||
|
|
bbb917c405 | ||
|
|
81d34dfa86 | ||
|
|
31723a2d3c | ||
|
|
975427b319 | ||
|
|
d9983debce | ||
|
|
6a12ef0e2c | ||
|
|
56ba342346 | ||
|
|
6d810a421a | ||
|
|
098519dea1 | ||
|
|
7183b3d761 | ||
|
|
0ac49d846f | ||
|
|
cade2c182c | ||
|
|
affeec82f0 | ||
|
|
a75538c093 | ||
|
|
037cc86c1f | ||
|
|
1e8d550e96 |
@@ -1,3 +1,6 @@
|
|||||||
# Windows has stack overflows when calling from Tauri, so we increase compiler size
|
# Windows has stack overflows when calling from Tauri, so we increase compiler size
|
||||||
[target.'cfg(windows)']
|
[target.'cfg(windows)']
|
||||||
rustflags = ["-C", "link-args=/STACK:16777220"]
|
rustflags = ["-C", "link-args=/STACK:16777220"]
|
||||||
|
|
||||||
|
[build]
|
||||||
|
rustflags = ["--cfg", "tokio_unstable"]
|
||||||
4
.github/workflows/labrinth-docker.yml
vendored
4
.github/workflows/labrinth-docker.yml
vendored
@@ -38,8 +38,10 @@ jobs:
|
|||||||
- name: Build and push
|
- name: Build and push
|
||||||
id: docker_build
|
id: docker_build
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: true
|
||||||
with:
|
with:
|
||||||
context: ./apps/labrinth
|
file: ./apps/labrinth/Dockerfile
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
|
|||||||
4
.github/workflows/theseus-release.yml
vendored
4
.github/workflows/theseus-release.yml
vendored
@@ -6,9 +6,11 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
paths:
|
paths:
|
||||||
- .github/workflows/app-release.yml
|
- .github/workflows/theseus-release.yml
|
||||||
- 'apps/app/**'
|
- 'apps/app/**'
|
||||||
- 'apps/app-frontend/**'
|
- 'apps/app-frontend/**'
|
||||||
|
- 'apps/labrinth/src/common/**'
|
||||||
|
- 'apps/labrinth/Cargo.toml'
|
||||||
- 'packages/app-lib/**'
|
- 'packages/app-lib/**'
|
||||||
- 'packages/app-macros/**'
|
- 'packages/app-macros/**'
|
||||||
- 'packages/assets/**'
|
- 'packages/assets/**'
|
||||||
|
|||||||
4
.idea/code.iml
generated
4
.idea/code.iml
generated
@@ -10,9 +10,11 @@
|
|||||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/src" isTestSource="false" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
|
<sourceFolder url="file://$MODULE_DIR$/apps/labrinth/tests" isTestSource="true" />
|
||||||
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
|
<sourceFolder url="file://$MODULE_DIR$/packages/app-lib/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/packages/rust-common/src" isTestSource="false" />
|
||||||
|
<sourceFolder url="file://$MODULE_DIR$/packages/ariadne/src" isTestSource="false" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/target" />
|
<excludeFolder url="file://$MODULE_DIR$/target" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
</component>
|
</component>
|
||||||
</module>
|
</module>
|
||||||
687
Cargo.lock
generated
687
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ members = [
|
|||||||
'./apps/labrinth',
|
'./apps/labrinth',
|
||||||
'./apps/daedalus_client',
|
'./apps/daedalus_client',
|
||||||
'./packages/daedalus',
|
'./packages/daedalus',
|
||||||
|
'./packages/ariadne',
|
||||||
]
|
]
|
||||||
|
|
||||||
# Optimize for speed and reduce size on release builds
|
# Optimize for speed and reduce size on release builds
|
||||||
@@ -21,4 +22,4 @@ strip = true # Remove debug symbols
|
|||||||
opt-level = 3
|
opt-level = 3
|
||||||
|
|
||||||
[patch.crates-io]
|
[patch.crates-io]
|
||||||
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
|
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { XIcon, HammerIcon, LogInIcon, UpdatedIcon } from '@modrinth/assets'
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
DropdownIcon,
|
||||||
|
XIcon,
|
||||||
|
HammerIcon,
|
||||||
|
LogInIcon,
|
||||||
|
UpdatedIcon,
|
||||||
|
CopyIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
import { ChatIcon } from '@/assets/icons'
|
import { ChatIcon } from '@/assets/icons'
|
||||||
import { ref } from 'vue'
|
import { ButtonStyled, Collapsible } from '@modrinth/ui'
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||||
import { handleError } from '@/store/notifications.js'
|
import { handleError } from '@/store/notifications.js'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
@@ -13,6 +22,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
|||||||
const errorModal = ref()
|
const errorModal = ref()
|
||||||
const error = ref()
|
const error = ref()
|
||||||
const closable = ref(true)
|
const closable = ref(true)
|
||||||
|
const errorCollapsed = ref(false)
|
||||||
|
|
||||||
const title = ref('An error occurred')
|
const title = ref('An error occurred')
|
||||||
const errorType = ref('unknown')
|
const errorType = ref('unknown')
|
||||||
@@ -118,6 +128,26 @@ async function repairInstance() {
|
|||||||
}
|
}
|
||||||
loadingRepair.value = false
|
loadingRepair.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasDebugInfo = computed(
|
||||||
|
() =>
|
||||||
|
errorType.value === 'directory_move' ||
|
||||||
|
errorType.value === 'minecraft_auth' ||
|
||||||
|
errorType.value === 'state_init' ||
|
||||||
|
errorType.value === 'no_loader_version',
|
||||||
|
)
|
||||||
|
|
||||||
|
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
async function copyToClipboard(text) {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -244,16 +274,9 @@ async function repairInstance() {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ error.message ?? error }}
|
{{ debugInfo }}
|
||||||
</template>
|
</template>
|
||||||
<template
|
<template v-if="hasDebugInfo">
|
||||||
v-if="
|
|
||||||
errorType === 'directory_move' ||
|
|
||||||
errorType === 'minecraft_auth' ||
|
|
||||||
errorType === 'state_init' ||
|
|
||||||
errorType === 'no_loader_version'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<hr />
|
<hr />
|
||||||
<p>
|
<p>
|
||||||
If nothing is working and you need help, visit
|
If nothing is working and you need help, visit
|
||||||
@@ -261,16 +284,39 @@ async function repairInstance() {
|
|||||||
and start a chat using the widget in the bottom right and we will be more than happy to
|
and start a chat using the widget in the bottom right and we will be more than happy to
|
||||||
assist! Make sure to provide the following debug information to the agent:
|
assist! Make sure to provide the following debug information to the agent:
|
||||||
</p>
|
</p>
|
||||||
<details>
|
|
||||||
<summary>Debug information</summary>
|
|
||||||
{{ error.message ?? error }}
|
|
||||||
</details>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group push-right">
|
<div class="flex items-center gap-2">
|
||||||
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
|
<ButtonStyled>
|
||||||
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button>
|
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="closable">
|
||||||
|
<button @click="errorModal.hide()"><XIcon /> Close</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="hasDebugInfo">
|
||||||
|
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
|
||||||
|
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
|
||||||
|
<template v-else> <CopyIcon /> Copy debug info </template>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
<template v-if="hasDebugInfo">
|
||||||
|
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
|
||||||
|
<button
|
||||||
|
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
|
||||||
|
@click="errorCollapsed = !errorCollapsed"
|
||||||
|
>
|
||||||
|
<span class="text-contrast font-extrabold m-0">Debug information:</span>
|
||||||
|
<DropdownIcon
|
||||||
|
class="h-5 w-5 text-secondary transition-transform"
|
||||||
|
:class="{ 'rotate-180': !errorCollapsed }"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<Collapsible :collapsed="errorCollapsed">
|
||||||
|
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { handleError } from '@/store/notifications'
|
|||||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import { get } from '@/helpers/settings'
|
import { get } from '@/helpers/settings'
|
||||||
import { edit } from '@/helpers/profile'
|
import { edit } from '@/helpers/profile'
|
||||||
import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types'
|
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
@@ -114,7 +114,6 @@ const messages = defineMessages({
|
|||||||
<Toggle
|
<Toggle
|
||||||
id="fullscreen"
|
id="fullscreen"
|
||||||
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
|
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
|
||||||
:checked="fullscreenSetting"
|
|
||||||
:disabled="!overrideWindowSettings"
|
:disabled="!overrideWindowSettings"
|
||||||
@update:model-value="
|
@update:model-value="
|
||||||
(e) => {
|
(e) => {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function onModalHide() {
|
|||||||
if (props.showAdOnClose) {
|
if (props.showAdOnClose) {
|
||||||
show_ads_window()
|
show_ads_window()
|
||||||
}
|
}
|
||||||
props.onHide()
|
props.onHide?.()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Toggle, ThemeSelector, TeleportDropdownMenu } from '@modrinth/ui'
|
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
|
||||||
import { useTheming } from '@/store/state'
|
import { useTheming } from '@/store/state'
|
||||||
import { get, set } from '@/helpers/settings'
|
import { get, set } from '@/helpers/settings'
|
||||||
import { watch, ref } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { getOS } from '@/helpers/utils'
|
import { getOS } from '@/helpers/utils'
|
||||||
|
|
||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
@@ -46,7 +46,6 @@ watch(
|
|||||||
<Toggle
|
<Toggle
|
||||||
id="advanced-rendering"
|
id="advanced-rendering"
|
||||||
:model-value="themeStore.advancedRendering"
|
:model-value="themeStore.advancedRendering"
|
||||||
:checked="themeStore.advancedRendering"
|
|
||||||
@update:model-value="
|
@update:model-value="
|
||||||
(e) => {
|
(e) => {
|
||||||
themeStore.advancedRendering = e
|
themeStore.advancedRendering = e
|
||||||
@@ -61,16 +60,7 @@ watch(
|
|||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
|
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
|
||||||
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||||
id="native-decorations"
|
|
||||||
:model-value="settings.native_decorations"
|
|
||||||
:checked="settings.native_decorations"
|
|
||||||
@update:model-value="
|
|
||||||
(e) => {
|
|
||||||
settings.native_decorations = e
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-4 flex items-center justify-between">
|
||||||
@@ -78,16 +68,7 @@ watch(
|
|||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
|
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
|
||||||
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
|
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
||||||
id="minimize-launcher"
|
|
||||||
:model-value="settings.hide_on_process_start"
|
|
||||||
:checked="settings.hide_on_process_start"
|
|
||||||
@update:model-value="
|
|
||||||
(e) => {
|
|
||||||
settings.hide_on_process_start = e
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between">
|
<div class="mt-4 flex items-center justify-between">
|
||||||
@@ -111,7 +92,6 @@ watch(
|
|||||||
<Toggle
|
<Toggle
|
||||||
id="toggle-sidebar"
|
id="toggle-sidebar"
|
||||||
:model-value="settings.toggle_sidebar"
|
:model-value="settings.toggle_sidebar"
|
||||||
:checked="settings.toggle_sidebar"
|
|
||||||
@update:model-value="
|
@update:model-value="
|
||||||
(e) => {
|
(e) => {
|
||||||
settings.toggle_sidebar = e
|
settings.toggle_sidebar = e
|
||||||
|
|||||||
@@ -57,16 +57,7 @@ watch(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Toggle
|
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
|
||||||
id="fullscreen"
|
|
||||||
:model-value="settings.force_fullscreen"
|
|
||||||
:checked="settings.force_fullscreen"
|
|
||||||
@update:model-value="
|
|
||||||
(e) => {
|
|
||||||
settings.force_fullscreen = e
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ watch(
|
|||||||
<Toggle
|
<Toggle
|
||||||
id="advanced-rendering"
|
id="advanced-rendering"
|
||||||
:model-value="getStoreValue(option)"
|
:model-value="getStoreValue(option)"
|
||||||
:checked="getStoreValue(option)"
|
|
||||||
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,16 +30,7 @@ watch(
|
|||||||
option, you opt out and ads will no longer be shown based on your interests.
|
option, you opt out and ads will no longer be shown based on your interests.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
|
||||||
id="personalized-ads"
|
|
||||||
:model-value="settings.personalized_ads"
|
|
||||||
:checked="settings.personalized_ads"
|
|
||||||
@update:model-value="
|
|
||||||
(e) => {
|
|
||||||
settings.personalized_ads = e
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between gap-4">
|
<div class="mt-4 flex items-center justify-between gap-4">
|
||||||
@@ -51,16 +42,7 @@ watch(
|
|||||||
longer be collected.
|
longer be collected.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
|
||||||
id="opt-out-analytics"
|
|
||||||
:model-value="settings.telemetry"
|
|
||||||
:checked="settings.telemetry"
|
|
||||||
@update:model-value="
|
|
||||||
(e) => {
|
|
||||||
settings.telemetry = e
|
|
||||||
}
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 flex items-center justify-between gap-4">
|
<div class="mt-4 flex items-center justify-between gap-4">
|
||||||
@@ -75,10 +57,6 @@ watch(
|
|||||||
as those added by mods. (app restart required to take effect)
|
as those added by mods. (app restart required to take effect)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
|
||||||
id="disable-discord-rpc"
|
|
||||||
v-model="settings.discord_rpc"
|
|
||||||
:checked="settings.discord_rpc"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -179,7 +179,6 @@
|
|||||||
<Toggle
|
<Toggle
|
||||||
class="!mx-2"
|
class="!mx-2"
|
||||||
:model-value="!item.data.disabled"
|
:model-value="!item.data.disabled"
|
||||||
:checked="!item.data.disabled"
|
|
||||||
@update:model-value="toggleDisableMod(item.data)"
|
@update:model-value="toggleDisableMod(item.data)"
|
||||||
/>
|
/>
|
||||||
<ButtonStyled type="transparent" circular>
|
<ButtonStyled type="transparent" circular>
|
||||||
|
|||||||
2
apps/app-playground/.cargo/config.toml
Normal file
2
apps/app-playground/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[env]
|
||||||
|
SQLX_OFFLINE = "true"
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
windows_subsystem = "windows"
|
windows_subsystem = "windows"
|
||||||
)]
|
)]
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
|
use tokio::signal::ctrl_c;
|
||||||
use theseus::profile::create::profile_create;
|
|
||||||
|
|
||||||
// A simple Rust implementation of the authentication run
|
// A simple Rust implementation of the authentication run
|
||||||
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
|
// 1) call the authenticate_begin_flow() function to get the URL to open (like you would in the frontend)
|
||||||
@@ -41,54 +41,21 @@ async fn main() -> theseus::Result<()> {
|
|||||||
// Initialize state
|
// Initialize state
|
||||||
State::init().await?;
|
State::init().await?;
|
||||||
|
|
||||||
if minecraft_auth::users().await?.is_empty() {
|
loop {
|
||||||
println!("No users found, authenticating.");
|
if State::get().await?.friends_socket.is_connected().await {
|
||||||
authenticate_run().await?; // could take credentials from here direct, but also deposited in state users
|
break;
|
||||||
}
|
|
||||||
//
|
|
||||||
// st.settings
|
|
||||||
// .write()
|
|
||||||
// .await
|
|
||||||
// .java_globals
|
|
||||||
// .insert(JAVA_8_KEY.to_string(), check_jre(path).await?.unwrap());
|
|
||||||
// Clear profiles
|
|
||||||
println!("Clearing profiles.");
|
|
||||||
{
|
|
||||||
let h = profile::list().await?;
|
|
||||||
for profile in h.into_iter() {
|
|
||||||
profile::remove(&profile.path).await?;
|
|
||||||
}
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("Creating/adding profile.");
|
tracing::info!("Starting host");
|
||||||
|
|
||||||
let name = "Example".to_string();
|
let socket = State::get().await?.friends_socket.open_port(25565).await?;
|
||||||
let game_version = "1.16.1".to_string();
|
tracing::info!("Running host on socket {}", socket.socket_id());
|
||||||
let modloader = ModLoader::Forge;
|
|
||||||
let loader_version = "stable".to_string();
|
|
||||||
|
|
||||||
let profile_path = profile_create(
|
ctrl_c().await?;
|
||||||
name,
|
tracing::info!("Stopping host");
|
||||||
game_version,
|
socket.shutdown().await?;
|
||||||
modloader,
|
|
||||||
Some(loader_version),
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
println!("running");
|
|
||||||
// Run a profile, running minecraft and store the RwLock to the process
|
|
||||||
let process = profile::run(&profile_path).await?;
|
|
||||||
|
|
||||||
println!("Minecraft UUID: {}", process.uuid);
|
|
||||||
|
|
||||||
println!("All running process UUID {:?}", process::get_all().await?);
|
|
||||||
|
|
||||||
// hold the lock to the process until it ends
|
|
||||||
println!("Waiting for process to end...");
|
|
||||||
process::wait_for(process.uuid).await?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
2
apps/app/.cargo/config.toml
Normal file
2
apps/app/.cargo/config.toml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
[env]
|
||||||
|
SQLX_OFFLINE = "true"
|
||||||
@@ -10,12 +10,12 @@
|
|||||||
"astro": "astro"
|
"astro": "astro"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/check": "^0.9.3",
|
"@astrojs/check": "^0.9.4",
|
||||||
"@astrojs/starlight": "^0.26.3",
|
"@astrojs/starlight": "^0.32.2",
|
||||||
"@modrinth/assets": "workspace:*",
|
"@modrinth/assets": "workspace:*",
|
||||||
"astro": "^4.10.2",
|
"astro": "^5.4.1",
|
||||||
"sharp": "^0.32.5",
|
"sharp": "^0.33.5",
|
||||||
"starlight-openapi": "^0.7.0",
|
"starlight-openapi": "^0.14.0",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.8.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
7
apps/docs/src/content.config.ts
Normal file
7
apps/docs/src/content.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineCollection } from 'astro:content';
|
||||||
|
import { docsLoader } from '@astrojs/starlight/loaders';
|
||||||
|
import { docsSchema } from '@astrojs/starlight/schema';
|
||||||
|
|
||||||
|
export const collections = {
|
||||||
|
docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
|
||||||
|
};
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { defineCollection } from 'astro:content'
|
|
||||||
import { docsSchema } from '@astrojs/starlight/schema'
|
|
||||||
|
|
||||||
export const collections = {
|
|
||||||
docs: defineCollection({ schema: docsSchema() }),
|
|
||||||
}
|
|
||||||
@@ -57,6 +57,8 @@
|
|||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
|
"three": "^0.172.0",
|
||||||
|
"@types/three": "^0.172.0",
|
||||||
"vue-multiselect": "3.0.0-alpha.2",
|
"vue-multiselect": "3.0.0-alpha.2",
|
||||||
"vue-typed-virtual-list": "^1.0.10",
|
"vue-typed-virtual-list": "^1.0.10",
|
||||||
"vue3-ace-editor": "^2.2.4",
|
"vue3-ace-editor": "^2.2.4",
|
||||||
|
|||||||
@@ -133,6 +133,21 @@
|
|||||||
"sidebar"
|
"sidebar"
|
||||||
/ 100%;
|
/ 100%;
|
||||||
|
|
||||||
|
.normal-page__ultimate-sidebar {
|
||||||
|
grid-area: ultimate-sidebar;
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
max-width: calc(100% - 2rem);
|
||||||
|
max-height: calc(100vh - 2rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 1024px) {
|
@media screen and (min-width: 1024px) {
|
||||||
&.sidebar {
|
&.sidebar {
|
||||||
grid-template:
|
grid-template:
|
||||||
@@ -156,6 +171,45 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 1400px) {
|
||||||
|
&.ultimate-sidebar {
|
||||||
|
max-width: calc(80rem + 0.75rem + 600px);
|
||||||
|
|
||||||
|
grid-template:
|
||||||
|
"header header ultimate-sidebar" auto
|
||||||
|
"content sidebar ultimate-sidebar" auto
|
||||||
|
"content dummy ultimate-sidebar" 1fr
|
||||||
|
/ 1fr 18.75rem auto;
|
||||||
|
|
||||||
|
.normal-page__header {
|
||||||
|
max-width: 80rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.normal-page__ultimate-sidebar {
|
||||||
|
position: sticky;
|
||||||
|
top: 4.5rem;
|
||||||
|
bottom: unset;
|
||||||
|
right: unset;
|
||||||
|
z-index: unset;
|
||||||
|
align-self: start;
|
||||||
|
display: flex;
|
||||||
|
height: calc(100vh - 4.5rem * 2);
|
||||||
|
|
||||||
|
> div {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.alt-layout {
|
||||||
|
grid-template:
|
||||||
|
"ultimate-sidebar header header" auto
|
||||||
|
"ultimate-sidebar sidebar content" auto
|
||||||
|
"ultimate-sidebar dummy content" 1fr
|
||||||
|
/ auto 18.75rem 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.normal-page__sidebar {
|
.normal-page__sidebar {
|
||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<label for="additional-information" class="flex flex-col gap-1">
|
<label for="additional-information" class="flex flex-col gap-1">
|
||||||
<span class="text-lg font-semibold text-contrast">
|
<span class="text-lg font-semibold text-contrast"> Summary </span>
|
||||||
Summary
|
|
||||||
<span class="text-brand-red">*</span>
|
|
||||||
</span>
|
|
||||||
<span>A sentence or two that describes your collection.</span>
|
<span>A sentence or two that describes your collection.</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="textarea-wrapper">
|
<div class="textarea-wrapper">
|
||||||
@@ -52,8 +49,8 @@
|
|||||||
</NewModal>
|
</NewModal>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { XIcon, PlusIcon } from "@modrinth/assets";
|
import { PlusIcon, XIcon } from "@modrinth/assets";
|
||||||
import { NewModal, ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
|
|
||||||
const router = useNativeRouter();
|
const router = useNativeRouter();
|
||||||
|
|
||||||
@@ -78,7 +75,7 @@ async function create() {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
name: name.value.trim(),
|
name: name.value.trim(),
|
||||||
description: description.value.trim(),
|
description: description.value.trim() || undefined,
|
||||||
projects: props.projectIds,
|
projects: props.projectIds,
|
||||||
},
|
},
|
||||||
apiVersion: 3,
|
apiVersion: 3,
|
||||||
|
|||||||
@@ -1,329 +1,366 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="card moderation-checklist">
|
<div
|
||||||
<h1>Moderation checklist</h1>
|
class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
|
||||||
<div v-if="done">
|
:class="collapsed ? `sm:max-w-[300px]` : 'sm:max-w-[600px]'"
|
||||||
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
|
>
|
||||||
|
<div class="flex grow-0 items-center gap-2">
|
||||||
|
<h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast">
|
||||||
|
<ScaleIcon class="text-orange" /> Moderation
|
||||||
|
</h1>
|
||||||
|
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||||||
|
<button v-tooltip="`Exit moderation`" @click="exitModeration">
|
||||||
|
<CrossIcon />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<button v-tooltip="collapsed ? `Expand` : `Collapse`" @click="emit('toggleCollapsed')">
|
||||||
|
<DropdownIcon class="transition-transform" :class="{ 'rotate-180': collapsed }" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="generatedMessage">
|
<Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
|
||||||
<p>
|
<div class="my-4 h-[1px] w-full bg-divider" />
|
||||||
Enter your moderation message here. Remember to check the Moderation tab to answer any
|
<div v-if="done">
|
||||||
questions an author might have!
|
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
|
||||||
</p>
|
|
||||||
<div class="markdown-editor-spacing">
|
|
||||||
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div v-else-if="generatedMessage">
|
||||||
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
|
<p>
|
||||||
<h2 v-if="modPackData">
|
Enter your moderation message here. Remember to check the Moderation tab to answer any
|
||||||
Modpack permissions
|
questions an author might have!
|
||||||
<template v-if="modPackIndex + 1 <= modPackData.length">
|
</p>
|
||||||
({{ modPackIndex + 1 }} / {{ modPackData.length }})
|
<div class="markdown-editor-spacing">
|
||||||
</template>
|
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
|
||||||
</h2>
|
</div>
|
||||||
<div v-if="!modPackData">Loading data...</div>
|
|
||||||
<div v-else-if="modPackData.length === 0">
|
|
||||||
<p>All permissions obtained. You may skip this step!</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!modPackData[modPackIndex]">
|
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
|
||||||
<p>All permission checks complete!</p>
|
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
|
||||||
<div class="input-group modpack-buttons">
|
Modpack permissions
|
||||||
<button class="btn" @click="modPackIndex -= 1">
|
<template v-if="modPackIndex + 1 <= modPackData.length">
|
||||||
<LeftArrowIcon aria-hidden="true" />
|
({{ modPackIndex + 1 }} / {{ modPackData.length }})
|
||||||
Previous
|
</template>
|
||||||
</button>
|
</h2>
|
||||||
|
<div v-if="!modPackData">Loading data...</div>
|
||||||
|
<div v-else-if="modPackData.length === 0">
|
||||||
|
<p>All permissions obtained. You may skip this step!</p>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!modPackData[modPackIndex]">
|
||||||
|
<p>All permission checks complete!</p>
|
||||||
|
<div class="input-group modpack-buttons">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="modPackIndex -= 1">
|
||||||
|
<LeftArrowIcon aria-hidden="true" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<div v-if="modPackData[modPackIndex].type === 'unknown'">
|
||||||
|
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<button
|
||||||
|
v-for="(option, index) in fileApprovalTypes"
|
||||||
|
:key="index"
|
||||||
|
class="btn"
|
||||||
|
:class="{
|
||||||
|
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||||
|
}"
|
||||||
|
@click="modPackData[modPackIndex].status = option.id"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="modPackData[modPackIndex].status !== 'unidentified'"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<label for="proof">
|
||||||
|
<span class="label__title">Proof</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="proof"
|
||||||
|
v-model="modPackData[modPackIndex].proof"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Enter proof of status..."
|
||||||
|
/>
|
||||||
|
<label for="link">
|
||||||
|
<span class="label__title">Link</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="link"
|
||||||
|
v-model="modPackData[modPackIndex].url"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Enter link of project..."
|
||||||
|
/>
|
||||||
|
<label for="title">
|
||||||
|
<span class="label__title">Title</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="title"
|
||||||
|
v-model="modPackData[modPackIndex].title"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
placeholder="Enter title of project..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
|
||||||
|
<p>
|
||||||
|
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
|
||||||
|
:href="modPackData[modPackIndex].url"
|
||||||
|
target="_blank"
|
||||||
|
class="text-link"
|
||||||
|
>{{ modPackData[modPackIndex].url }}</a
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<button
|
||||||
|
v-for="(option, index) in fileApprovalTypes"
|
||||||
|
:key="index"
|
||||||
|
class="btn"
|
||||||
|
:class="{
|
||||||
|
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||||
|
}"
|
||||||
|
@click="modPackData[modPackIndex].status = option.id"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
|
||||||
|
Does this project provide identification and permission for
|
||||||
|
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
|
||||||
|
Does this project provide attribution for
|
||||||
|
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<p v-else>
|
||||||
|
Does this project provide proof of permission for
|
||||||
|
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||||
|
>?
|
||||||
|
</p>
|
||||||
|
<div class="input-group">
|
||||||
|
<button
|
||||||
|
v-for="(option, index) in filePermissionTypes"
|
||||||
|
:key="index"
|
||||||
|
class="btn"
|
||||||
|
:class="{
|
||||||
|
'option-selected': modPackData[modPackIndex].approved === option.id,
|
||||||
|
}"
|
||||||
|
@click="modPackData[modPackIndex].approved = option.id"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
|
||||||
|
<LeftArrowIcon aria-hidden="true" />
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="blue">
|
||||||
|
<button :disabled="!modPackData[modPackIndex].status" @click="modPackIndex += 1">
|
||||||
|
<RightArrowIcon aria-hidden="true" />
|
||||||
|
Next project
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div v-if="modPackData[modPackIndex].type === 'unknown'">
|
<h2 class="m-0 mb-2 text-lg font-extrabold">{{ steps[currentStepIndex].question }}</h2>
|
||||||
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
|
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
|
||||||
<div class="input-group">
|
<strong>Guidance:</strong>
|
||||||
<button
|
<ul class="mb-3 mt-2 leading-tight">
|
||||||
v-for="(option, index) in fileApprovalTypes"
|
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
|
||||||
:key="index"
|
{{ rule }}
|
||||||
class="btn"
|
</li>
|
||||||
:class="{
|
</ul>
|
||||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
</template>
|
||||||
}"
|
<template
|
||||||
@click="modPackData[modPackIndex].status = option.id"
|
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
|
||||||
>
|
>
|
||||||
{{ option.name }}
|
<strong>Reject things like:</strong>
|
||||||
</button>
|
<ul class="mb-3 mt-2 leading-tight">
|
||||||
</div>
|
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
|
||||||
<template v-if="modPackData[modPackIndex].status !== 'unidentified'">
|
{{ example }}
|
||||||
<div class="universal-labels"></div>
|
</li>
|
||||||
<label for="proof">
|
</ul>
|
||||||
<span class="label__title">Proof</span>
|
</template>
|
||||||
</label>
|
<template
|
||||||
<input
|
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
|
||||||
id="proof"
|
>
|
||||||
v-model="modPackData[modPackIndex].proof"
|
<strong>Exceptions:</strong>
|
||||||
type="text"
|
<ul class="mb-3 mt-2 leading-tight">
|
||||||
autocomplete="off"
|
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
|
||||||
placeholder="Enter proof of status..."
|
{{ exception }}
|
||||||
/>
|
</li>
|
||||||
<label for="link">
|
</ul>
|
||||||
<span class="label__title">Link</span>
|
</template>
|
||||||
</label>
|
<p v-if="steps[currentStepIndex].id === 'title'">
|
||||||
<input
|
<strong>Title:</strong> {{ project.title }}
|
||||||
id="link"
|
</p>
|
||||||
v-model="modPackData[modPackIndex].url"
|
<p v-if="steps[currentStepIndex].id === 'slug'">
|
||||||
type="text"
|
<strong>Slug:</strong> {{ project.slug }}
|
||||||
autocomplete="off"
|
</p>
|
||||||
placeholder="Enter link of project..."
|
<p v-if="steps[currentStepIndex].id === 'summary'">
|
||||||
/>
|
<strong>Summary:</strong> {{ project.description }}
|
||||||
<label for="title">
|
</p>
|
||||||
<span class="label__title">Title</span>
|
<p v-if="steps[currentStepIndex].id === 'links'">
|
||||||
</label>
|
<template v-if="project.issues_url">
|
||||||
<input
|
<strong>Issues: </strong>
|
||||||
id="title"
|
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
|
||||||
v-model="modPackData[modPackIndex].title"
|
|
||||||
type="text"
|
|
||||||
autocomplete="off"
|
|
||||||
placeholder="Enter title of project..."
|
|
||||||
/>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
<template v-if="project.source_url">
|
||||||
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
|
<strong>Source: </strong>
|
||||||
<p>
|
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
|
||||||
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
|
</template>
|
||||||
:href="modPackData[modPackIndex].url"
|
<template v-if="project.wiki_url">
|
||||||
target="_blank"
|
<strong>Wiki: </strong>
|
||||||
class="text-link"
|
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
|
||||||
>{{ modPackData[modPackIndex].url }}</a
|
</template>
|
||||||
>?
|
<template v-if="project.discord_url">
|
||||||
</p>
|
<strong>Discord: </strong>
|
||||||
<div class="input-group">
|
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
|
||||||
<button
|
<br />
|
||||||
v-for="(option, index) in fileApprovalTypes"
|
</template>
|
||||||
:key="index"
|
<template v-for="(donation, index) in project.donation_urls" :key="index">
|
||||||
class="btn"
|
<strong>{{ donation.platform }}: </strong>
|
||||||
:class="{
|
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
|
||||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
<br />
|
||||||
}"
|
</template>
|
||||||
@click="modPackData[modPackIndex].status = option.id"
|
</p>
|
||||||
>
|
<p v-if="steps[currentStepIndex].id === 'categories'">
|
||||||
{{ option.name }}
|
<strong>Categories:</strong>
|
||||||
</button>
|
<Categories
|
||||||
</div>
|
:categories="project.categories.concat(project.additional_categories)"
|
||||||
|
:type="project.actualProjectType"
|
||||||
|
class="categories"
|
||||||
|
/>
|
||||||
|
</p>
|
||||||
|
<p v-if="steps[currentStepIndex].id === 'side-types'">
|
||||||
|
<strong>Client side:</strong> {{ project.client_side }} <br />
|
||||||
|
<strong>Server side:</strong> {{ project.server_side }}
|
||||||
|
</p>
|
||||||
|
<div class="options input-group">
|
||||||
|
<button
|
||||||
|
v-for="(option, index) in steps[currentStepIndex].options"
|
||||||
|
:key="index"
|
||||||
|
class="btn"
|
||||||
|
:class="{
|
||||||
|
'option-selected':
|
||||||
|
selectedOptions[steps[currentStepIndex].id] &&
|
||||||
|
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
|
||||||
|
}"
|
||||||
|
@click="toggleOption(steps[currentStepIndex].id, option)"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
|
selectedOptions[steps[currentStepIndex].id] &&
|
||||||
|
selectedOptions[steps[currentStepIndex].id].length > 0
|
||||||
"
|
"
|
||||||
|
class="inputs universal-labels"
|
||||||
>
|
>
|
||||||
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
|
<div
|
||||||
Does this project provide identification and permission for
|
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
|
||||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
(x) => x.fillers && x.fillers.length > 0,
|
||||||
>?
|
)"
|
||||||
</p>
|
:key="index"
|
||||||
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
|
>
|
||||||
Does this project provide attribution for
|
<div v-for="(filler, idx) in option.fillers" :key="idx">
|
||||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
<label :for="filler.id">
|
||||||
>?
|
<span class="label__title">
|
||||||
</p>
|
{{ filler.question }}
|
||||||
<p v-else>
|
<span v-if="filler.required" class="required">*</span>
|
||||||
Does this project provide proof of permission for
|
</span>
|
||||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
</label>
|
||||||
>?
|
<div v-if="filler.large" class="markdown-editor-spacing">
|
||||||
</p>
|
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
|
||||||
<div class="input-group">
|
</div>
|
||||||
<button
|
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
|
||||||
v-for="(option, index) in filePermissionTypes"
|
</div>
|
||||||
:key="index"
|
</div>
|
||||||
class="btn"
|
</div>
|
||||||
:class="{
|
</div>
|
||||||
'option-selected': modPackData[modPackIndex].approved === option.id,
|
<div class="mt-auto">
|
||||||
}"
|
<div
|
||||||
@click="modPackData[modPackIndex].approved = option.id"
|
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
|
||||||
>
|
>
|
||||||
{{ option.name }}
|
<div class="flex items-center gap-2">
|
||||||
|
<ButtonStyled v-if="!done">
|
||||||
|
<button aria-label="Skip" @click="goToNextProject">
|
||||||
|
<ExitIcon aria-hidden="true" />
|
||||||
|
<template v-if="futureProjects.length > 0">Skip</template>
|
||||||
|
<template v-else>Exit</template>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-if="currentStepIndex > 0">
|
||||||
|
<button @click="previousPage() && !done">
|
||||||
|
<LeftArrowIcon aria-hidden="true" /> Previous
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ButtonStyled v-if="currentStepIndex < steps.length - 1 && !done" color="brand">
|
||||||
|
<button @click="nextPage()"><RightArrowIcon aria-hidden="true" /> Next</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled v-else-if="!generatedMessage" color="brand">
|
||||||
|
<button :disabled="loadingMessage" @click="generateMessage">
|
||||||
|
<UpdatedIcon aria-hidden="true" /> Generate message
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<template v-if="generatedMessage && !done">
|
||||||
|
<ButtonStyled color="green">
|
||||||
|
<button @click="sendMessage(project.requested_status ?? 'approved')">
|
||||||
|
<CheckIcon aria-hidden="true" /> Approve
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<div class="joined-buttons">
|
||||||
|
<ButtonStyled color="red">
|
||||||
|
<button @click="sendMessage('rejected')">
|
||||||
|
<CrossIcon aria-hidden="true" /> Reject
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled color="red">
|
||||||
|
<OverflowMenu
|
||||||
|
class="btn-dropdown-animation"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'withhold',
|
||||||
|
color: 'danger',
|
||||||
|
action: () => sendMessage('withheld'),
|
||||||
|
hoverFilled: true,
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<DropdownIcon style="rotate: 180deg" />
|
||||||
|
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
|
||||||
|
Next project
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group modpack-buttons">
|
|
||||||
<button class="btn" :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
|
|
||||||
<LeftArrowIcon aria-hidden="true" />
|
|
||||||
Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-blue"
|
|
||||||
:disabled="!modPackData[modPackIndex].status"
|
|
||||||
@click="modPackIndex += 1"
|
|
||||||
>
|
|
||||||
<RightArrowIcon aria-hidden="true" />
|
|
||||||
Next project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Collapsible>
|
||||||
<div v-else>
|
|
||||||
<h2>{{ steps[currentStepIndex].question }}</h2>
|
|
||||||
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
|
|
||||||
<strong>Rules guidance:</strong>
|
|
||||||
<ul>
|
|
||||||
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
|
|
||||||
{{ rule }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
<template
|
|
||||||
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
|
|
||||||
>
|
|
||||||
<strong>Examples of what to reject:</strong>
|
|
||||||
<ul>
|
|
||||||
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
|
|
||||||
{{ example }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
<template
|
|
||||||
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
|
|
||||||
>
|
|
||||||
<strong>Exceptions:</strong>
|
|
||||||
<ul>
|
|
||||||
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
|
|
||||||
{{ exception }}
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</template>
|
|
||||||
<p v-if="steps[currentStepIndex].id === 'title'">
|
|
||||||
<strong>Title:</strong> {{ project.title }}
|
|
||||||
</p>
|
|
||||||
<p v-if="steps[currentStepIndex].id === 'slug'"><strong>Slug:</strong> {{ project.slug }}</p>
|
|
||||||
<p v-if="steps[currentStepIndex].id === 'summary'">
|
|
||||||
<strong>Summary:</strong> {{ project.description }}
|
|
||||||
</p>
|
|
||||||
<p v-if="steps[currentStepIndex].id === 'links'">
|
|
||||||
<template v-if="project.issues_url">
|
|
||||||
<strong>Issues: </strong>
|
|
||||||
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
|
|
||||||
</template>
|
|
||||||
<template v-if="project.source_url">
|
|
||||||
<strong>Source: </strong>
|
|
||||||
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
|
|
||||||
</template>
|
|
||||||
<template v-if="project.wiki_url">
|
|
||||||
<strong>Wiki: </strong>
|
|
||||||
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
|
|
||||||
</template>
|
|
||||||
<template v-if="project.discord_url">
|
|
||||||
<strong>Discord: </strong>
|
|
||||||
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
|
|
||||||
<br />
|
|
||||||
</template>
|
|
||||||
<template v-for="(donation, index) in project.donation_urls" :key="index">
|
|
||||||
<strong>{{ donation.platform }}: </strong>
|
|
||||||
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
|
|
||||||
<br />
|
|
||||||
</template>
|
|
||||||
</p>
|
|
||||||
<p v-if="steps[currentStepIndex].id === 'categories'">
|
|
||||||
<strong>Categories:</strong>
|
|
||||||
<Categories
|
|
||||||
:categories="project.categories.concat(project.additional_categories)"
|
|
||||||
:type="project.actualProjectType"
|
|
||||||
class="categories"
|
|
||||||
/>
|
|
||||||
</p>
|
|
||||||
<p v-if="steps[currentStepIndex].id === 'side-types'">
|
|
||||||
<strong>Client side:</strong> {{ project.client_side }} <br />
|
|
||||||
<strong>Server side:</strong> {{ project.server_side }}
|
|
||||||
</p>
|
|
||||||
<div class="options input-group">
|
|
||||||
<button
|
|
||||||
v-for="(option, index) in steps[currentStepIndex].options"
|
|
||||||
:key="index"
|
|
||||||
class="btn"
|
|
||||||
:class="{
|
|
||||||
'option-selected':
|
|
||||||
selectedOptions[steps[currentStepIndex].id] &&
|
|
||||||
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
|
|
||||||
}"
|
|
||||||
@click="toggleOption(steps[currentStepIndex].id, option)"
|
|
||||||
>
|
|
||||||
{{ option.name }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
selectedOptions[steps[currentStepIndex].id] &&
|
|
||||||
selectedOptions[steps[currentStepIndex].id].length > 0
|
|
||||||
"
|
|
||||||
class="inputs universal-labels"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
|
|
||||||
(x) => x.fillers && x.fillers.length > 0,
|
|
||||||
)"
|
|
||||||
:key="index"
|
|
||||||
>
|
|
||||||
<div v-for="(filler, idx) in option.fillers" :key="idx">
|
|
||||||
<label :for="filler.id">
|
|
||||||
<span class="label__title">
|
|
||||||
{{ filler.question }}
|
|
||||||
<span v-if="filler.required" class="required">*</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div v-if="filler.large" class="markdown-editor-spacing">
|
|
||||||
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
|
|
||||||
</div>
|
|
||||||
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="input-group modpack-buttons">
|
|
||||||
<button v-if="!done" class="btn skip-btn" aria-label="Skip" @click="goToNextProject">
|
|
||||||
<ExitIcon aria-hidden="true" />
|
|
||||||
<template v-if="futureProjects.length > 0">Skip</template>
|
|
||||||
<template v-else>Exit</template>
|
|
||||||
</button>
|
|
||||||
<button v-if="currentStepIndex > 0" class="btn" @click="previousPage() && !done">
|
|
||||||
<LeftArrowIcon aria-hidden="true" /> Previous
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="currentStepIndex < steps.length - 1 && !done"
|
|
||||||
class="btn btn-primary"
|
|
||||||
@click="nextPage()"
|
|
||||||
>
|
|
||||||
<RightArrowIcon aria-hidden="true" /> Next
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else-if="!generatedMessage"
|
|
||||||
class="btn btn-primary"
|
|
||||||
:disabled="loadingMessage"
|
|
||||||
@click="generateMessage"
|
|
||||||
>
|
|
||||||
<UpdatedIcon aria-hidden="true" /> Generate message
|
|
||||||
</button>
|
|
||||||
<template v-if="generatedMessage && !done">
|
|
||||||
<button class="btn btn-green" @click="sendMessage(project.requested_status ?? 'approved')">
|
|
||||||
<CheckIcon aria-hidden="true" /> Approve
|
|
||||||
</button>
|
|
||||||
<div class="joined-buttons">
|
|
||||||
<button class="btn btn-danger" @click="sendMessage('rejected')">
|
|
||||||
<CrossIcon aria-hidden="true" /> Reject
|
|
||||||
</button>
|
|
||||||
<OverflowMenu
|
|
||||||
class="btn btn-danger btn-dropdown-animation icon-only"
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'withhold',
|
|
||||||
color: 'danger',
|
|
||||||
action: () => sendMessage('withheld'),
|
|
||||||
hoverFilled: true,
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<DropdownIcon style="rotate: 180deg" />
|
|
||||||
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
|
|
||||||
Next project
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -337,8 +374,9 @@ import {
|
|||||||
XIcon as CrossIcon,
|
XIcon as CrossIcon,
|
||||||
EyeOffIcon,
|
EyeOffIcon,
|
||||||
ExitIcon,
|
ExitIcon,
|
||||||
|
ScaleIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { MarkdownEditor, OverflowMenu } from "@modrinth/ui";
|
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
|
||||||
import Categories from "~/components/ui/search/Categories.vue";
|
import Categories from "~/components/ui/search/Categories.vue";
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -355,8 +393,14 @@ const props = defineProps({
|
|||||||
required: true,
|
required: true,
|
||||||
default: () => {},
|
default: () => {},
|
||||||
},
|
},
|
||||||
|
collapsed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["exit", "toggleCollapsed"]);
|
||||||
|
|
||||||
const steps = computed(() =>
|
const steps = computed(() =>
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -1008,6 +1052,20 @@ async function sendMessage(status) {
|
|||||||
|
|
||||||
const router = useNativeRouter();
|
const router = useNativeRouter();
|
||||||
|
|
||||||
|
async function exitModeration() {
|
||||||
|
await router.push({
|
||||||
|
name: "type-id",
|
||||||
|
params: {
|
||||||
|
type: "project",
|
||||||
|
id: props.project.id,
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
showChecklist: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit("exit");
|
||||||
|
}
|
||||||
|
|
||||||
async function goToNextProject() {
|
async function goToNextProject() {
|
||||||
const project = props.futureProjects[0];
|
const project = props.futureProjects[0];
|
||||||
|
|
||||||
@@ -1031,23 +1089,8 @@ async function goToNextProject() {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.moderation-checklist {
|
.moderation-checklist {
|
||||||
position: sticky;
|
@media (prefers-reduced-motion) {
|
||||||
bottom: 0;
|
transition: none !important;
|
||||||
left: 100vw;
|
|
||||||
z-index: 100;
|
|
||||||
border: 1px solid var(--color-bg-inverted);
|
|
||||||
width: 600px;
|
|
||||||
|
|
||||||
.skip-btn {
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.next-project {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modpack-buttons {
|
|
||||||
margin-top: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.option-selected {
|
.option-selected {
|
||||||
|
|||||||
@@ -76,7 +76,12 @@ function pickLink() {
|
|||||||
subpageSelected.value = false;
|
subpageSelected.value = false;
|
||||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||||
const link = filteredLinks.value[i];
|
const link = filteredLinks.value[i];
|
||||||
if (decodeURIComponent(route.path) === link.href) {
|
if (props.query) {
|
||||||
|
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
|
||||||
|
index = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else if (decodeURIComponent(route.path) === link.href) {
|
||||||
index = i;
|
index = i;
|
||||||
break;
|
break;
|
||||||
} else if (
|
} else if (
|
||||||
@@ -150,7 +155,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => route.path,
|
() => [route.path, route.query],
|
||||||
() => pickLink(),
|
() => pickLink(),
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,13 +19,21 @@
|
|||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="report.item_type === 'user'" class="item-info">
|
<div v-else-if="report.item_type === 'user'" class="item-info">
|
||||||
<nuxt-link :to="`/user/${report.user.username}`" class="iconified-stacked-link">
|
<nuxt-link
|
||||||
|
v-if="report.user"
|
||||||
|
:to="`/user/${report.user.username}`"
|
||||||
|
class="iconified-stacked-link"
|
||||||
|
>
|
||||||
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
|
<Avatar :src="report.user.avatar_url" circle size="xs" no-shadow :raised="raised" />
|
||||||
<div class="stacked">
|
<div class="stacked">
|
||||||
<span class="title">{{ report.user.username }}</span>
|
<span class="title">{{ report.user.username }}</span>
|
||||||
<span>User</span>
|
<span>User</span>
|
||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
<div v-else class="item-info">
|
||||||
|
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
|
||||||
|
<span>Reported user not found: <CopyCode :text="report.item_id" /> </span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="report.item_type === 'version'" class="item-info">
|
<div v-else-if="report.item_type === 'version'" class="item-info">
|
||||||
<nuxt-link
|
<nuxt-link
|
||||||
@@ -50,7 +58,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else class="item-info">
|
<div v-else class="item-info">
|
||||||
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
|
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
|
||||||
<span>Unknown report type</span>
|
<span>Unknown report type: {{ report.item_type }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="report-type">
|
<div class="report-type">
|
||||||
<Badge v-if="report.closed" type="closed" />
|
<Badge v-if="report.closed" type="closed" />
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<template>
|
<template>
|
||||||
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
|
|
||||||
<ReportInfo
|
<ReportInfo
|
||||||
v-for="report in reports.filter(
|
v-for="report in reports.filter(
|
||||||
(x) =>
|
(x) =>
|
||||||
@@ -17,7 +16,6 @@
|
|||||||
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Chips from "~/components/ui/Chips.vue";
|
|
||||||
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
||||||
import { addReportMessage } from "~/helpers/threads.js";
|
import { addReportMessage } from "~/helpers/threads.js";
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ const fetchSettings = async () => {
|
|||||||
initialSettings.value = settings as { interval: number; enabled: boolean };
|
initialSettings.value = settings as { interval: number; enabled: boolean };
|
||||||
autoBackupEnabled.value = settings?.enabled ?? false;
|
autoBackupEnabled.value = settings?.enabled ?? false;
|
||||||
autoBackupInterval.value = settings?.interval || 6;
|
autoBackupInterval.value = settings?.interval || 6;
|
||||||
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error fetching backup settings:", error);
|
console.error("Error fetching backup settings:", error);
|
||||||
addNotification({
|
addNotification({
|
||||||
@@ -114,6 +115,7 @@ const fetchSettings = async () => {
|
|||||||
text: "Failed to load backup settings",
|
text: "Failed to load backup settings",
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
isLoadingSettings.value = false;
|
isLoadingSettings.value = false;
|
||||||
}
|
}
|
||||||
@@ -155,8 +157,10 @@ const saveSettings = async () => {
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
show: async () => {
|
show: async () => {
|
||||||
await fetchSettings();
|
const success = await fetchSettings();
|
||||||
modal.value?.show();
|
if (success) {
|
||||||
|
modal.value?.show();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,530 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="modModal" :header="`Editing ${type.toLocaleLowerCase()} version`">
|
||||||
|
<template #title>
|
||||||
|
<div class="flex min-w-full items-center gap-2 md:w-[calc(420px-5.5rem)]">
|
||||||
|
<UiAvatar :src="modDetails?.icon_url" size="48px" :alt="`${modDetails?.name} Icon`" />
|
||||||
|
<span class="truncate text-xl font-extrabold text-contrast">{{ modDetails?.name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="flex flex-col gap-2 md:w-[420px]">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<template v-if="versionsLoading">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg font-semibold">
|
||||||
|
<span class="opacity-0" aria-hidden="true">{{ type }} version</span>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-[22px] min-w-[140px] animate-pulse rounded-full bg-button-bg" />
|
||||||
|
</div>
|
||||||
|
<div class="min-h-9 w-full animate-pulse rounded-xl bg-button-bg" />
|
||||||
|
<div class="w-fit animate-pulse select-none rounded-md bg-button-bg">
|
||||||
|
<span class="ml-6 opacity-0" aria-hidden="true">
|
||||||
|
Show any beta and alpha releases
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="font-semibold text-contrast">{{ type }} version</div>
|
||||||
|
<NuxtLink
|
||||||
|
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||||
|
@click="
|
||||||
|
versionFilter &&
|
||||||
|
(unlockFilterAccordion.isOpen
|
||||||
|
? unlockFilterAccordion.close()
|
||||||
|
: unlockFilterAccordion.open())
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<TagItem
|
||||||
|
v-if="formattedVersions.game_versions.length > 0"
|
||||||
|
v-tooltip="formattedVersions.game_versions.join(', ')"
|
||||||
|
:style="`--_color: var(--color-green)`"
|
||||||
|
>
|
||||||
|
{{ formattedVersions.game_versions[0] }}
|
||||||
|
</TagItem>
|
||||||
|
<TagItem
|
||||||
|
v-if="formattedVersions.loaders.length > 0"
|
||||||
|
v-tooltip="formattedVersions.loaders.join(', ')"
|
||||||
|
:style="`--_color: var(--color-platform-${formattedVersions.loaders[0].toLowerCase()})`"
|
||||||
|
>
|
||||||
|
{{ formattedVersions.loaders[0] }}
|
||||||
|
</TagItem>
|
||||||
|
<DropdownIcon
|
||||||
|
:class="[
|
||||||
|
'transition-all duration-200 ease-in-out',
|
||||||
|
{ 'rotate-180': unlockFilterAccordion.isOpen },
|
||||||
|
{ 'opacity-0': !versionFilter },
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<UiServersTeleportDropdownMenu
|
||||||
|
v-model="selectedVersion"
|
||||||
|
name="Project"
|
||||||
|
:options="filteredVersions"
|
||||||
|
placeholder="No valid versions found"
|
||||||
|
class="!min-w-full"
|
||||||
|
:disabled="filteredVersions.length === 0"
|
||||||
|
:display-name="
|
||||||
|
(version) => (typeof version === 'object' ? version?.version_number : version)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
<Checkbox v-model="showBetaAlphaReleases"> Show any beta and alpha releases </Checkbox>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Accordion
|
||||||
|
ref="unlockFilterAccordion"
|
||||||
|
:open-by-default="!versionFilter"
|
||||||
|
:class="[
|
||||||
|
versionFilter ? '' : '!border-solid border-orange bg-bg-orange !text-contrast',
|
||||||
|
'flex flex-col gap-2 rounded-2xl border-2 border-dashed border-divider p-3 transition-all',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<p class="m-0 items-center font-bold">
|
||||||
|
<span>
|
||||||
|
{{
|
||||||
|
noCompatibleVersions
|
||||||
|
? `No compatible versions of this ${type.toLowerCase()} were found`
|
||||||
|
: versionFilter
|
||||||
|
? "Game version and platform is provided by the server"
|
||||||
|
: "Incompatible game version and platform versions are unlocked"
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
<p class="m-0 text-sm">
|
||||||
|
{{
|
||||||
|
noCompatibleVersions
|
||||||
|
? `No versions compatible with your server were found. You can still select any available version.`
|
||||||
|
: versionFilter
|
||||||
|
? `Unlocking this filter may allow you to change this ${type.toLowerCase()}
|
||||||
|
to an incompatible version.`
|
||||||
|
: "You might see versions listed that aren't compatible with your server configuration."
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<ContentVersionFilter
|
||||||
|
v-if="currentVersions"
|
||||||
|
ref="filtersRef"
|
||||||
|
:versions="currentVersions"
|
||||||
|
:game-versions="tags.gameVersions"
|
||||||
|
:select-classes="'w-full'"
|
||||||
|
:type="type"
|
||||||
|
:disabled="versionFilter"
|
||||||
|
:platform-tags="tags.loaders"
|
||||||
|
:listed-game-versions="gameVersions"
|
||||||
|
:listed-platforms="platforms"
|
||||||
|
@update:query="updateFiltersFromUi($event)"
|
||||||
|
@vue:mounted="updateFiltersToUi"
|
||||||
|
>
|
||||||
|
<template #platform>
|
||||||
|
<LoaderIcon
|
||||||
|
v-if="filtersRef?.selectedPlatforms.length === 0"
|
||||||
|
:loader="'Vanilla'"
|
||||||
|
class="size-5 flex-none"
|
||||||
|
/>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
class="size-5 flex-none"
|
||||||
|
v-html="tags.loaders.find((x) => x.name === filtersRef?.selectedPlatforms[0])?.icon"
|
||||||
|
></svg>
|
||||||
|
|
||||||
|
<div class="w-full truncate text-left">
|
||||||
|
{{
|
||||||
|
filtersRef?.selectedPlatforms.length === 0
|
||||||
|
? "All platforms"
|
||||||
|
: filtersRef?.selectedPlatforms.map((x) => formatCategory(x)).join(", ")
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template #game-versions>
|
||||||
|
<GameIcon class="size-5 flex-none" />
|
||||||
|
<div class="w-full truncate text-left">
|
||||||
|
{{
|
||||||
|
filtersRef?.selectedGameVersions.length === 0
|
||||||
|
? "All game versions"
|
||||||
|
: filtersRef?.selectedGameVersions.join(", ")
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ContentVersionFilter>
|
||||||
|
|
||||||
|
<ButtonStyled v-if="!noCompatibleVersions" color-fill="text">
|
||||||
|
<button
|
||||||
|
class="w-full"
|
||||||
|
:disabled="gameVersions.length < 2 && platforms.length < 2"
|
||||||
|
@click="
|
||||||
|
versionFilter = !versionFilter;
|
||||||
|
setInitialFilters();
|
||||||
|
updateFiltersToUi();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<LockOpenIcon />
|
||||||
|
{{
|
||||||
|
gameVersions.length < 2 && platforms.length < 2
|
||||||
|
? "No other platforms or versions available"
|
||||||
|
: versionFilter
|
||||||
|
? "Unlock"
|
||||||
|
: "Return to compatibility"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</Accordion>
|
||||||
|
|
||||||
|
<Admonition
|
||||||
|
v-if="versionsError"
|
||||||
|
type="critical"
|
||||||
|
header="Failed to load versions"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
Something went wrong trying to load versions for this {{ type.toLocaleLowerCase() }}.
|
||||||
|
Please try again later or contact support if the issue persists.
|
||||||
|
</span>
|
||||||
|
<LazyUiCopyCode class="!mt-2 !break-all" :text="versionsError" />
|
||||||
|
</div>
|
||||||
|
</Admonition>
|
||||||
|
|
||||||
|
<Admonition
|
||||||
|
v-else-if="props.modPack"
|
||||||
|
type="warning"
|
||||||
|
header="Changing version may cause issues"
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
Your server was created using a modpack. It's recommended to use the modpack's version of
|
||||||
|
the mod.
|
||||||
|
<NuxtLink
|
||||||
|
class="mt-2 flex items-center gap-1"
|
||||||
|
:to="`/servers/manage/${props.serverId}/options/loader`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version
|
||||||
|
</NuxtLink>
|
||||||
|
</Admonition>
|
||||||
|
|
||||||
|
<div class="flex flex-row items-center gap-4">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button
|
||||||
|
:disabled="versionsLoading || selectedVersion.id === modDetails?.version_id"
|
||||||
|
@click="emitChangeModVersion"
|
||||||
|
>
|
||||||
|
<CheckIcon />
|
||||||
|
Install
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button @click="modModal.hide()">
|
||||||
|
<XIcon />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DropdownIcon,
|
||||||
|
XIcon,
|
||||||
|
CheckIcon,
|
||||||
|
LockOpenIcon,
|
||||||
|
GameIcon,
|
||||||
|
ExternalIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import { Admonition, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
|
import TagItem from "@modrinth/ui/src/components/base/TagItem.vue";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { formatCategory, formatVersionsForDisplay, type Version } from "@modrinth/utils";
|
||||||
|
import Accordion from "~/components/ui/Accordion.vue";
|
||||||
|
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||||
|
import ContentVersionFilter, {
|
||||||
|
type ListedGameVersion,
|
||||||
|
type ListedPlatform,
|
||||||
|
} from "~/components/ui/servers/ContentVersionFilter.vue";
|
||||||
|
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
type: "Mod" | "Plugin";
|
||||||
|
loader: string;
|
||||||
|
gameVersion: string;
|
||||||
|
modPack: boolean;
|
||||||
|
serverId: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
interface ContentItem extends Mod {
|
||||||
|
changing?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditVersion extends Version {
|
||||||
|
installed: boolean;
|
||||||
|
upgrade?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modModal = ref();
|
||||||
|
const modDetails = ref<ContentItem>();
|
||||||
|
const currentVersions = ref<EditVersion[] | null>(null);
|
||||||
|
const versionsLoading = ref(false);
|
||||||
|
const versionsError = ref("");
|
||||||
|
const showBetaAlphaReleases = ref(false);
|
||||||
|
const unlockFilterAccordion = ref();
|
||||||
|
const versionFilter = ref(true);
|
||||||
|
const tags = useTags();
|
||||||
|
const noCompatibleVersions = ref(false);
|
||||||
|
|
||||||
|
const { pluginLoaders, modLoaders } = tags.value.loaders.reduce(
|
||||||
|
(acc, tag) => {
|
||||||
|
if (tag.supported_project_types.includes("plugin")) {
|
||||||
|
acc.pluginLoaders.push(tag.name);
|
||||||
|
}
|
||||||
|
if (tag.supported_project_types.includes("mod")) {
|
||||||
|
acc.modLoaders.push(tag.name);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{ pluginLoaders: [] as string[], modLoaders: [] as string[] },
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedVersion = ref();
|
||||||
|
const filtersRef: Ref<InstanceType<typeof ContentVersionFilter> | null> = ref(null);
|
||||||
|
interface SelectedContentFilters {
|
||||||
|
selectedGameVersions: string[];
|
||||||
|
selectedPlatforms: string[];
|
||||||
|
}
|
||||||
|
const selectedFilters = ref<SelectedContentFilters>({
|
||||||
|
selectedGameVersions: [],
|
||||||
|
selectedPlatforms: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const backwardCompatPlatformMap = {
|
||||||
|
purpur: ["purpur", "paper", "spigot", "bukkit"],
|
||||||
|
paper: ["paper", "spigot", "bukkit"],
|
||||||
|
spigot: ["spigot", "bukkit"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const platforms = ref<ListedPlatform[]>([]);
|
||||||
|
const gameVersions = ref<ListedGameVersion[]>([]);
|
||||||
|
const initPlatform = ref<string>("");
|
||||||
|
|
||||||
|
const setInitialFilters = () => {
|
||||||
|
selectedFilters.value = {
|
||||||
|
selectedGameVersions: [
|
||||||
|
gameVersions.value.find((version) => version.name === props.gameVersion)?.name ??
|
||||||
|
gameVersions.value.find((version) => version.release)?.name ??
|
||||||
|
gameVersions.value[0]?.name,
|
||||||
|
],
|
||||||
|
selectedPlatforms: [initPlatform.value],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFiltersToUi = () => {
|
||||||
|
if (!filtersRef.value) return;
|
||||||
|
filtersRef.value.selectedGameVersions = selectedFilters.value.selectedGameVersions;
|
||||||
|
filtersRef.value.selectedPlatforms = selectedFilters.value.selectedPlatforms;
|
||||||
|
|
||||||
|
selectedVersion.value = filteredVersions.value[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFiltersFromUi = (event: { g: string[]; l: string[] }) => {
|
||||||
|
selectedFilters.value = {
|
||||||
|
selectedGameVersions: event.g,
|
||||||
|
selectedPlatforms: event.l,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredVersions = computed(() => {
|
||||||
|
if (!currentVersions.value) return [];
|
||||||
|
|
||||||
|
const versionsWithoutReleaseFilter = currentVersions.value.filter((version: EditVersion) => {
|
||||||
|
if (version.installed) return true;
|
||||||
|
return (
|
||||||
|
filtersRef.value?.selectedPlatforms.every((platform) =>
|
||||||
|
(
|
||||||
|
backwardCompatPlatformMap[platform as keyof typeof backwardCompatPlatformMap] || [
|
||||||
|
platform,
|
||||||
|
]
|
||||||
|
).some((loader) => version.loaders.includes(loader)),
|
||||||
|
) &&
|
||||||
|
filtersRef.value?.selectedGameVersions.every((gameVersion) =>
|
||||||
|
version.game_versions.includes(gameVersion),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const versionTypes = new Set(
|
||||||
|
versionsWithoutReleaseFilter.map((v: EditVersion) => v.version_type),
|
||||||
|
);
|
||||||
|
const releaseVersions = versionTypes.has("release");
|
||||||
|
const betaVersions = versionTypes.has("beta");
|
||||||
|
const alphaVersions = versionTypes.has("alpha");
|
||||||
|
|
||||||
|
const versions = versionsWithoutReleaseFilter.filter((version: EditVersion) => {
|
||||||
|
if (showBetaAlphaReleases.value || version.installed) return true;
|
||||||
|
return releaseVersions
|
||||||
|
? version.version_type === "release"
|
||||||
|
: betaVersions
|
||||||
|
? version.version_type === "beta"
|
||||||
|
: alphaVersions
|
||||||
|
? version.version_type === "alpha"
|
||||||
|
: false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return versions.map((version: EditVersion) => {
|
||||||
|
let suffix = "";
|
||||||
|
|
||||||
|
if (version.version_type === "alpha" && releaseVersions && betaVersions) {
|
||||||
|
suffix += " (alpha)";
|
||||||
|
} else if (version.version_type === "beta" && releaseVersions) {
|
||||||
|
suffix += " (beta)";
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...version,
|
||||||
|
version_number: version.version_number + suffix,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const formattedVersions = computed(() => {
|
||||||
|
return {
|
||||||
|
game_versions: formatVersionsForDisplay(
|
||||||
|
selectedVersion.value?.game_versions || [],
|
||||||
|
tags.value.gameVersions,
|
||||||
|
),
|
||||||
|
loaders: (selectedVersion.value?.loaders || [])
|
||||||
|
.sort((firstLoader: string, secondLoader: string) => {
|
||||||
|
const loaderList = backwardCompatPlatformMap[
|
||||||
|
props.loader as keyof typeof backwardCompatPlatformMap
|
||||||
|
] || [props.loader];
|
||||||
|
|
||||||
|
const firstLoaderPosition = loaderList.indexOf(firstLoader.toLowerCase());
|
||||||
|
const secondLoaderPosition = loaderList.indexOf(secondLoader.toLowerCase());
|
||||||
|
|
||||||
|
if (firstLoaderPosition === -1 && secondLoaderPosition === -1) return 0;
|
||||||
|
if (firstLoaderPosition === -1) return 1;
|
||||||
|
if (secondLoaderPosition === -1) return -1;
|
||||||
|
return firstLoaderPosition - secondLoaderPosition;
|
||||||
|
})
|
||||||
|
.map((loader: string) => formatCategory(loader)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
async function show(mod: ContentItem) {
|
||||||
|
versionFilter.value = true;
|
||||||
|
modModal.value.show();
|
||||||
|
versionsLoading.value = true;
|
||||||
|
modDetails.value = mod;
|
||||||
|
versionsError.value = "";
|
||||||
|
currentVersions.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
|
||||||
|
if (
|
||||||
|
Array.isArray(result) &&
|
||||||
|
result.every(
|
||||||
|
(item) =>
|
||||||
|
"id" in item &&
|
||||||
|
"version_number" in item &&
|
||||||
|
"version_type" in item &&
|
||||||
|
"loaders" in item &&
|
||||||
|
"game_versions" in item,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
currentVersions.value = result as EditVersion[];
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid version data received.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// find the installed version and move it to the top of the list
|
||||||
|
const currentModIndex = currentVersions.value.findIndex(
|
||||||
|
(item: { id: string }) => item.id === mod.version_id,
|
||||||
|
);
|
||||||
|
if (currentModIndex === -1) {
|
||||||
|
currentVersions.value[currentModIndex] = {
|
||||||
|
...currentVersions.value[currentModIndex],
|
||||||
|
installed: true,
|
||||||
|
version_number: `${mod.version_number} (current) (external)`,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
currentVersions.value[currentModIndex].version_number = `${mod.version_number} (current)`;
|
||||||
|
currentVersions.value[currentModIndex].installed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// initially filter the platform and game versions for the server config
|
||||||
|
const platformSet = new Set<string>();
|
||||||
|
const gameVersionSet = new Set<string>();
|
||||||
|
for (const version of currentVersions.value) {
|
||||||
|
for (const loader of version.loaders) {
|
||||||
|
platformSet.add(loader);
|
||||||
|
}
|
||||||
|
for (const gameVersion of version.game_versions) {
|
||||||
|
gameVersionSet.add(gameVersion);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (gameVersionSet.size > 0) {
|
||||||
|
const filteredGameVersions = tags.value.gameVersions.filter((x) =>
|
||||||
|
gameVersionSet.has(x.version),
|
||||||
|
);
|
||||||
|
|
||||||
|
gameVersions.value = filteredGameVersions.map((x) => ({
|
||||||
|
name: x.version,
|
||||||
|
release: x.version_type === "release",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (platformSet.size > 0) {
|
||||||
|
const tempPlatforms = Array.from(platformSet).map((platform) => ({
|
||||||
|
name: platform,
|
||||||
|
isType:
|
||||||
|
props.type === "Plugin"
|
||||||
|
? pluginLoaders.includes(platform)
|
||||||
|
: props.type === "Mod"
|
||||||
|
? modLoaders.includes(platform)
|
||||||
|
: false,
|
||||||
|
}));
|
||||||
|
platforms.value = tempPlatforms;
|
||||||
|
}
|
||||||
|
|
||||||
|
// set default platform
|
||||||
|
const defaultPlatform = Array.from(platformSet)[0];
|
||||||
|
initPlatform.value = platformSet.has(props.loader)
|
||||||
|
? props.loader
|
||||||
|
: props.loader in backwardCompatPlatformMap
|
||||||
|
? backwardCompatPlatformMap[props.loader as keyof typeof backwardCompatPlatformMap].find(
|
||||||
|
(p) => platformSet.has(p),
|
||||||
|
) || defaultPlatform
|
||||||
|
: defaultPlatform;
|
||||||
|
|
||||||
|
// check if there's nothing compatible with the server config
|
||||||
|
noCompatibleVersions.value =
|
||||||
|
!platforms.value.some((p) => p.isType) ||
|
||||||
|
!gameVersions.value.some((v) => v.name === props.gameVersion);
|
||||||
|
|
||||||
|
if (noCompatibleVersions.value) {
|
||||||
|
unlockFilterAccordion.value.open();
|
||||||
|
versionFilter.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitialFilters();
|
||||||
|
versionsLoading.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading versions:", error);
|
||||||
|
versionsError.value = error instanceof Error ? error.message : "Unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
changeVersion: [string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
function emitChangeModVersion() {
|
||||||
|
if (!selectedVersion.value) return;
|
||||||
|
emit("changeVersion", selectedVersion.value.id.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
show,
|
||||||
|
hide: () => modModal.value.hide(),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
172
apps/frontend/src/components/ui/servers/ContentVersionFilter.vue
Normal file
172
apps/frontend/src/components/ui/servers/ContentVersionFilter.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<template>
|
||||||
|
<div class="experimental-styles-within flex w-full flex-col items-center gap-2">
|
||||||
|
<ManySelect
|
||||||
|
v-model="selectedPlatforms"
|
||||||
|
:tooltip="
|
||||||
|
filterOptions.platform.length < 2 && !disabled ? 'No other platforms available' : undefined
|
||||||
|
"
|
||||||
|
:options="filterOptions.platform"
|
||||||
|
:dropdown-id="`${baseId}-platform`"
|
||||||
|
search
|
||||||
|
show-always
|
||||||
|
class="w-full"
|
||||||
|
:disabled="disabled || filterOptions.platform.length < 2"
|
||||||
|
:dropdown-class="'w-full'"
|
||||||
|
@change="updateFilters"
|
||||||
|
>
|
||||||
|
<slot name="platform">
|
||||||
|
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||||
|
Platform
|
||||||
|
</slot>
|
||||||
|
<template #option="{ option }">
|
||||||
|
{{ formatCategory(option) }}
|
||||||
|
</template>
|
||||||
|
<template v-if="hasAnyUnsupportedPlatforms" #footer>
|
||||||
|
<Checkbox
|
||||||
|
v-model="showSupportedPlatformsOnly"
|
||||||
|
class="mx-1"
|
||||||
|
:label="`Show ${type?.toLowerCase()} platforms only`"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</ManySelect>
|
||||||
|
<ManySelect
|
||||||
|
v-model="selectedGameVersions"
|
||||||
|
:tooltip="
|
||||||
|
filterOptions.gameVersion.length < 2 && !disabled
|
||||||
|
? 'No other game versions available'
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
|
:options="filterOptions.gameVersion"
|
||||||
|
:dropdown-id="`${baseId}-game-version`"
|
||||||
|
search
|
||||||
|
show-always
|
||||||
|
class="w-full"
|
||||||
|
:disabled="disabled || filterOptions.gameVersion.length < 2"
|
||||||
|
:dropdown-class="'w-full'"
|
||||||
|
@change="updateFilters"
|
||||||
|
>
|
||||||
|
<slot name="game-versions">
|
||||||
|
<FilterIcon class="h-5 w-5 text-secondary" />
|
||||||
|
Game versions
|
||||||
|
</slot>
|
||||||
|
<template v-if="hasAnySnapshots" #footer>
|
||||||
|
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
|
||||||
|
</template>
|
||||||
|
</ManySelect>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FilterIcon } from "@modrinth/assets";
|
||||||
|
import { type Version, formatCategory, type GameVersionTag } from "@modrinth/utils";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useRoute } from "vue-router";
|
||||||
|
import ManySelect from "@modrinth/ui/src/components/base/ManySelect.vue";
|
||||||
|
import Checkbox from "@modrinth/ui/src/components/base/Checkbox.vue";
|
||||||
|
|
||||||
|
export type ListedGameVersion = {
|
||||||
|
name: string;
|
||||||
|
release: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListedPlatform = {
|
||||||
|
name: string;
|
||||||
|
isType: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
versions: Version[];
|
||||||
|
gameVersions: GameVersionTag[];
|
||||||
|
listedGameVersions: ListedGameVersion[];
|
||||||
|
listedPlatforms: ListedPlatform[];
|
||||||
|
baseId?: string;
|
||||||
|
type: "Mod" | "Plugin";
|
||||||
|
platformTags: {
|
||||||
|
name: string;
|
||||||
|
supported_project_types: string[];
|
||||||
|
}[];
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:query"]);
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const showSnapshots = ref(false);
|
||||||
|
const hasAnySnapshots = computed(() => {
|
||||||
|
return props.versions.some((x) =>
|
||||||
|
props.gameVersions.some(
|
||||||
|
(y) => y.version_type !== "release" && x.game_versions.includes(y.version),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasOnlySnapshots = computed(() => {
|
||||||
|
return props.versions.every((version) => {
|
||||||
|
return version.game_versions.every((gv) => {
|
||||||
|
const matched = props.gameVersions.find((tag) => tag.version === gv);
|
||||||
|
return matched && matched.version_type !== "release";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasAnyUnsupportedPlatforms = computed(() => {
|
||||||
|
return props.listedPlatforms.some((x) => !x.isType);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasOnlyUnsupportedPlatforms = computed(() => {
|
||||||
|
return props.listedPlatforms.every((x) => !x.isType);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showSupportedPlatformsOnly = ref(true);
|
||||||
|
|
||||||
|
const filterOptions = computed(() => {
|
||||||
|
const filters: Record<"gameVersion" | "platform", string[]> = {
|
||||||
|
gameVersion: [],
|
||||||
|
platform: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
filters.gameVersion = props.listedGameVersions
|
||||||
|
.filter((x) => {
|
||||||
|
return showSnapshots.value || hasOnlySnapshots.value ? true : x.release;
|
||||||
|
})
|
||||||
|
.map((x) => x.name);
|
||||||
|
|
||||||
|
filters.platform = props.listedPlatforms
|
||||||
|
.filter((x) => {
|
||||||
|
return !showSupportedPlatformsOnly.value || hasOnlyUnsupportedPlatforms.value
|
||||||
|
? true
|
||||||
|
: x.isType;
|
||||||
|
})
|
||||||
|
.map((x) => x.name);
|
||||||
|
|
||||||
|
return filters;
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedGameVersions = ref<string[]>([]);
|
||||||
|
const selectedPlatforms = ref<string[]>([]);
|
||||||
|
|
||||||
|
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : [];
|
||||||
|
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : [];
|
||||||
|
|
||||||
|
function updateFilters() {
|
||||||
|
emit("update:query", {
|
||||||
|
g: selectedGameVersions.value,
|
||||||
|
l: selectedPlatforms.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
selectedGameVersions,
|
||||||
|
selectedPlatforms,
|
||||||
|
});
|
||||||
|
|
||||||
|
function getArrayOrString(x: string | (string | null)[]): string[] {
|
||||||
|
if (typeof x === "string") {
|
||||||
|
return [x];
|
||||||
|
} else {
|
||||||
|
return x.filter((item): item is string => item !== null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style></style>
|
||||||
@@ -75,7 +75,7 @@ import {
|
|||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { computed, shallowRef, ref } from "vue";
|
import { computed, shallowRef, ref } from "vue";
|
||||||
import { renderToString } from "@vue/server-renderer";
|
import { renderToString } from "vue/server-renderer";
|
||||||
import { useRouter, useRoute } from "vue-router";
|
import { useRouter, useRoute } from "vue-router";
|
||||||
import {
|
import {
|
||||||
UiServersIconsCogFolderIcon,
|
UiServersIconsCogFolderIcon,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
|
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
|
||||||
<header
|
<header
|
||||||
:class="[
|
:class="[
|
||||||
'duration-20 h-26 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
|
'duration-20 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
|
||||||
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
|
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
|
||||||
]"
|
]"
|
||||||
data-pyro-files-state="browsing"
|
data-pyro-files-state="browsing"
|
||||||
@@ -76,25 +76,23 @@
|
|||||||
<UiServersTeleportOverflowMenu
|
<UiServersTeleportOverflowMenu
|
||||||
position="bottom"
|
position="bottom"
|
||||||
direction="left"
|
direction="left"
|
||||||
aria-label="Sort files"
|
aria-label="Filter view"
|
||||||
:options="[
|
:options="[
|
||||||
{ id: 'normal', action: () => $emit('sort', 'default') },
|
{ id: 'all', action: () => $emit('filter', 'all') },
|
||||||
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
|
||||||
{ id: 'created', action: () => $emit('sort', 'created') },
|
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
|
||||||
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
|
||||||
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span class="hidden whitespace-pre text-sm font-medium sm:block">
|
<div class="flex items-center gap-1">
|
||||||
{{ sortMethodLabel }}
|
<FilterIcon aria-hidden="true" class="h-5 w-5" />
|
||||||
</span>
|
<span class="hidden text-sm font-medium sm:block">
|
||||||
<SortAscendingIcon aria-hidden="true" />
|
{{ filterLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||||
<template #normal> Alphabetical </template>
|
<template #all>Show all</template>
|
||||||
<template #modified> Date modified </template>
|
<template #filesOnly>Files only</template>
|
||||||
<template #created> Date created </template>
|
<template #foldersOnly>Folders only</template>
|
||||||
<template #filesOnly> Files only </template>
|
|
||||||
<template #foldersOnly> Folders only </template>
|
|
||||||
</UiServersTeleportOverflowMenu>
|
</UiServersTeleportOverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<div class="mx-1 w-full text-sm sm:w-48">
|
<div class="mx-1 w-full text-sm sm:w-48">
|
||||||
@@ -148,9 +146,9 @@ import {
|
|||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
SearchIcon,
|
SearchIcon,
|
||||||
SortAscendingIcon,
|
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
|
FilterIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
@@ -159,15 +157,15 @@ import { useIntersectionObserver } from "@vueuse/core";
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
breadcrumbSegments: string[];
|
breadcrumbSegments: string[];
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
sortMethod: string;
|
currentFilter: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: "navigate", index: number): void;
|
(e: "navigate", index: number): void;
|
||||||
(e: "sort", method: string): void;
|
|
||||||
(e: "create", type: "file" | "directory"): void;
|
(e: "create", type: "file" | "directory"): void;
|
||||||
(e: "upload"): void;
|
(e: "upload"): void;
|
||||||
(e: "update:searchQuery", value: string): void;
|
(e: "update:searchQuery", value: string): void;
|
||||||
|
(e: "filter", type: string): void;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const pyroFilesSentinel = ref<HTMLElement | null>(null);
|
const pyroFilesSentinel = ref<HTMLElement | null>(null);
|
||||||
@@ -181,18 +179,14 @@ useIntersectionObserver(
|
|||||||
{ threshold: [0, 1] },
|
{ threshold: [0, 1] },
|
||||||
);
|
);
|
||||||
|
|
||||||
const sortMethodLabel = computed(() => {
|
const filterLabel = computed(() => {
|
||||||
switch (props.sortMethod) {
|
switch (props.currentFilter) {
|
||||||
case "modified":
|
|
||||||
return "Date modified";
|
|
||||||
case "created":
|
|
||||||
return "Date created";
|
|
||||||
case "filesOnly":
|
case "filesOnly":
|
||||||
return "Files only";
|
return "Files only";
|
||||||
case "foldersOnly":
|
case "foldersOnly":
|
||||||
return "Folders only";
|
return "Folders only";
|
||||||
default:
|
default:
|
||||||
return "Alphabetical";
|
return "Show all";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
@mouseleave="stopPan"
|
@mouseleave="stopPan"
|
||||||
@wheel.prevent="handleWheel"
|
@wheel.prevent="handleWheel"
|
||||||
>
|
>
|
||||||
<UiServersPyroLoading v-if="state.isLoading" />
|
<div v-if="state.isLoading" />
|
||||||
<div
|
<div
|
||||||
v-if="state.hasError"
|
v-if="state.hasError"
|
||||||
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||||
|
|||||||
@@ -1,14 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
class="flex w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised px-3 py-2 text-xs font-bold uppercase"
|
class="sticky top-12 z-20 flex h-8 w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised bg-bg px-3 text-xs font-bold uppercase"
|
||||||
>
|
>
|
||||||
<div class="min-w-[48px]"></div>
|
<div class="min-w-[48px]"></div>
|
||||||
<span class="flex w-full">Name</span>
|
<button
|
||||||
|
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
|
||||||
|
@click="$emit('sort', 'name')"
|
||||||
|
>
|
||||||
|
<span>Name</span>
|
||||||
|
<ChevronUpIcon v-if="sortField === 'name' && !sortDesc" class="h-3 w-3" aria-hidden="true" />
|
||||||
|
<ChevronDownIcon v-if="sortField === 'name' && sortDesc" class="h-3 w-3" aria-hidden="true" />
|
||||||
|
</button>
|
||||||
<div class="flex shrink-0 gap-4 text-right md:gap-12">
|
<div class="flex shrink-0 gap-4 text-right md:gap-12">
|
||||||
<span class="hidden min-w-[160px] md:block">Created</span>
|
<button
|
||||||
<span class="mr-4 min-w-[160px]">Modified</span>
|
class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
|
||||||
<div class="min-w-[36px]"></div>
|
@click="$emit('sort', 'created')"
|
||||||
|
>
|
||||||
|
<span>Created</span>
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-if="sortField === 'created' && !sortDesc"
|
||||||
|
class="h-3 w-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="sortField === 'created' && sortDesc"
|
||||||
|
class="h-3 w-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="mr-4 hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
|
||||||
|
@click="$emit('sort', 'modified')"
|
||||||
|
>
|
||||||
|
<span>Modified</span>
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-if="sortField === 'modified' && !sortDesc"
|
||||||
|
class="h-3 w-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="sortField === 'modified' && sortDesc"
|
||||||
|
class="h-3 w-3"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div class="min-w-[24px]"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ChevronDownIcon from "./icons/ChevronDownIcon.vue";
|
||||||
|
import ChevronUpIcon from "./icons/ChevronUpIcon.vue";
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sortField: string;
|
||||||
|
sortDesc: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
(e: "sort", field: string): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|||||||
309
apps/frontend/src/components/ui/servers/Globe.vue
Normal file
309
apps/frontend/src/components/ui/servers/Globe.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="container" class="relative h-[400px] w-full cursor-move lg:h-[600px]">
|
||||||
|
<div
|
||||||
|
v-for="location in locations"
|
||||||
|
:key="location.name"
|
||||||
|
:class="{
|
||||||
|
'opacity-0': !showLabels,
|
||||||
|
hidden: !isLocationVisible(location),
|
||||||
|
'z-40': location.clicked,
|
||||||
|
}"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${location.screenPosition?.x || 0}px`,
|
||||||
|
top: `${location.screenPosition?.y || 0}px`,
|
||||||
|
}"
|
||||||
|
class="location-button center-on-top-left flex transform cursor-pointer items-center rounded-full bg-bg px-3 outline-1 outline-red transition-opacity duration-200 hover:z-50"
|
||||||
|
@click="toggleLocationClicked(location)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'animate-pulse': location.active,
|
||||||
|
'border-gray-400': !location.active,
|
||||||
|
'border-purple bg-purple': location.active,
|
||||||
|
'border-dashed': !location.active,
|
||||||
|
'opacity-40': !location.active,
|
||||||
|
}"
|
||||||
|
class="my-3 size-2.5 shrink-0 rounded-full border-2"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="expanding-item"
|
||||||
|
:class="{
|
||||||
|
expanded: location.clicked,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="whitespace-nowrap text-sm">
|
||||||
|
<span class="ml-2"> {{ location.name }} </span>
|
||||||
|
<span v-if="!location.active" class="ml-1 text-xs text-secondary">(Coming Soon)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import * as THREE from "three";
|
||||||
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
|
||||||
|
const container = ref(null);
|
||||||
|
const showLabels = ref(false);
|
||||||
|
|
||||||
|
const locations = ref([
|
||||||
|
// Active locations
|
||||||
|
{ 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: "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 },
|
||||||
|
// { name: "Amsterdam", lat: 52.3676, lng: 4.9041, active: false, clicked: false },
|
||||||
|
// { name: "Paris", lat: 48.8566, lng: 2.3522, active: false, clicked: false },
|
||||||
|
// { name: "Singapore", lat: 1.3521, lng: 103.8198, active: false, clicked: false },
|
||||||
|
// { name: "Tokyo", lat: 35.6762, lng: 139.6503, active: false, clicked: false },
|
||||||
|
// { name: "Sydney", lat: -33.8688, lng: 151.2093, active: false, clicked: false },
|
||||||
|
// { name: "São Paulo", lat: -23.5505, lng: -46.6333, active: false, clicked: false },
|
||||||
|
// { name: "Toronto", lat: 43.6532, lng: -79.3832, active: false, clicked: false },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isLocationVisible = (location) => {
|
||||||
|
if (!location.screenPosition || !globe) return false;
|
||||||
|
|
||||||
|
const vector = latLngToVector3(location.lat, location.lng).clone();
|
||||||
|
vector.applyMatrix4(globe.matrixWorld);
|
||||||
|
|
||||||
|
const cameraVector = new THREE.Vector3();
|
||||||
|
camera.getWorldPosition(cameraVector);
|
||||||
|
|
||||||
|
const viewVector = vector.clone().sub(cameraVector).normalize();
|
||||||
|
|
||||||
|
const normal = vector.clone().normalize();
|
||||||
|
|
||||||
|
const dotProduct = normal.dot(viewVector);
|
||||||
|
|
||||||
|
return dotProduct < -0.15;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleLocationClicked = (location) => {
|
||||||
|
console.log("clicked", location.name);
|
||||||
|
locations.value.find((loc) => loc.name === location.name).clicked = !location.clicked;
|
||||||
|
};
|
||||||
|
|
||||||
|
let scene, camera, renderer, globe, controls;
|
||||||
|
let animationFrame;
|
||||||
|
|
||||||
|
const init = () => {
|
||||||
|
scene = new THREE.Scene();
|
||||||
|
camera = new THREE.PerspectiveCamera(
|
||||||
|
45,
|
||||||
|
container.value.clientWidth / container.value.clientHeight,
|
||||||
|
0.1,
|
||||||
|
1000,
|
||||||
|
);
|
||||||
|
renderer = new THREE.WebGLRenderer({
|
||||||
|
antialias: true,
|
||||||
|
alpha: true,
|
||||||
|
powerPreference: "low-power",
|
||||||
|
});
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
|
||||||
|
container.value.appendChild(renderer.domElement);
|
||||||
|
|
||||||
|
const geometry = new THREE.SphereGeometry(5, 64, 64);
|
||||||
|
const outlineTexture = new THREE.TextureLoader().load("/earth-outline.png");
|
||||||
|
outlineTexture.minFilter = THREE.LinearFilter;
|
||||||
|
outlineTexture.magFilter = THREE.LinearFilter;
|
||||||
|
|
||||||
|
const material = new THREE.ShaderMaterial({
|
||||||
|
uniforms: {
|
||||||
|
outlineTexture: { value: outlineTexture },
|
||||||
|
globeColor: { value: new THREE.Color("#60fbb5") },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform sampler2D outlineTexture;
|
||||||
|
uniform vec3 globeColor;
|
||||||
|
varying vec2 vUv;
|
||||||
|
void main() {
|
||||||
|
vec4 texColor = texture2D(outlineTexture, vUv);
|
||||||
|
|
||||||
|
float brightness = max(max(texColor.r, texColor.g), texColor.b);
|
||||||
|
gl_FragColor = vec4(globeColor, brightness * 0.8);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
transparent: true,
|
||||||
|
side: THREE.FrontSide,
|
||||||
|
});
|
||||||
|
|
||||||
|
globe = new THREE.Mesh(geometry, material);
|
||||||
|
scene.add(globe);
|
||||||
|
|
||||||
|
const atmosphereGeometry = new THREE.SphereGeometry(5.2, 64, 64);
|
||||||
|
const atmosphereMaterial = new THREE.ShaderMaterial({
|
||||||
|
transparent: true,
|
||||||
|
side: THREE.BackSide,
|
||||||
|
uniforms: {
|
||||||
|
color: { value: new THREE.Color("#56f690") },
|
||||||
|
viewVector: { value: camera.position },
|
||||||
|
},
|
||||||
|
vertexShader: `
|
||||||
|
uniform vec3 viewVector;
|
||||||
|
varying float intensity;
|
||||||
|
void main() {
|
||||||
|
vec3 vNormal = normalize(normalMatrix * normal);
|
||||||
|
vec3 vNormel = normalize(normalMatrix * viewVector);
|
||||||
|
intensity = pow(0.7 - dot(vNormal, vNormel), 2.0);
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
fragmentShader: `
|
||||||
|
uniform vec3 color;
|
||||||
|
varying float intensity;
|
||||||
|
void main() {
|
||||||
|
gl_FragColor = vec4(color, intensity * 0.4);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const atmosphere = new THREE.Mesh(atmosphereGeometry, atmosphereMaterial);
|
||||||
|
scene.add(atmosphere);
|
||||||
|
|
||||||
|
const ambientLight = new THREE.AmbientLight(0x404040, 0.5);
|
||||||
|
scene.add(ambientLight);
|
||||||
|
|
||||||
|
camera.position.z = 15;
|
||||||
|
|
||||||
|
controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.05;
|
||||||
|
controls.rotateSpeed = 0.3;
|
||||||
|
controls.enableZoom = false;
|
||||||
|
controls.enablePan = false;
|
||||||
|
controls.autoRotate = true;
|
||||||
|
controls.autoRotateSpeed = 0.05;
|
||||||
|
controls.minPolarAngle = Math.PI * 0.3;
|
||||||
|
controls.maxPolarAngle = Math.PI * 0.7;
|
||||||
|
|
||||||
|
globe.rotation.y = Math.PI * 1.9;
|
||||||
|
globe.rotation.x = Math.PI * 0.15;
|
||||||
|
};
|
||||||
|
|
||||||
|
const animate = () => {
|
||||||
|
animationFrame = requestAnimationFrame(animate);
|
||||||
|
controls.update();
|
||||||
|
|
||||||
|
locations.value.forEach((location) => {
|
||||||
|
const position = latLngToVector3(location.lat, location.lng);
|
||||||
|
const vector = position.clone();
|
||||||
|
vector.applyMatrix4(globe.matrixWorld);
|
||||||
|
|
||||||
|
const coords = vector.project(camera);
|
||||||
|
const screenPosition = {
|
||||||
|
x: (coords.x * 0.5 + 0.5) * container.value.clientWidth,
|
||||||
|
y: (-coords.y * 0.5 + 0.5) * container.value.clientHeight,
|
||||||
|
};
|
||||||
|
location.screenPosition = screenPosition;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
};
|
||||||
|
|
||||||
|
const latLngToVector3 = (lat, lng) => {
|
||||||
|
const phi = (90 - lat) * (Math.PI / 180);
|
||||||
|
const theta = (lng + 180) * (Math.PI / 180);
|
||||||
|
const radius = 5;
|
||||||
|
|
||||||
|
return new THREE.Vector3(
|
||||||
|
-radius * Math.sin(phi) * Math.cos(theta),
|
||||||
|
radius * Math.cos(phi),
|
||||||
|
radius * Math.sin(phi) * Math.sin(theta),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (!container.value) return;
|
||||||
|
camera.aspect = container.value.clientWidth / container.value.clientHeight;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(container.value.clientWidth, container.value.clientHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
init();
|
||||||
|
animate();
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
showLabels.value = true;
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (animationFrame) {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
}
|
||||||
|
window.removeEventListener("resize", handleResize);
|
||||||
|
if (renderer) {
|
||||||
|
renderer.dispose();
|
||||||
|
}
|
||||||
|
if (container.value) {
|
||||||
|
container.value.innerHTML = "";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes pulse {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0.3);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(27, 217, 106, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(27, 217, 106, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center-on-top-left {
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanding-item.expanded {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
.location-button:hover .expanding-item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanding-item {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 0fr;
|
||||||
|
transition: grid-template-columns 0.15s ease-in-out;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
> div {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion) {
|
||||||
|
.expanding-item {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
76
apps/frontend/src/components/ui/servers/InstallingTicker.vue
Normal file
76
apps/frontend/src/components/ui/servers/InstallingTicker.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<template>
|
||||||
|
<div class="ticker-container">
|
||||||
|
<div class="ticker-content">
|
||||||
|
<div
|
||||||
|
v-for="(message, index) in msgs"
|
||||||
|
:key="message"
|
||||||
|
class="ticker-item text-xs"
|
||||||
|
:class="{ active: index === currentIndex % msgs.length }"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from "vue";
|
||||||
|
|
||||||
|
const msgs = [
|
||||||
|
"Organizing files...",
|
||||||
|
"Downloading mods...",
|
||||||
|
"Configuring server...",
|
||||||
|
"Setting up environment...",
|
||||||
|
"Adding Java...",
|
||||||
|
];
|
||||||
|
|
||||||
|
const currentIndex = ref(0);
|
||||||
|
|
||||||
|
let intervalId: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
currentIndex.value = (currentIndex.value + 1) % msgs.length;
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (intervalId) {
|
||||||
|
clearInterval(intervalId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.ticker-container {
|
||||||
|
height: 20px;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-item {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--color-secondary-text);
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9);
|
||||||
|
filter: blur(4px);
|
||||||
|
transition: all 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ticker-item.active {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
:is-current="isCurrentLoader(loader.name)"
|
:is-current="isCurrentLoader(loader.name)"
|
||||||
:loader-version="data.loader_version"
|
:loader-version="data.loader_version"
|
||||||
:current-loader="data.loader"
|
:current-loader="data.loader"
|
||||||
|
:is-installing="isInstalling"
|
||||||
@select="selectLoader"
|
@select="selectLoader"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
:is-current="isCurrentLoader(loader.name)"
|
:is-current="isCurrentLoader(loader.name)"
|
||||||
:loader-version="data.loader_version"
|
:loader-version="data.loader_version"
|
||||||
:current-loader="data.loader"
|
:current-loader="data.loader"
|
||||||
|
:is-installing="isInstalling"
|
||||||
@select="selectLoader"
|
@select="selectLoader"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,6 +49,7 @@
|
|||||||
:is-current="isCurrentLoader(loader.name)"
|
:is-current="isCurrentLoader(loader.name)"
|
||||||
:loader-version="data.loader_version"
|
:loader-version="data.loader_version"
|
||||||
:current-loader="data.loader"
|
:current-loader="data.loader"
|
||||||
|
:is-installing="isInstalling"
|
||||||
@select="selectLoader"
|
@select="selectLoader"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -60,6 +63,7 @@ const props = defineProps<{
|
|||||||
loader: string | null;
|
loader: string | null;
|
||||||
loader_version: string | null;
|
loader_version: string | null;
|
||||||
};
|
};
|
||||||
|
isInstalling?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button @click="onSelect">
|
<button :disabled="isInstalling" @click="onSelect">
|
||||||
<DownloadIcon class="h-5 w-5" />
|
<DownloadIcon class="h-5 w-5" />
|
||||||
{{ isCurrentLoader ? "Reinstall" : "Install" }}
|
{{ isCurrentLoader ? "Reinstall" : "Install" }}
|
||||||
</button>
|
</button>
|
||||||
@@ -52,6 +52,7 @@ interface Props {
|
|||||||
loader: LoaderInfo;
|
loader: LoaderInfo;
|
||||||
currentLoader: string | null;
|
currentLoader: string | null;
|
||||||
loaderVersion: string | null;
|
loaderVersion: string | null;
|
||||||
|
isInstalling?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>();
|
const props = defineProps<Props>();
|
||||||
|
|||||||
91
apps/frontend/src/components/ui/servers/LogLine.vue
Normal file
91
apps/frontend/src/components/ui/servers/LogLine.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
|
||||||
|
@mouseenter="checkOverflow"
|
||||||
|
@touchstart="checkOverflow"
|
||||||
|
>
|
||||||
|
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
|
||||||
|
<span v-html="sanitizedLog"></span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="isOverflowing"
|
||||||
|
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
|
||||||
|
type="button"
|
||||||
|
@click.stop="$emit('show-full-log', props.log)"
|
||||||
|
>
|
||||||
|
...
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, onUnmounted } from "vue";
|
||||||
|
import Convert from "ansi-to-html";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
log: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
"show-full-log": [log: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const logContent = ref<HTMLElement | null>(null);
|
||||||
|
const isOverflowing = ref(false);
|
||||||
|
|
||||||
|
const checkOverflow = () => {
|
||||||
|
if (logContent.value && !isOverflowing.value) {
|
||||||
|
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const convert = new Convert({
|
||||||
|
fg: "#FFF",
|
||||||
|
bg: "#000",
|
||||||
|
newline: false,
|
||||||
|
escapeXML: true,
|
||||||
|
stream: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sanitizedLog = computed(() =>
|
||||||
|
DOMPurify.sanitize(convert.toHtml(props.log), {
|
||||||
|
ALLOWED_TAGS: ["span"],
|
||||||
|
ALLOWED_ATTR: ["style"],
|
||||||
|
USE_PROFILES: { html: true },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const preventSelection = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
logContent.value?.addEventListener("mousedown", preventSelection);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
logContent.value?.removeEventListener("mousedown", preventSelection);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.parsed-log {
|
||||||
|
background: transparent;
|
||||||
|
transition: background-color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parsed-log:hover {
|
||||||
|
background: rgba(128, 128, 128, 0.25);
|
||||||
|
transition: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content > span {
|
||||||
|
user-select: none;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-content {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="parsed-log group relative w-full overflow-hidden px-6 py-1">
|
|
||||||
<div
|
|
||||||
ref="logContent"
|
|
||||||
class="log-content selectable whitespace-pre-wrap selection:bg-black selection:text-white dark:selection:bg-white dark:selection:text-black"
|
|
||||||
v-html="sanitizedLog"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from "vue";
|
|
||||||
import Convert from "ansi-to-html";
|
|
||||||
import DOMPurify from "dompurify";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
log: string;
|
|
||||||
index: number;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const logContent = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
const colors = {
|
|
||||||
30: "#101010",
|
|
||||||
31: "#EFA6A2",
|
|
||||||
32: "#80C990",
|
|
||||||
33: "#A69460",
|
|
||||||
34: "#A3B8EF",
|
|
||||||
35: "#E6A3DC",
|
|
||||||
36: "#50CACD",
|
|
||||||
37: "#808080",
|
|
||||||
90: "#454545",
|
|
||||||
91: "#E0AF85",
|
|
||||||
92: "#5ACCAF",
|
|
||||||
93: "#C8C874",
|
|
||||||
94: "#CCACED",
|
|
||||||
95: "#F2A1C2",
|
|
||||||
96: "#74C3E4",
|
|
||||||
97: "#C0C0C0",
|
|
||||||
};
|
|
||||||
|
|
||||||
const convert = new Convert({
|
|
||||||
fg: "#FFF",
|
|
||||||
bg: "#000",
|
|
||||||
newline: false,
|
|
||||||
escapeXML: true,
|
|
||||||
stream: false,
|
|
||||||
colors,
|
|
||||||
});
|
|
||||||
|
|
||||||
const urlRegex = /https?:\/\/[^\s]+/g;
|
|
||||||
const usernameRegex = /<([^&]+)>/g;
|
|
||||||
|
|
||||||
const sanitizedLog = computed(() => {
|
|
||||||
let html = convert.toHtml(props.log);
|
|
||||||
html = html.replace(
|
|
||||||
urlRegex,
|
|
||||||
(url) =>
|
|
||||||
`<a style="color:var(--color-link);text-decoration:underline;" href="${url}" target="_blank" rel="noopener noreferrer">${url}</a>`,
|
|
||||||
);
|
|
||||||
html = html.replace(
|
|
||||||
usernameRegex,
|
|
||||||
(_, username) => `<span class="minecraft-username"><${username}></span>`,
|
|
||||||
);
|
|
||||||
return DOMPurify.sanitize(html, {
|
|
||||||
ALLOWED_TAGS: ["span", "a"],
|
|
||||||
ALLOWED_ATTR: ["style", "href", "target", "rel", "class"],
|
|
||||||
ADD_ATTR: ["target"],
|
|
||||||
RETURN_TRUSTED_TYPE: true,
|
|
||||||
USE_PROFILES: { html: true },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.parsed-log:hover:not(.selected) {
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.light-mode .parsed-log:hover:not(.selected) {
|
|
||||||
background-color: #ccc;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.dark-mode .parsed-log:hover:not(.selected) {
|
|
||||||
background-color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
html.oled-mode .parsed-log:hover:not(.selected) {
|
|
||||||
background-color: #222;
|
|
||||||
}
|
|
||||||
|
|
||||||
.minecraft-username {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
::v-deep(.log-content) {
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
::v-deep(.log-content.selectable) {
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
::v-deep(.log-content *) {
|
|
||||||
user-select: text;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -34,8 +34,7 @@
|
|||||||
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"
|
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">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">0 Bytes</h2>
|
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
|
||||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 0 Bytes</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
|
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
|
||||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||||
@@ -50,8 +49,12 @@
|
|||||||
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<div
|
||||||
class="console relative h-full min-h-[488px] w-full overflow-hidden rounded-xl bg-bg text-sm"
|
class="console relative h-full min-h-[516px] w-full overflow-hidden rounded-xl bg-bg text-sm"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -59,7 +62,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CPUIcon, DBIcon, FolderOpenIcon } from "@modrinth/assets";
|
import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } from "@modrinth/assets";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<template>
|
|
||||||
<ButtonStyled type="standard">
|
|
||||||
<button aria-label="Copy server IP" @click="copyText">
|
|
||||||
<CopyIcon />
|
|
||||||
Copy IP
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { CopyIcon } from "@modrinth/assets";
|
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
ip: string;
|
|
||||||
port: number;
|
|
||||||
subdomain?: string | null;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const copyText = () => {
|
|
||||||
const text = props.subdomain ? `${props.subdomain}.modrinth.gg` : `${props.ip}:${props.port}`;
|
|
||||||
navigator.clipboard.writeText(text);
|
|
||||||
|
|
||||||
addNotification({
|
|
||||||
group: "server",
|
|
||||||
title: `Copied IP`,
|
|
||||||
text: `Your server's IP has been copied to your clipboard`,
|
|
||||||
type: "success",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
@@ -1,23 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contents">
|
<div class="contents">
|
||||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal">
|
<NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
|
||||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||||
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p>
|
<p class="m-0">
|
||||||
|
Are you sure you want to <span class="lowercase">{{ confirmActionText }}</span> the
|
||||||
|
server?
|
||||||
|
</p>
|
||||||
<UiCheckbox
|
<UiCheckbox
|
||||||
v-model="powerDontAskAgainCheckbox"
|
v-model="dontAskAgain"
|
||||||
label="Don't ask me again"
|
label="Don't ask me again"
|
||||||
class="text-sm"
|
class="text-sm"
|
||||||
:disabled="!currentPendingAction"
|
:disabled="!powerAction"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-row gap-4">
|
<div class="flex flex-row gap-4">
|
||||||
<ButtonStyled type="standard" color="brand" @click="confirmAction">
|
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
|
||||||
<button>
|
<button>
|
||||||
<CheckIcon class="h-5 w-5" />
|
<CheckIcon class="h-5 w-5" />
|
||||||
{{ currentPendingActionFriendly }} server
|
{{ confirmActionText }} server
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled @click="closePowerModal">
|
<ButtonStyled @click="resetPowerAction">
|
||||||
<button>
|
<button>
|
||||||
<XIcon class="h-5 w-5" />
|
<XIcon class="h-5 w-5" />
|
||||||
Cancel
|
Cancel
|
||||||
@@ -29,7 +31,7 @@
|
|||||||
|
|
||||||
<NewModal
|
<NewModal
|
||||||
ref="detailsModal"
|
ref="detailsModal"
|
||||||
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`"
|
:header="`All of ${serverName || 'Server'} info`"
|
||||||
@close="closeDetailsModal"
|
@close="closeDetailsModal"
|
||||||
>
|
>
|
||||||
<UiServersServerInfoLabels
|
<UiServersServerInfoLabels
|
||||||
@@ -51,75 +53,74 @@
|
|||||||
<UiServersPanelSpinner class="size-5" /> Installing...
|
<UiServersPanelSpinner class="size-5" /> Installing...
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<div v-else class="contents">
|
|
||||||
|
<template v-else>
|
||||||
<ButtonStyled v-if="showStopButton" type="transparent">
|
<ButtonStyled v-if="showStopButton" type="transparent">
|
||||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer">
|
<button :disabled="!canTakeAction" @click="initiateAction('stop')">
|
||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<StopCircleIcon class="h-5 w-5" />
|
<StopCircleIcon class="h-5 w-5" />
|
||||||
<span>{{ stopButtonText }}</span>
|
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|
||||||
<ButtonStyled type="standard" color="brand">
|
<ButtonStyled type="standard" color="brand">
|
||||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction">
|
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||||
<div v-if="isStartingOrRestarting" class="grid place-content-center">
|
<div v-if="isTransitionState" class="grid place-content-center">
|
||||||
<UiServersIconsLoadingIcon />
|
<UiServersIconsLoadingIcon />
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="contents">
|
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
|
||||||
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" />
|
<span>{{ primaryActionText }}</span>
|
||||||
</div>
|
|
||||||
<span>
|
|
||||||
{{ actionButtonText }}
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dropdown options -->
|
<ButtonStyled circular type="transparent">
|
||||||
<ButtonStyled circular type="transparent">
|
<UiServersTeleportOverflowMenu :options="[...menuOptions]">
|
||||||
<UiServersTeleportOverflowMenu
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
:options="[
|
<template #kill>
|
||||||
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
|
<SlashIcon class="h-5 w-5" />
|
||||||
{ id: 'allServers', action: () => router.push('/servers/manage') },
|
<span>Kill server</span>
|
||||||
{ id: 'details', action: () => showDetailsModal() },
|
</template>
|
||||||
]"
|
<template #allServers>
|
||||||
>
|
<ServerIcon class="h-5 w-5" />
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
<span>All servers</span>
|
||||||
<template #kill>
|
</template>
|
||||||
<SlashIcon class="h-5 w-5" />
|
<template #details>
|
||||||
<span>Kill server</span>
|
<InfoIcon class="h-5 w-5" />
|
||||||
</template>
|
<span>Details</span>
|
||||||
<template #allServers>
|
</template>
|
||||||
<ServerIcon class="h-5 w-5" />
|
</UiServersTeleportOverflowMenu>
|
||||||
<span>All servers</span>
|
</ButtonStyled>
|
||||||
</template>
|
</template>
|
||||||
<template #details>
|
|
||||||
<InfoIcon class="h-5 w-5" />
|
|
||||||
<span>Details</span>
|
|
||||||
</template>
|
|
||||||
</UiServersTeleportOverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import {
|
import {
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
UpdatedIcon,
|
UpdatedIcon,
|
||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
SlashIcon,
|
SlashIcon,
|
||||||
MoreVerticalIcon,
|
|
||||||
XIcon,
|
XIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useStorage } from "@vueuse/core";
|
import { useStorage } from "@vueuse/core";
|
||||||
|
|
||||||
|
type ServerAction = "start" | "stop" | "restart" | "kill";
|
||||||
|
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
|
||||||
|
|
||||||
|
interface PowerAction {
|
||||||
|
action: ServerAction;
|
||||||
|
nextState: ServerState;
|
||||||
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isOnline: boolean;
|
isOnline: boolean;
|
||||||
isActioning: boolean;
|
isActioning: boolean;
|
||||||
@@ -130,183 +131,142 @@ const props = defineProps<{
|
|||||||
uptimeSeconds: number;
|
uptimeSeconds: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "action", action: ServerAction): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const serverId = router.currentRoute.value.params.id;
|
const serverId = router.currentRoute.value.params.id;
|
||||||
|
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||||
|
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||||
|
|
||||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||||
powerDontAskAgain: false,
|
powerDontAskAgain: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
|
||||||
(e: "action", action: "start" | "restart" | "stop" | "kill"): void;
|
const powerAction = ref<PowerAction | null>(null);
|
||||||
}>();
|
const dontAskAgain = ref(false);
|
||||||
|
const startingDelay = ref(false);
|
||||||
|
|
||||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
|
|
||||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
|
|
||||||
|
|
||||||
const ServerState = {
|
|
||||||
Stopped: "Stopped",
|
|
||||||
Starting: "Starting",
|
|
||||||
Running: "Running",
|
|
||||||
Stopping: "Stopping",
|
|
||||||
Restarting: "Restarting",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
|
|
||||||
|
|
||||||
const currentPendingAction = ref<string | null>(null);
|
|
||||||
const currentPendingState = ref<ServerStateType | null>(null);
|
|
||||||
const powerDontAskAgainCheckbox = ref(false);
|
|
||||||
|
|
||||||
const currentState = ref<ServerStateType>(
|
|
||||||
props.isOnline ? ServerState.Running : ServerState.Stopped,
|
|
||||||
);
|
|
||||||
|
|
||||||
const isStartingDelay = ref(false);
|
|
||||||
const showStopButton = computed(
|
|
||||||
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
|
|
||||||
);
|
|
||||||
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
|
|
||||||
const canTakeAction = computed(
|
const canTakeAction = computed(
|
||||||
() =>
|
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
|
||||||
!props.isActioning &&
|
|
||||||
!isStartingDelay.value &&
|
|
||||||
currentState.value !== ServerState.Starting &&
|
|
||||||
currentState.value !== ServerState.Stopping,
|
|
||||||
);
|
);
|
||||||
|
const isRunning = computed(() => serverState.value === "running");
|
||||||
const isStartingOrRestarting = computed(
|
const isTransitionState = computed(() =>
|
||||||
() =>
|
["starting", "stopping", "restarting"].includes(serverState.value),
|
||||||
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
|
|
||||||
);
|
);
|
||||||
|
const isStoppingState = computed(() => serverState.value === "stopping");
|
||||||
|
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
|
||||||
|
|
||||||
const isStopping = computed(() => currentState.value === ServerState.Stopping);
|
const primaryActionText = computed(() => {
|
||||||
|
const states: Record<ServerState, string> = {
|
||||||
const actionButtonText = computed(() => {
|
starting: "Starting...",
|
||||||
switch (currentState.value) {
|
restarting: "Restarting...",
|
||||||
case ServerState.Starting:
|
running: "Restart",
|
||||||
return "Starting...";
|
stopping: "Stopping...",
|
||||||
case ServerState.Restarting:
|
stopped: "Start",
|
||||||
return "Restarting...";
|
};
|
||||||
case ServerState.Running:
|
return states[serverState.value];
|
||||||
return "Restart";
|
|
||||||
case ServerState.Stopping:
|
|
||||||
return "Stopping...";
|
|
||||||
default:
|
|
||||||
return "Start";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPendingActionFriendly = computed(() => {
|
const confirmActionText = computed(() => {
|
||||||
switch (currentPendingAction.value) {
|
if (!powerAction.value) return "";
|
||||||
case "start":
|
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
|
||||||
return "Start";
|
|
||||||
case "restart":
|
|
||||||
return "Restart";
|
|
||||||
case "stop":
|
|
||||||
return "Stop";
|
|
||||||
case "kill":
|
|
||||||
return "Kill";
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const stopButtonText = computed(() =>
|
const menuOptions = computed(() => [
|
||||||
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop",
|
...(props.isInstalling
|
||||||
);
|
? []
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: "kill",
|
||||||
|
label: "Kill server",
|
||||||
|
icon: SlashIcon,
|
||||||
|
action: () => initiateAction("kill"),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
{
|
||||||
|
id: "allServers",
|
||||||
|
label: "All servers",
|
||||||
|
icon: ServerIcon,
|
||||||
|
action: () => router.push("/servers/manage"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "details",
|
||||||
|
label: "Details",
|
||||||
|
icon: InfoIcon,
|
||||||
|
action: () => detailsModal.value?.show(),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
const createPendingAction = () => {
|
function initiateAction(action: ServerAction) {
|
||||||
if (!canTakeAction.value) return;
|
if (!canTakeAction.value) return;
|
||||||
if (currentState.value === ServerState.Running) {
|
|
||||||
currentPendingAction.value = "restart";
|
const stateMap: Record<ServerAction, ServerState> = {
|
||||||
currentPendingState.value = ServerState.Restarting;
|
start: "starting",
|
||||||
showPowerModal();
|
stop: "stopping",
|
||||||
} else {
|
restart: "restarting",
|
||||||
runAction("start", ServerState.Starting);
|
kill: "stopping",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (action === "start") {
|
||||||
|
emit("action", action);
|
||||||
|
serverState.value = stateMap[action];
|
||||||
|
startingDelay.value = true;
|
||||||
|
setTimeout(() => (startingDelay.value = false), 5000);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleAction = () => {
|
powerAction.value = { action, nextState: stateMap[action] };
|
||||||
createPendingAction();
|
|
||||||
};
|
|
||||||
|
|
||||||
const showPowerModal = () => {
|
|
||||||
if (userPreferences.value.powerDontAskAgain) {
|
if (userPreferences.value.powerDontAskAgain) {
|
||||||
runAction(
|
executePowerAction();
|
||||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
|
||||||
currentPendingState.value!,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
confirmActionModal.value?.show();
|
confirmActionModal.value?.show();
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
const confirmAction = () => {
|
function handlePrimaryAction() {
|
||||||
if (powerDontAskAgainCheckbox.value) {
|
initiateAction(isRunning.value ? "restart" : "start");
|
||||||
|
}
|
||||||
|
|
||||||
|
function executePowerAction() {
|
||||||
|
if (!powerAction.value) return;
|
||||||
|
|
||||||
|
const { action, nextState } = powerAction.value;
|
||||||
|
emit("action", action);
|
||||||
|
serverState.value = nextState;
|
||||||
|
|
||||||
|
if (dontAskAgain.value) {
|
||||||
userPreferences.value.powerDontAskAgain = true;
|
userPreferences.value.powerDontAskAgain = true;
|
||||||
}
|
}
|
||||||
runAction(
|
|
||||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
|
||||||
currentPendingState.value!,
|
|
||||||
);
|
|
||||||
closePowerModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
|
|
||||||
emit("action", action);
|
|
||||||
currentState.value = serverState;
|
|
||||||
|
|
||||||
if (action === "start") {
|
if (action === "start") {
|
||||||
isStartingDelay.value = true;
|
startingDelay.value = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => (startingDelay.value = false), 5000);
|
||||||
isStartingDelay.value = false;
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const stopServer = () => {
|
resetPowerAction();
|
||||||
if (!canTakeAction.value) return;
|
}
|
||||||
currentPendingAction.value = "stop";
|
|
||||||
currentPendingState.value = ServerState.Stopping;
|
|
||||||
showPowerModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const killServer = () => {
|
function resetPowerAction() {
|
||||||
currentPendingAction.value = "kill";
|
|
||||||
currentPendingState.value = ServerState.Stopping;
|
|
||||||
showPowerModal();
|
|
||||||
};
|
|
||||||
|
|
||||||
const closePowerModal = () => {
|
|
||||||
confirmActionModal.value?.hide();
|
confirmActionModal.value?.hide();
|
||||||
currentPendingAction.value = null;
|
powerAction.value = null;
|
||||||
powerDontAskAgainCheckbox.value = false;
|
dontAskAgain.value = false;
|
||||||
};
|
}
|
||||||
|
|
||||||
const closeDetailsModal = () => {
|
function closeDetailsModal() {
|
||||||
detailsModal.value?.hide();
|
detailsModal.value?.hide();
|
||||||
};
|
}
|
||||||
|
|
||||||
const showDetailsModal = () => {
|
|
||||||
detailsModal.value?.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.isOnline,
|
() => props.isOnline,
|
||||||
(newValue) => {
|
(online) => (serverState.value = online ? "running" : "stopped"),
|
||||||
if (newValue) {
|
|
||||||
currentState.value = ServerState.Running;
|
|
||||||
} else {
|
|
||||||
currentState.value = ServerState.Stopped;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => router.currentRoute.value.fullPath,
|
() => router.currentRoute.value.fullPath,
|
||||||
() => {
|
() => closeDetailsModal(),
|
||||||
closeDetailsModal();
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,66 +1,72 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
:aria-label="`Server is ${getStatusText}`"
|
:aria-label="`Server is ${getStatusText(state)}`"
|
||||||
class="relative inline-flex select-none items-center"
|
class="relative inline-flex select-none items-center"
|
||||||
@mouseenter="isExpanded = true"
|
@mouseenter="isExpanded = true"
|
||||||
@mouseleave="isExpanded = false"
|
@mouseleave="isExpanded = false"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`"
|
:class="[
|
||||||
|
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
|
||||||
|
getStatusClass(state).main,
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`"
|
:class="[
|
||||||
|
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
|
||||||
|
getStatusClass(state).bg,
|
||||||
|
]"
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${
|
:class="[
|
||||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0'
|
'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
|
||||||
}`"
|
getStatusClass(state).bg,
|
||||||
|
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<div class="h-3 w-3 rounded-full"></div>
|
<div class="h-3 w-3 rounded-full"></div>
|
||||||
<span
|
<span
|
||||||
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out"
|
:class="[
|
||||||
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`"
|
'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
|
||||||
|
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
{{ getStatusText }}
|
{{ getStatusText(state) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { ref } from "vue";
|
||||||
import type { ServerState } from "~/types/servers";
|
import type { ServerState } from "~/types/servers";
|
||||||
|
|
||||||
const props = defineProps<{
|
const STATUS_CLASSES = {
|
||||||
|
running: { main: "bg-brand", bg: "bg-bg-green" },
|
||||||
|
stopped: { main: "", bg: "" },
|
||||||
|
crashed: { main: "bg-brand-red", bg: "bg-bg-red" },
|
||||||
|
unknown: { main: "", bg: "" },
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const STATUS_TEXTS = {
|
||||||
|
running: "Running",
|
||||||
|
stopped: "",
|
||||||
|
crashed: "Crashed",
|
||||||
|
unknown: "Unknown",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
state: ServerState;
|
state: ServerState;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const isExpanded = ref(false);
|
const isExpanded = ref(false);
|
||||||
|
|
||||||
const getStatusClass = computed(() => {
|
function getStatusClass(state: ServerState) {
|
||||||
switch (props.state) {
|
return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
|
||||||
case "running":
|
}
|
||||||
return { main: "bg-brand", bg: "bg-bg-green" };
|
|
||||||
case "stopped":
|
|
||||||
return { main: "", bg: "" };
|
|
||||||
case "crashed":
|
|
||||||
return { main: "bg-brand-red", bg: "bg-bg-red" };
|
|
||||||
default:
|
|
||||||
return { main: "", bg: "" };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const getStatusText = computed(() => {
|
function getStatusText(state: ServerState) {
|
||||||
switch (props.state) {
|
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown;
|
||||||
case "running":
|
}
|
||||||
return "Running";
|
|
||||||
case "stopped":
|
|
||||||
return "";
|
|
||||||
case "crashed":
|
|
||||||
return "Crashed";
|
|
||||||
default:
|
|
||||||
return "Unknown";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,164 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="modal"
|
||||||
|
:header="'Changing ' + props.project?.title + ' version'"
|
||||||
|
@hide="onHide"
|
||||||
|
@show="onShow"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<p class="m-0">
|
||||||
|
Select the version of {{ props.project?.title || "the modpack" }} you want to install on
|
||||||
|
your server.
|
||||||
|
</p>
|
||||||
|
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
|
||||||
|
Currently installed: {{ props.currentVersion.version_number }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-4">
|
||||||
|
<UiServersTeleportDropdownMenu
|
||||||
|
v-if="props.versions?.length"
|
||||||
|
v-model="selectedVersion"
|
||||||
|
:options="versionOptions"
|
||||||
|
placeholder="Select version..."
|
||||||
|
name="version"
|
||||||
|
class="w-full max-w-full"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="flex w-full flex-row items-center justify-between">
|
||||||
|
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
|
||||||
|
Erase all data
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="modpack-hard-reset"
|
||||||
|
v-model="hardReset"
|
||||||
|
class="switch stylized-toggle shrink-0"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
If enabled, existing mods, worlds, and configurations, will be deleted before installing
|
||||||
|
the new modpack version.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-start gap-4">
|
||||||
|
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
|
||||||
|
<button
|
||||||
|
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
|
||||||
|
@click="handleReinstall"
|
||||||
|
>
|
||||||
|
<DownloadIcon class="size-4" />
|
||||||
|
{{ isLoading ? "Installing..." : hardReset ? "Erase and install" : "Install" }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button :disabled="isLoading" @click="hide">
|
||||||
|
<XIcon />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
|
import { DownloadIcon, XIcon } from "@modrinth/assets";
|
||||||
|
import type { Server } from "~/composables/pyroServers";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
|
project: any;
|
||||||
|
versions: any[];
|
||||||
|
currentVersion?: any;
|
||||||
|
currentVersionId?: string;
|
||||||
|
serverStatus?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reinstall: [any?];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const modal = ref();
|
||||||
|
const hardReset = ref(false);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const selectedVersion = ref("");
|
||||||
|
|
||||||
|
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || []);
|
||||||
|
|
||||||
|
const handleReinstall = async () => {
|
||||||
|
if (!selectedVersion.value || !props.project?.id) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id;
|
||||||
|
|
||||||
|
await props.server.general?.reinstall(
|
||||||
|
props.server.serverId,
|
||||||
|
false,
|
||||||
|
props.project.id,
|
||||||
|
versionId,
|
||||||
|
undefined,
|
||||||
|
hardReset.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit("reinstall");
|
||||||
|
hide();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Cannot reinstall server",
|
||||||
|
text: "You are being rate limited. Please try again later.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Reinstall Failed",
|
||||||
|
text: "An unexpected error occurred while reinstalling. Please try again later.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.serverStatus,
|
||||||
|
(newStatus) => {
|
||||||
|
if (newStatus === "installing") {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const onShow = () => {
|
||||||
|
hardReset.value = false;
|
||||||
|
selectedVersion.value =
|
||||||
|
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHide = () => {
|
||||||
|
hardReset.value = false;
|
||||||
|
selectedVersion.value = "";
|
||||||
|
isLoading.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = () => modal.value?.show();
|
||||||
|
const hide = () => modal.value?.hide();
|
||||||
|
|
||||||
|
defineExpose({ show, hide });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stylized-toggle:checked::after {
|
||||||
|
background: var(--color-accent-contrast) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
281
apps/frontend/src/components/ui/servers/PlatformMrpackModal.vue
Normal file
281
apps/frontend/src/components/ui/servers/PlatformMrpackModal.vue
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="mrpackModal" header="Uploading mrpack" @hide="onHide" @show="onShow">
|
||||||
|
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||||
|
<p
|
||||||
|
v-if="isMrpackModalSecondPhase"
|
||||||
|
:style="{
|
||||||
|
lineHeight: isMrpackModalSecondPhase ? '1.5' : undefined,
|
||||||
|
marginBottom: isMrpackModalSecondPhase ? '-12px' : '0',
|
||||||
|
marginTop: isMrpackModalSecondPhase ? '-4px' : '-2px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
This will reinstall your server and erase all data. You may want to back up your server
|
||||||
|
before proceeding. Are you sure you want to continue?
|
||||||
|
</p>
|
||||||
|
<div v-if="!isMrpackModalSecondPhase" class="flex flex-col gap-4">
|
||||||
|
<div class="mx-auto flex flex-row items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||||
|
>
|
||||||
|
<UploadIcon class="size-10" />
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="size-10"
|
||||||
|
>
|
||||||
|
<path d="M5 9v6" />
|
||||||
|
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||||
|
>
|
||||||
|
<ServerIcon class="size-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="text-sm font-bold text-contrast">Upload mrpack</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".mrpack"
|
||||||
|
class=""
|
||||||
|
:disabled="isLoading"
|
||||||
|
@change="uploadMrpack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="flex w-full flex-row items-center justify-between">
|
||||||
|
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||||
|
Erase all data
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="hard-reset"
|
||||||
|
v-model="hardReset"
|
||||||
|
class="switch stylized-toggle shrink-0"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||||
|
then reinstalls it with the selected version.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="flex w-full flex-row items-center justify-between">
|
||||||
|
<label class="w-full text-lg font-bold text-contrast" for="backup-server-mrpack">
|
||||||
|
Backup server
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="backup-server-mrpack"
|
||||||
|
v-model="backupServer"
|
||||||
|
class="switch stylized-toggle shrink-0"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>Creates a backup of your server before proceeding.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-start gap-4">
|
||||||
|
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||||
|
<button :disabled="canInstall" @click="handleReinstall">
|
||||||
|
<RightArrowIcon />
|
||||||
|
{{
|
||||||
|
isBackingUp
|
||||||
|
? "Backing up..."
|
||||||
|
: isMrpackModalSecondPhase
|
||||||
|
? "Erase and install"
|
||||||
|
: loadingServerCheck
|
||||||
|
? "Loading..."
|
||||||
|
: isDangerous
|
||||||
|
? "Erase and install"
|
||||||
|
: "Install"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="
|
||||||
|
if (isMrpackModalSecondPhase) {
|
||||||
|
isMrpackModalSecondPhase = false;
|
||||||
|
} else {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
{{ isMrpackModalSecondPhase ? "Go back" : "Cancel" }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
|
import { UploadIcon, RightArrowIcon, XIcon, ServerIcon } from "@modrinth/assets";
|
||||||
|
import type { Server } from "~/composables/pyroServers";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reinstall: [any?];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const mrpackModal = ref();
|
||||||
|
const isMrpackModalSecondPhase = ref(false);
|
||||||
|
const hardReset = ref(false);
|
||||||
|
const backupServer = ref(false);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isBackingUp = ref(false);
|
||||||
|
const loadingServerCheck = ref(false);
|
||||||
|
const mrpackFile = ref<File | null>(null);
|
||||||
|
|
||||||
|
const isDangerous = computed(() => hardReset.value);
|
||||||
|
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
||||||
|
|
||||||
|
const uploadMrpack = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (!target.files || target.files.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mrpackFile.value = target.files[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const performBackup = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const date = new Date();
|
||||||
|
const format = date.toLocaleString(navigator.language || "en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric",
|
||||||
|
timeZoneName: "short",
|
||||||
|
});
|
||||||
|
const backupName = `Reinstallation - ${format}`;
|
||||||
|
isLoading.value = true;
|
||||||
|
const backupId = await props.server.backups?.create(backupName);
|
||||||
|
isBackingUp.value = true;
|
||||||
|
let attempts = 0;
|
||||||
|
while (true) {
|
||||||
|
attempts++;
|
||||||
|
if (attempts > 100) {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Backup Failed",
|
||||||
|
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await props.server.refresh(["backups"]);
|
||||||
|
const backups = await props.server.backups?.data;
|
||||||
|
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||||
|
if (backup && !backup.ongoing) {
|
||||||
|
isBackingUp.value = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Backup Failed",
|
||||||
|
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReinstall = async () => {
|
||||||
|
if (hardReset.value && !backupServer.value && !isMrpackModalSecondPhase.value) {
|
||||||
|
isMrpackModalSecondPhase.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupServer.value && !(await performBackup())) {
|
||||||
|
isLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!mrpackFile.value) {
|
||||||
|
throw new Error("No mrpack file selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
const mrpack = new File([mrpackFile.value], mrpackFile.value.name, {
|
||||||
|
type: mrpackFile.value.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
await props.server.general?.reinstallFromMrpack(mrpack, hardReset.value);
|
||||||
|
|
||||||
|
emit("reinstall", {
|
||||||
|
loader: "mrpack",
|
||||||
|
lVersion: "",
|
||||||
|
mVersion: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
hide();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Cannot reinstall server",
|
||||||
|
text: "You are being rate limited. Please try again later.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Reinstall Failed",
|
||||||
|
text: "An unexpected error occurred while reinstalling. Please try again later.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onShow = () => {
|
||||||
|
hardReset.value = false;
|
||||||
|
backupServer.value = false;
|
||||||
|
isMrpackModalSecondPhase.value = false;
|
||||||
|
loadingServerCheck.value = false;
|
||||||
|
isLoading.value = false;
|
||||||
|
mrpackFile.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHide = () => {
|
||||||
|
onShow();
|
||||||
|
};
|
||||||
|
|
||||||
|
const show = () => mrpackModal.value?.show();
|
||||||
|
const hide = () => mrpackModal.value?.hide();
|
||||||
|
|
||||||
|
defineExpose({ show, hide });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stylized-toggle:checked::after {
|
||||||
|
background: var(--color-accent-contrast) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,551 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="versionSelectModal"
|
||||||
|
:header="
|
||||||
|
isSecondPhase
|
||||||
|
? 'Confirming reinstallation'
|
||||||
|
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
|
||||||
|
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
|
||||||
|
"
|
||||||
|
@hide="onHide"
|
||||||
|
@show="onShow"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||||
|
<p
|
||||||
|
v-if="isSecondPhase"
|
||||||
|
:style="{
|
||||||
|
lineHeight: isSecondPhase ? '1.5' : undefined,
|
||||||
|
marginBottom: isSecondPhase ? '-12px' : '0',
|
||||||
|
marginTop: isSecondPhase ? '-4px' : '-2px',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
backupServer
|
||||||
|
? "A backup will be created before proceeding with the reinstallation, then all data will be erased from your server. Are you sure you want to continue?"
|
||||||
|
: "This will reinstall your server and erase all data. Are you sure you want to continue?"
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
|
||||||
|
<div class="mx-auto flex flex-row items-center gap-4">
|
||||||
|
<div
|
||||||
|
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||||
|
>
|
||||||
|
<UiServersIconsLoaderIcon class="size-10" :loader="selectedLoader" />
|
||||||
|
</div>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="size-10"
|
||||||
|
>
|
||||||
|
<path d="M5 9v6" />
|
||||||
|
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||||
|
</svg>
|
||||||
|
<div
|
||||||
|
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||||
|
>
|
||||||
|
<ServerIcon class="size-10" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="text-sm font-bold text-contrast">Minecraft version</div>
|
||||||
|
<UiServersTeleportDropdownMenu
|
||||||
|
v-model="selectedMCVersion"
|
||||||
|
name="mcVersion"
|
||||||
|
:options="mcVersions"
|
||||||
|
class="w-full max-w-[100%]"
|
||||||
|
placeholder="Select Minecraft version..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
|
||||||
|
class="flex w-full flex-col gap-2 rounded-2xl p-4"
|
||||||
|
:class="{
|
||||||
|
'bg-table-alternateRow':
|
||||||
|
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
|
||||||
|
'bg-highlight-red':
|
||||||
|
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="text-sm font-bold text-contrast">{{ selectedLoader }} version</div>
|
||||||
|
|
||||||
|
<template v-if="!selectedMCVersion">
|
||||||
|
<div
|
||||||
|
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
|
||||||
|
>
|
||||||
|
Select a Minecraft version to see available versions
|
||||||
|
<DropdownIcon class="absolute right-4" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="isLoading">
|
||||||
|
<div
|
||||||
|
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
|
||||||
|
>
|
||||||
|
<UiServersIconsLoadingIcon class="mr-2 animate-spin" />
|
||||||
|
Loading versions...
|
||||||
|
<DropdownIcon class="absolute right-4" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="selectedLoaderVersions.length > 0">
|
||||||
|
<UiServersTeleportDropdownMenu
|
||||||
|
v-model="selectedLoaderVersion"
|
||||||
|
name="loaderVersion"
|
||||||
|
:options="selectedLoaderVersions"
|
||||||
|
class="w-full max-w-[100%]"
|
||||||
|
:placeholder="
|
||||||
|
selectedLoader.toLowerCase() === 'paper' ||
|
||||||
|
selectedLoader.toLowerCase() === 'purpur'
|
||||||
|
? `Select build number...`
|
||||||
|
: `Select loader version...`
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="flex w-full flex-row items-center justify-between">
|
||||||
|
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||||
|
Erase all data
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="hard-reset"
|
||||||
|
v-model="hardReset"
|
||||||
|
class="switch stylized-toggle shrink-0"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||||
|
then reinstalls it with the selected version.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||||
|
<div class="flex w-full flex-row items-center justify-between">
|
||||||
|
<label class="w-full text-lg font-bold text-contrast" for="backup-server">
|
||||||
|
Backup server
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="backup-server"
|
||||||
|
v-model="backupServer"
|
||||||
|
class="switch stylized-toggle shrink-0"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Creates a backup of your server before proceeding with the installation or
|
||||||
|
reinstallation.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex justify-start gap-4">
|
||||||
|
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||||
|
<button :disabled="canInstall" @click="handleReinstall">
|
||||||
|
<RightArrowIcon />
|
||||||
|
{{
|
||||||
|
isBackingUp
|
||||||
|
? "Backing up..."
|
||||||
|
: isLoading
|
||||||
|
? "Installing..."
|
||||||
|
: isSecondPhase
|
||||||
|
? "Erase and install"
|
||||||
|
: hardReset
|
||||||
|
? "Continue"
|
||||||
|
: "Install"
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button
|
||||||
|
:disabled="isLoading"
|
||||||
|
@click="
|
||||||
|
if (isSecondPhase) {
|
||||||
|
isSecondPhase = false;
|
||||||
|
} else {
|
||||||
|
hide();
|
||||||
|
}
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<XIcon />
|
||||||
|
{{ isSecondPhase ? "Go back" : "Cancel" }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||||
|
import { RightArrowIcon, XIcon, ServerIcon, DropdownIcon } from "@modrinth/assets";
|
||||||
|
import type { Server } from "~/composables/pyroServers";
|
||||||
|
import type { Loaders } from "~/types/servers";
|
||||||
|
|
||||||
|
interface LoaderVersion {
|
||||||
|
id: string;
|
||||||
|
stable: boolean;
|
||||||
|
loaders: {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
stable: boolean;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type VersionMap = Record<string, LoaderVersion[]>;
|
||||||
|
type VersionCache = Record<string, any>;
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
|
currentLoader: Loaders | undefined;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
reinstall: [any?];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const versionSelectModal = ref();
|
||||||
|
const isSecondPhase = ref(false);
|
||||||
|
const hardReset = ref(false);
|
||||||
|
const backupServer = ref(false);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const isBackingUp = ref(false);
|
||||||
|
const loadingServerCheck = ref(false);
|
||||||
|
const serverCheckError = ref("");
|
||||||
|
|
||||||
|
const selectedLoader = ref<Loaders>("Vanilla");
|
||||||
|
const selectedMCVersion = ref("");
|
||||||
|
const selectedLoaderVersion = ref("");
|
||||||
|
|
||||||
|
const paperVersions = ref<Record<string, number[]>>({});
|
||||||
|
const purpurVersions = ref<Record<string, string[]>>({});
|
||||||
|
const loaderVersions = ref<VersionMap>({});
|
||||||
|
const cachedVersions = ref<VersionCache>({});
|
||||||
|
|
||||||
|
const versionStrings = ["forge", "fabric", "quilt", "neo"] as const;
|
||||||
|
|
||||||
|
const fetchLoaderVersions = async () => {
|
||||||
|
const versions = await Promise.all(
|
||||||
|
versionStrings.map(async (loader) => {
|
||||||
|
const runFetch = async (iterations: number) => {
|
||||||
|
if (iterations > 5) {
|
||||||
|
throw new Error("Failed to fetch loader versions");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await $fetch(`/loader-versions?loader=${loader}`);
|
||||||
|
return { [loader]: (res as any).gameVersions };
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
} catch (_) {
|
||||||
|
return await runFetch(iterations + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
return await runFetch(0);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return { [loader]: [] };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPaperVersions = async (mcVersion: string) => {
|
||||||
|
try {
|
||||||
|
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`);
|
||||||
|
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a);
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPurpurVersions = async (mcVersion: string) => {
|
||||||
|
try {
|
||||||
|
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`);
|
||||||
|
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
|
||||||
|
(a: string, b: string) => parseInt(b) - parseInt(a),
|
||||||
|
);
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedLoaderVersions = computed(() => {
|
||||||
|
const loader = selectedLoader.value.toLowerCase();
|
||||||
|
|
||||||
|
if (loader === "paper") {
|
||||||
|
return paperVersions.value[selectedMCVersion.value] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loader === "purpur") {
|
||||||
|
return purpurVersions.value[selectedMCVersion.value] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loader === "vanilla") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let apiLoader = loader;
|
||||||
|
if (loader === "neoforge") {
|
||||||
|
apiLoader = "neo";
|
||||||
|
}
|
||||||
|
|
||||||
|
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
|
||||||
|
// eslint-disable-next-line no-template-curly-in-string
|
||||||
|
(x) => x.id === "${modrinth.gameVersion}",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (backwardsCompatibleVersion) {
|
||||||
|
return backwardsCompatibleVersion.loaders.map((x) => x.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
loaderVersions.value[apiLoader]
|
||||||
|
?.find((x) => x.id === selectedMCVersion.value)
|
||||||
|
?.loaders.map((x) => x.id) || []
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(selectedLoader, async () => {
|
||||||
|
if (selectedMCVersion.value) {
|
||||||
|
selectedLoaderVersion.value = "";
|
||||||
|
serverCheckError.value = "";
|
||||||
|
|
||||||
|
await checkVersionAvailability(selectedMCVersion.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedLoaderVersions,
|
||||||
|
(newVersions) => {
|
||||||
|
if (newVersions.length > 0 && !selectedLoaderVersion.value) {
|
||||||
|
selectedLoaderVersion.value = String(newVersions[0]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkVersionAvailability = async (version: string) => {
|
||||||
|
if (!version || version.trim().length < 3) return;
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
loadingServerCheck.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mcRes =
|
||||||
|
cachedVersions.value[version] ||
|
||||||
|
(await $fetch(`/loader-versions?loader=minecraft&version=${version}`));
|
||||||
|
|
||||||
|
cachedVersions.value[version] = mcRes;
|
||||||
|
|
||||||
|
if (!mcRes.downloads?.server) {
|
||||||
|
serverCheckError.value = "We couldn't find a server.jar for this version.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = selectedLoader.value.toLowerCase();
|
||||||
|
if (loader === "paper" || loader === "purpur") {
|
||||||
|
const fetchFn = loader === "paper" ? fetchPaperVersions : fetchPurpurVersions;
|
||||||
|
const result = await fetchFn(version);
|
||||||
|
if (!result) {
|
||||||
|
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serverCheckError.value = "";
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
serverCheckError.value = "Failed to fetch versions.";
|
||||||
|
} finally {
|
||||||
|
loadingServerCheck.value = false;
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(selectedMCVersion, checkVersionAvailability);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchLoaderVersions();
|
||||||
|
});
|
||||||
|
|
||||||
|
const tags = useTags();
|
||||||
|
const mcVersions = tags.value.gameVersions
|
||||||
|
.filter((x) => x.version_type === "release")
|
||||||
|
.map((x) => x.version)
|
||||||
|
.filter((x) => {
|
||||||
|
const segment = parseInt(x.split(".")[1], 10);
|
||||||
|
return !isNaN(segment) && segment > 2;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isDangerous = computed(() => hardReset.value);
|
||||||
|
const canInstall = computed(() => {
|
||||||
|
const conds =
|
||||||
|
!selectedMCVersion.value ||
|
||||||
|
isLoading.value ||
|
||||||
|
loadingServerCheck.value ||
|
||||||
|
serverCheckError.value.trim().length > 0;
|
||||||
|
|
||||||
|
if (selectedLoader.value.toLowerCase() === "vanilla") {
|
||||||
|
return conds;
|
||||||
|
}
|
||||||
|
|
||||||
|
return conds || !selectedLoaderVersion.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const performBackup = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const date = new Date();
|
||||||
|
const format = date.toLocaleString(navigator.language || "en-US", {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
year: "numeric",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
second: "numeric",
|
||||||
|
timeZoneName: "short",
|
||||||
|
});
|
||||||
|
const backupName = `Reinstallation - ${format}`;
|
||||||
|
isLoading.value = true;
|
||||||
|
const backupId = await props.server.backups?.create(backupName);
|
||||||
|
isBackingUp.value = true;
|
||||||
|
let attempts = 0;
|
||||||
|
while (true) {
|
||||||
|
attempts++;
|
||||||
|
if (attempts > 100) {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Backup Failed",
|
||||||
|
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
await props.server.refresh(["backups"]);
|
||||||
|
const backups = await props.server.backups?.data;
|
||||||
|
const backup = backupId ? backups?.find((x) => x.id === backupId) : undefined;
|
||||||
|
if (backup && !backup.ongoing) {
|
||||||
|
isBackingUp.value = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Backup Failed",
|
||||||
|
text: "An unexpected error occurred while backing up. Please try again later.",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReinstall = async () => {
|
||||||
|
if (hardReset.value && !isSecondPhase.value) {
|
||||||
|
isSecondPhase.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupServer.value) {
|
||||||
|
isBackingUp.value = true;
|
||||||
|
if (!(await performBackup())) {
|
||||||
|
isBackingUp.value = false;
|
||||||
|
isLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isBackingUp.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await props.server.general?.reinstall(
|
||||||
|
props.server.serverId,
|
||||||
|
true,
|
||||||
|
selectedLoader.value,
|
||||||
|
selectedMCVersion.value,
|
||||||
|
selectedLoader.value === "Vanilla" ? "" : selectedLoaderVersion.value,
|
||||||
|
hardReset.value,
|
||||||
|
);
|
||||||
|
|
||||||
|
emit("reinstall", {
|
||||||
|
loader: selectedLoader.value,
|
||||||
|
lVersion: selectedLoaderVersion.value,
|
||||||
|
mVersion: selectedMCVersion.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
hide();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof PyroFetchError && error.statusCode === 429) {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Cannot reinstall server",
|
||||||
|
text: "You are being rate limited. Please try again later.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
addNotification({
|
||||||
|
group: "server",
|
||||||
|
title: "Reinstall Failed",
|
||||||
|
text: "An unexpected error occurred while reinstalling. Please try again later.",
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onShow = () => {
|
||||||
|
selectedMCVersion.value = props.server.general?.mc_version || "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const onHide = () => {
|
||||||
|
hardReset.value = false;
|
||||||
|
backupServer.value = false;
|
||||||
|
isSecondPhase.value = false;
|
||||||
|
serverCheckError.value = "";
|
||||||
|
loadingServerCheck.value = false;
|
||||||
|
isLoading.value = false;
|
||||||
|
selectedMCVersion.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();
|
||||||
|
};
|
||||||
|
const hide = () => versionSelectModal.value?.hide();
|
||||||
|
|
||||||
|
defineExpose({ show, hide });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stylized-toggle:checked::after {
|
||||||
|
background: var(--color-accent-contrast) !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-[400px] w-full max-w-xl flex-col overflow-hidden">
|
|
||||||
<div class="iconified-input mb-4 w-full">
|
|
||||||
<label class="hidden" for="search">Search</label>
|
|
||||||
<SearchIcon aria-hidden="true" />
|
|
||||||
<input
|
|
||||||
id="search"
|
|
||||||
v-model="queryFilter"
|
|
||||||
name="search"
|
|
||||||
type="search"
|
|
||||||
:placeholder="`Search ${props.type}s...`"
|
|
||||||
autocomplete="off"
|
|
||||||
@keyup.enter="resetList"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex h-full w-full flex-col">
|
|
||||||
<div
|
|
||||||
v-if="mods && mods.hits.length > 0"
|
|
||||||
ref="scrollContainer"
|
|
||||||
class="flex h-full w-full flex-col gap-2 overflow-y-scroll"
|
|
||||||
>
|
|
||||||
<div v-for="mod in mods.hits" :key="mod.title" class="rounded-lg px-2 py-2 hover:bg-bg">
|
|
||||||
<div class="flex cursor-pointer gap-2" @click="toggleMod(mod.project_id)">
|
|
||||||
<UiAvatar :src="mod.icon_url" class="!h-12 !min-h-12 !w-12 !min-w-12" />
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<h1 class="m-0 text-2xl font-bold leading-none text-contrast">
|
|
||||||
{{ mod.title }}
|
|
||||||
</h1>
|
|
||||||
<span class="text-sm text-secondary">
|
|
||||||
{{ mod.description.substring(0, 100) }}
|
|
||||||
{{ mod.description.length > 100 ? "..." : "" }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="expandedMods[mod.project_id]" class="mt-2 flex items-center gap-2">
|
|
||||||
<DropdownSelect
|
|
||||||
id="version-select"
|
|
||||||
v-model="selectedVersions[mod.project_id]"
|
|
||||||
name="version-select"
|
|
||||||
:options="expandedMods[mod.project_id].versions"
|
|
||||||
placeholder="Select version..."
|
|
||||||
/>
|
|
||||||
<Button icon-only @click="emits('select', mod, selectedVersions[mod.project_id])">
|
|
||||||
<ChevronRightIcon />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ChevronRightIcon, SearchIcon } from "@modrinth/assets";
|
|
||||||
import { Button, DropdownSelect } from "@modrinth/ui";
|
|
||||||
import { useInfiniteScroll } from "@vueuse/core";
|
|
||||||
|
|
||||||
const emits = defineEmits(["select"]);
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
type: "mod" | "modpack" | "plugin" | "datapack";
|
|
||||||
isserver?: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const route = useNativeRoute();
|
|
||||||
const serverId = route.params.id as string;
|
|
||||||
const server = serverId ? await usePyroServer(serverId, ["general"]) : null;
|
|
||||||
|
|
||||||
const data = computed(() => (serverId ? server?.general : null));
|
|
||||||
|
|
||||||
const scrollContainer = ref<HTMLElement | null>(null);
|
|
||||||
const pages = ref(1);
|
|
||||||
const page = ref(0);
|
|
||||||
|
|
||||||
const queryFilter = ref("");
|
|
||||||
const facets = ref<any>([]);
|
|
||||||
|
|
||||||
if (props.isserver === false && props.type !== "modpack") {
|
|
||||||
facets.value.push(`["categories:${data.value?.loader?.toLocaleLowerCase()}"]`);
|
|
||||||
facets.value.push(`["versions:${data.value?.mc_version}"]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
facets.value.push(`["project_type:${props.type}"]`);
|
|
||||||
|
|
||||||
const buildFacetString = (facets: string[]) => {
|
|
||||||
return "[" + facets.map((facet) => `${facet}`).join(",") + "]";
|
|
||||||
};
|
|
||||||
|
|
||||||
const mods = ref<any>({ hits: [] });
|
|
||||||
const modsStatus = ref("idle");
|
|
||||||
|
|
||||||
const loadMods = async () => {
|
|
||||||
modsStatus.value = "loading";
|
|
||||||
|
|
||||||
const newMods = (await useBaseFetch(
|
|
||||||
`search?query=${queryFilter.value}&facets=${buildFacetString(facets.value)}&index=relevance&limit=25&offset=${page.value * 25}`,
|
|
||||||
{},
|
|
||||||
false,
|
|
||||||
)) as any;
|
|
||||||
pages.value = newMods.total_hits;
|
|
||||||
mods.value.hits.push(...newMods.hits);
|
|
||||||
modsStatus.value = "success";
|
|
||||||
};
|
|
||||||
|
|
||||||
const versions = reactive<{ [key: string]: any[] }>({});
|
|
||||||
|
|
||||||
const getVersions = async (projectId: string) => {
|
|
||||||
if (!versions[projectId]) {
|
|
||||||
const allVersions = (await useBaseFetch(`project/${projectId}/version`, {}, false)) as any;
|
|
||||||
|
|
||||||
if (props.isserver === false && props.type !== "modpack") {
|
|
||||||
versions[projectId] = allVersions
|
|
||||||
.filter((x: any) => x.loaders.includes(data.value?.loader?.toLocaleLowerCase()))
|
|
||||||
.filter((x: any) => x.game_versions.includes(data.value?.mc_version))
|
|
||||||
.map((x: any) => x.version_number);
|
|
||||||
} else {
|
|
||||||
versions[projectId] = allVersions.map((x: any) => x.version_number);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return versions[projectId];
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedVersions = reactive<{ [key: string]: string }>({});
|
|
||||||
|
|
||||||
const expandedMods = reactive<{ [key: string]: { expanded: boolean; versions: any[] } }>({});
|
|
||||||
|
|
||||||
const toggleMod = async (modId: string) => {
|
|
||||||
if (!expandedMods[modId]) {
|
|
||||||
expandedMods[modId] = { expanded: false, versions: [] };
|
|
||||||
}
|
|
||||||
expandedMods[modId].expanded = !expandedMods[modId].expanded;
|
|
||||||
if (expandedMods[modId].expanded && expandedMods[modId].versions.length === 0) {
|
|
||||||
expandedMods[modId].versions = await getVersions(modId);
|
|
||||||
// Select the first version by default
|
|
||||||
if (expandedMods[modId].versions.length > 0) {
|
|
||||||
selectedVersions[modId] = expandedMods[modId].versions[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const loadMore = async () => {
|
|
||||||
page.value++;
|
|
||||||
await loadMods();
|
|
||||||
};
|
|
||||||
|
|
||||||
const { reset } = useInfiniteScroll(scrollContainer, async () => {
|
|
||||||
if (page.value <= pages.value) {
|
|
||||||
await loadMore();
|
|
||||||
console.log("loading more");
|
|
||||||
console.log(page.value);
|
|
||||||
console.log(pages.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetList = () => {
|
|
||||||
mods.value.hits = [];
|
|
||||||
Object.keys(expandedMods).forEach((key) => delete expandedMods[key]);
|
|
||||||
Object.keys(selectedVersions).forEach((key) => delete selectedVersions[key]);
|
|
||||||
page.value = 0;
|
|
||||||
loadMods();
|
|
||||||
reset();
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadMods();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex h-[70vh] w-full flex-col items-center justify-center">
|
|
||||||
<PyroIcon class="pyro-logo-animation size-32 opacity-10" />
|
|
||||||
<p
|
|
||||||
class="text-sm transition"
|
|
||||||
:class="{ 'opacity-0': !showLoading, 'animate-pulse opacity-100': showLoading }"
|
|
||||||
>
|
|
||||||
Loading...
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, onMounted } from "vue";
|
|
||||||
import { PyroIcon } from "@modrinth/assets";
|
|
||||||
|
|
||||||
const showLoading = ref(false);
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
showLoading.value = true;
|
|
||||||
}, 5000);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.page-enter-active,
|
|
||||||
.page-leave-active {
|
|
||||||
transition: all 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-enter-from,
|
|
||||||
.page-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes zoom-in {
|
|
||||||
0% {
|
|
||||||
transform: scale(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.pyro-logo-animation {
|
|
||||||
animation: zoom-in 0.8s
|
|
||||||
linear(
|
|
||||||
0 0%,
|
|
||||||
0.01 0.8%,
|
|
||||||
0.04 1.6%,
|
|
||||||
0.161 3.3%,
|
|
||||||
0.816 9.4%,
|
|
||||||
1.046 11.9%,
|
|
||||||
1.189 14.4%,
|
|
||||||
1.231 15.7%,
|
|
||||||
1.254 17%,
|
|
||||||
1.259 17.8%,
|
|
||||||
1.257 18.6%,
|
|
||||||
1.236 20.45%,
|
|
||||||
1.194 22.3%,
|
|
||||||
1.057 27%,
|
|
||||||
0.999 29.4%,
|
|
||||||
0.955 32.1%,
|
|
||||||
0.942 33.5%,
|
|
||||||
0.935 34.9%,
|
|
||||||
0.933 36.65%,
|
|
||||||
0.939 38.4%,
|
|
||||||
1 47.3%,
|
|
||||||
1.011 49.95%,
|
|
||||||
1.017 52.6%,
|
|
||||||
1.016 56.4%,
|
|
||||||
1 65.2%,
|
|
||||||
0.996 70.2%,
|
|
||||||
1.001 87.2%,
|
|
||||||
1 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fade-bg-in {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-loading-animation {
|
|
||||||
animation: fade-bg-in 0.12s linear forwards;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div
|
|
||||||
class="flex h-full flex-col gap-4 py-6"
|
|
||||||
:class="
|
|
||||||
'flex h-full flex-col gap-4 py-6' +
|
|
||||||
(danger
|
|
||||||
? ' rounded-2xl border-2 border-solid border-[#cb2245] bg-[#fff5f6] dark:border-[#FF496E] dark:bg-[#270B11]'
|
|
||||||
: '')
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div class="mb-2 flex items-center justify-between gap-4 px-6">
|
|
||||||
<div class="flex w-full items-center gap-4">
|
|
||||||
<UiServersServerIcon v-if="data" :image="data.image" class="h-12 w-12 rounded-lg" />
|
|
||||||
<div class="text-2xl font-extrabold text-contrast">{{ props.header }}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
:class="
|
|
||||||
'h-8 w-8 rounded-full bg-button-bg p-2 text-contrast hover:bg-button-bgActive' +
|
|
||||||
(danger ? 'hover:bg-[#ffffff20] [&&]:bg-[#ffffff10]' : '')
|
|
||||||
"
|
|
||||||
@click="$emit('modal')"
|
|
||||||
>
|
|
||||||
<XIcon class="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="border-0 border-b border-solid"
|
|
||||||
:class="danger ? 'border-[#cb2245] dark:border-[#612d38]' : 'border-divider'"
|
|
||||||
></div>
|
|
||||||
<div class="mt-2 h-full w-full overflow-auto px-6">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { XIcon } from "@modrinth/assets";
|
|
||||||
|
|
||||||
const emit = defineEmits(["modal"]);
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
header?: string;
|
|
||||||
data?: any;
|
|
||||||
danger?: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const onEscKeyRelease = (event: KeyboardEvent) => {
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
emit("modal");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
document.body.addEventListener("keyup", onEscKeyRelease);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
document.removeEventListener("keyup", onEscKeyRelease);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -39,7 +39,7 @@ const props = defineProps<{
|
|||||||
save: () => void;
|
save: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const saveAndRestart = async () => {
|
const saveAndRestart = async () => {
|
||||||
|
|||||||
@@ -8,13 +8,19 @@
|
|||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="isLink"
|
v-if="isLink"
|
||||||
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
||||||
class="min-w-0 truncate text-sm font-semibold"
|
class="flex min-w-0 items-center truncate text-sm font-semibold"
|
||||||
:class="serverId ? 'hover:underline' : ''"
|
:class="serverId ? 'hover:underline' : ''"
|
||||||
>
|
>
|
||||||
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
|
<div class="flex flex-row items-center gap-1">
|
||||||
|
{{ game[0].toUpperCase() + game.slice(1) }}
|
||||||
|
<span v-if="mcVersion">{{ mcVersion }}</span>
|
||||||
|
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
|
||||||
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div v-else class="min-w-0 truncate text-sm font-semibold">
|
<div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
|
||||||
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
|
{{ game[0].toUpperCase() + game.slice(1) }}
|
||||||
|
<span v-if="mcVersion">{{ mcVersion }}</span>
|
||||||
|
<span v-else class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,19 +2,18 @@
|
|||||||
<div>
|
<div>
|
||||||
<UiServersServerGameLabel
|
<UiServersServerGameLabel
|
||||||
v-if="showGameLabel"
|
v-if="showGameLabel"
|
||||||
:game="serverData.game!"
|
:game="serverData.game"
|
||||||
:mc-version="serverData.mc_version ?? ''"
|
:mc-version="serverData.mc_version ?? ''"
|
||||||
:is-link="linked"
|
:is-link="linked"
|
||||||
/>
|
/>
|
||||||
<UiServersServerLoaderLabel
|
<UiServersServerLoaderLabel
|
||||||
v-if="showLoaderLabel"
|
:loader="serverData.loader"
|
||||||
:loader="serverData.loader!"
|
|
||||||
:loader-version="serverData.loader_version ?? ''"
|
:loader-version="serverData.loader_version ?? ''"
|
||||||
:no-separator="column"
|
:no-separator="column"
|
||||||
:is-link="linked"
|
:is-link="linked"
|
||||||
/>
|
/>
|
||||||
<UiServersServerSubdomainLabel
|
<UiServersServerSubdomainLabel
|
||||||
v-if="serverData.net.domain"
|
v-if="serverData.net?.domain"
|
||||||
:subdomain="serverData.net.domain"
|
:subdomain="serverData.net.domain"
|
||||||
:no-separator="column"
|
:no-separator="column"
|
||||||
:is-link="linked"
|
:is-link="linked"
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||||
:show-game-label="showGameLabel"
|
:show-game-label="showGameLabel"
|
||||||
:show-loader-label="showLoaderLabel"
|
:show-loader-label="showLoaderLabel"
|
||||||
:show-subdomain-label="showSubdomainLabel"
|
|
||||||
:linked="false"
|
:linked="false"
|
||||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||||
/>
|
/>
|
||||||
@@ -70,11 +69,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
|
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
|
||||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||||
>
|
>
|
||||||
<UiServersIconsPanelErrorIcon class="!size-5" />
|
<div class="flex flex-row gap-2">
|
||||||
Your server has been suspended due to a billing issue. Please visit your billing settings or
|
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
|
||||||
contact Modrinth Support for more information.
|
update your billing information or contact Modrinth Support for more information.
|
||||||
|
</div>
|
||||||
|
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
@@ -85,9 +86,12 @@ import type { Project, Server } from "~/types/servers";
|
|||||||
|
|
||||||
const props = defineProps<Partial<Server>>();
|
const props = defineProps<Partial<Server>>();
|
||||||
|
|
||||||
|
if (props.server_id) {
|
||||||
|
await usePyroServer(props.server_id, ["general"]);
|
||||||
|
}
|
||||||
|
|
||||||
const showGameLabel = computed(() => !!props.game);
|
const showGameLabel = computed(() => !!props.game);
|
||||||
const showLoaderLabel = computed(() => !!props.loader);
|
const showLoaderLabel = computed(() => !!props.loader);
|
||||||
const showSubdomainLabel = computed(() => !!props.net?.domain);
|
|
||||||
|
|
||||||
let projectData: Ref<Project | null>;
|
let projectData: Ref<Project | null>;
|
||||||
if (props.upstream) {
|
if (props.upstream) {
|
||||||
@@ -103,39 +107,11 @@ if (props.upstream) {
|
|||||||
projectData = ref(null);
|
projectData = ref(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const image = ref<string | undefined>();
|
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
|
||||||
|
|
||||||
onMounted(async () => {
|
if (import.meta.server && projectData.value?.icon_url) {
|
||||||
const auth = (await usePyroFetch(`servers/${props.server_id}/fs`)) as any;
|
await usePyroServer(props.server_id!, ["general"]);
|
||||||
try {
|
}
|
||||||
const fileData = await usePyroFetch(`/download?path=/server-icon-original.png`, {
|
|
||||||
override: auth,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fileData instanceof Blob) {
|
|
||||||
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");
|
|
||||||
image.value = dataURL;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
|
||||||
image.value = undefined;
|
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-4 truncate">
|
||||||
v-if="loader"
|
|
||||||
v-tooltip="'Change server loader'"
|
|
||||||
class="flex min-w-0 flex-row items-center gap-4 truncate"
|
|
||||||
>
|
|
||||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<UiServersIconsLoaderIcon :loader="loader" class="flex shrink-0 [&&]:size-5" />
|
<UiServersIconsLoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
|
||||||
|
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="isLink"
|
v-if="isLink"
|
||||||
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
||||||
class="min-w-0 text-sm font-semibold"
|
class="flex min-w-0 items-center text-sm font-semibold"
|
||||||
:class="serverId ? 'hover:underline' : ''"
|
:class="serverId ? 'hover:underline' : ''"
|
||||||
>
|
>
|
||||||
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
|
<span v-if="loader">
|
||||||
|
{{ loader }}
|
||||||
|
<span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="flex gap-2">
|
||||||
|
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||||
|
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||||
|
</span>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div v-else class="min-w-0 text-sm font-semibold">
|
<div v-else class="min-w-0 text-sm font-semibold">
|
||||||
{{ loader }} <span v-if="loaderVersion">{{ loaderVersion }}</span>
|
<span v-if="loader">
|
||||||
|
{{ loader }}
|
||||||
|
<span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="flex gap-2">
|
||||||
|
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||||
|
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -25,8 +36,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
noSeparator?: boolean;
|
noSeparator?: boolean;
|
||||||
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
loader?: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||||
loaderVersion: string;
|
loaderVersion?: string;
|
||||||
isLink?: boolean;
|
isLink?: boolean;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ const emit = defineEmits(["reinstall"]);
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||||
route: RouteLocationNormalized;
|
route: RouteLocationNormalized;
|
||||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const onReinstall = (...args: any[]) => {
|
const onReinstall = (...args: any[]) => {
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col gap-4">
|
|
||||||
<div
|
|
||||||
v-for="n in count"
|
|
||||||
:key="n"
|
|
||||||
class="relative h-[128px] w-full animate-pulse rounded-3xl bg-bg-raised p-4"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
defineProps({
|
|
||||||
count: {
|
|
||||||
type: Number,
|
|
||||||
default: 3,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
@@ -9,44 +9,34 @@
|
|||||||
:key="index"
|
:key="index"
|
||||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||||
>
|
>
|
||||||
<div
|
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
||||||
class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1"
|
<div class="relative z-10">
|
||||||
:style="{
|
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||||
backdropFilter: 'blur(6px)',
|
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">{{ metric.value }}</h2>
|
||||||
}"
|
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
|
||||||
>
|
</div>
|
||||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
|
||||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">
|
{{ metric.title }}
|
||||||
{{ metric.value }}
|
<WarningIcon
|
||||||
</h2>
|
v-if="metric.warning"
|
||||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
|
v-tooltip="metric.warning"
|
||||||
|
class="size-5"
|
||||||
|
:style="{ color: 'var(--color-orange)' }"
|
||||||
|
/>
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<h3 class="relative z-10 flex items-center gap-2 text-base font-normal text-secondary">
|
<div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
|
||||||
{{ metric.title }}
|
|
||||||
<WarningIcon
|
|
||||||
v-tooltip="getPotentialWarning(metric)"
|
|
||||||
:style="{
|
|
||||||
color: 'var(--color-orange)',
|
|
||||||
width: '1.25rem',
|
|
||||||
height: '1.25rem',
|
|
||||||
display: getPotentialWarning(metric) ? 'block' : 'none',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<component :is="metric.icon" class="absolute right-10 top-10 z-10" />
|
<component :is="metric.icon" class="absolute right-10 top-10 z-10" />
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<VueApexCharts
|
<VueApexCharts
|
||||||
v-if="
|
v-if="metric.showGraph"
|
||||||
metric.data.length && !(metric.title === 'Memory usage' && userPreferences.ramAsNumber)
|
|
||||||
"
|
|
||||||
ref="chart"
|
|
||||||
type="area"
|
type="area"
|
||||||
height="142"
|
height="142"
|
||||||
:options="generateOptions(metric)"
|
:options="getChartOptions(metric.warning)"
|
||||||
:series="[{ name: 'Chart', data: metric.data }]"
|
:series="[{ name: metric.title, data: metric.data }]"
|
||||||
class="chart chart-animation absolute bottom-0 left-0 right-0 w-full"
|
class="chart absolute bottom-0 left-0 right-0 w-full opacity-0"
|
||||||
/>
|
/>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
</div>
|
</div>
|
||||||
@@ -57,21 +47,17 @@
|
|||||||
>
|
>
|
||||||
<div class="flex flex-row items-center gap-2">
|
<div class="flex flex-row items-center gap-2">
|
||||||
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
|
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
|
||||||
{{ formatBytes(animatedStorageUsage) }}
|
{{ formatBytes(stats.storage_usage_bytes) }}
|
||||||
</h2>
|
</h2>
|
||||||
<!-- <h3 class="relative z-10 text-sm font-normal text-secondary">
|
|
||||||
/ {{ formatBytes(props.data.current.storage_total_bytes) }}
|
|
||||||
</h3> -->
|
|
||||||
</div>
|
</div>
|
||||||
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
|
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
||||||
|
|
||||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch } from "vue";
|
import { ref, computed, shallowRef } from "vue";
|
||||||
import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets";
|
import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets";
|
||||||
import { useStorage } from "@vueuse/core";
|
import { useStorage } from "@vueuse/core";
|
||||||
import type { Stats } from "~/types/servers";
|
import type { Stats } from "~/types/servers";
|
||||||
@@ -79,252 +65,132 @@ import WarningIcon from "~/assets/images/utils/issues.svg?component";
|
|||||||
|
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
const serverId = route.params.id;
|
const serverId = route.params.id;
|
||||||
|
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||||
|
|
||||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||||
ramAsNumber: false,
|
ramAsNumber: false,
|
||||||
autoRestart: false,
|
|
||||||
backupWhileRunning: false,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
const props = defineProps<{ data: Stats }>();
|
||||||
|
|
||||||
const props = defineProps({
|
const stats = shallowRef(props.data.current);
|
||||||
data: {
|
|
||||||
type: Object as PropType<Stats>,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const lerp = (a: number, b: number) => {
|
|
||||||
return a + (b - a) * 0.5;
|
|
||||||
};
|
|
||||||
|
|
||||||
// I told you it would go into prod
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
const units = ["Bytes", "KB", "MB", "GB", "TB"];
|
const units = ["B", "KB", "MB", "GB"];
|
||||||
let value = bytes;
|
let value = bytes;
|
||||||
let unitIndex = 0;
|
let unit = 0;
|
||||||
|
while (value >= 1024 && unit < units.length - 1) {
|
||||||
while (value >= 1024 && unitIndex < units.length - 2) {
|
|
||||||
value /= 1024;
|
value /= 1024;
|
||||||
unitIndex++;
|
unit++;
|
||||||
}
|
}
|
||||||
|
return `${Math.round(value * 10) / 10} ${units[unit]}`;
|
||||||
return `${Math.round(value * 100) / 100} ${units[unitIndex]}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const animatedStorageUsage = ref(0);
|
const cpuData = ref<number[]>(Array(20).fill(0));
|
||||||
|
const ramData = ref<number[]>(Array(20).fill(0));
|
||||||
|
|
||||||
const animateValue = (start: number, end: number, duration: number): void => {
|
const updateGraphData = (arr: number[], newValue: number) => {
|
||||||
let startTimestamp: number | null = null;
|
arr.push(newValue);
|
||||||
const step = (timestamp: number) => {
|
arr.shift();
|
||||||
if (!startTimestamp) startTimestamp = timestamp;
|
|
||||||
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
|
|
||||||
animatedStorageUsage.value = Math.floor(progress * (end - start) + start);
|
|
||||||
if (progress < 1) {
|
|
||||||
requestAnimationFrame(step);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
requestAnimationFrame(step);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
const metrics = computed(() => {
|
||||||
animateValue(0, props.data.current.storage_usage_bytes, 250);
|
const ramPercent = Math.min(
|
||||||
|
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
const cpuPercent = Math.min(stats.value.cpu_percent, 100);
|
||||||
|
|
||||||
|
updateGraphData(cpuData.value, cpuPercent);
|
||||||
|
updateGraphData(ramData.value, ramPercent);
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: "CPU usage",
|
||||||
|
value: `${cpuPercent.toFixed(2)}%`,
|
||||||
|
max: "100%",
|
||||||
|
icon: CPUIcon,
|
||||||
|
data: cpuData.value,
|
||||||
|
showGraph: true,
|
||||||
|
warning: cpuPercent >= 90 ? "CPU usage is very high" : null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Memory usage",
|
||||||
|
value: userPreferences.value.ramAsNumber
|
||||||
|
? formatBytes(stats.value.ram_usage_bytes)
|
||||||
|
: `${ramPercent.toFixed(2)}%`,
|
||||||
|
max: userPreferences.value.ramAsNumber ? formatBytes(stats.value.ram_total_bytes) : "100%",
|
||||||
|
icon: DBIcon,
|
||||||
|
data: ramData.value,
|
||||||
|
showGraph: true,
|
||||||
|
warning: ramPercent >= 90 ? "Memory usage is very high" : null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const getChartOptions = (hasWarning: string | null) => ({
|
||||||
|
chart: {
|
||||||
|
type: "area",
|
||||||
|
animations: { enabled: false },
|
||||||
|
sparkline: { enabled: true },
|
||||||
|
toolbar: { show: false },
|
||||||
|
padding: {
|
||||||
|
left: -10,
|
||||||
|
right: -10,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
stroke: { curve: "smooth", width: 3 },
|
||||||
|
fill: {
|
||||||
|
type: "gradient",
|
||||||
|
gradient: {
|
||||||
|
shadeIntensity: 1,
|
||||||
|
opacityFrom: 0.25,
|
||||||
|
opacityTo: 0.05,
|
||||||
|
stops: [0, 100],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: { enabled: false },
|
||||||
|
grid: { show: false },
|
||||||
|
xaxis: {
|
||||||
|
labels: { show: false },
|
||||||
|
axisBorder: { show: false },
|
||||||
|
type: "numeric",
|
||||||
|
tickAmount: 20,
|
||||||
|
range: 20,
|
||||||
|
},
|
||||||
|
yaxis: {
|
||||||
|
show: false,
|
||||||
|
min: 0,
|
||||||
|
max: 100,
|
||||||
|
forceNiceScale: false,
|
||||||
|
},
|
||||||
|
colors: [hasWarning ? "var(--color-orange)" : "var(--color-brand)"],
|
||||||
|
dataLabels: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.data.current.storage_usage_bytes,
|
() => props.data.current,
|
||||||
(newValue, oldValue) => {
|
(newStats) => {
|
||||||
animateValue(oldValue, newValue, 250);
|
stats.value = newStats;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
const metrics = ref([
|
|
||||||
{
|
|
||||||
title: "CPU usage",
|
|
||||||
value: "0%",
|
|
||||||
max: "100%",
|
|
||||||
icon: markRaw(CPUIcon),
|
|
||||||
data: [] as number[],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Memory usage",
|
|
||||||
value: "0%",
|
|
||||||
max: userPreferences.value.ramAsNumber
|
|
||||||
? formatBytes(props.data.current.ram_total_bytes)
|
|
||||||
: "100%",
|
|
||||||
icon: markRaw(DBIcon),
|
|
||||||
data: [] as number[],
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const updateMetrics = () => {
|
|
||||||
console.log(props.data.current.ram_usage_bytes);
|
|
||||||
metrics.value = metrics.value.map((metric, index) => {
|
|
||||||
if (userPreferences.value.ramAsNumber && index === 1) {
|
|
||||||
return {
|
|
||||||
...metric,
|
|
||||||
value: formatBytes(props.data.current.ram_usage_bytes),
|
|
||||||
data: [...metric.data.slice(-10), props.data.current.ram_usage_bytes],
|
|
||||||
max: formatBytes(props.data.current.ram_total_bytes),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
const currentValue =
|
|
||||||
index === 0
|
|
||||||
? props.data.current.cpu_percent
|
|
||||||
: Math.min(
|
|
||||||
(props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100,
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
const pastValue =
|
|
||||||
index === 0
|
|
||||||
? props.data.past.cpu_percent
|
|
||||||
: Math.min(
|
|
||||||
(props.data.past.ram_usage_bytes / props.data.past.ram_total_bytes) * 100,
|
|
||||||
100,
|
|
||||||
);
|
|
||||||
|
|
||||||
const newValue = lerp(currentValue, pastValue);
|
|
||||||
return {
|
|
||||||
...metric,
|
|
||||||
value: `${newValue.toFixed(2)}%`,
|
|
||||||
data: [...metric.data.slice(-10), newValue],
|
|
||||||
// data: [36, 36],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// aww, you gotta give em that rinth tuah, mod on that thang
|
|
||||||
const getPotentialWarning = (metric: (typeof metrics.value)[0]) => {
|
|
||||||
// make all words in the string lowercase, unless the word is in all caps
|
|
||||||
const split = metric.title.split(" ");
|
|
||||||
const title = split
|
|
||||||
.map((word) => {
|
|
||||||
if (word === word.toUpperCase()) {
|
|
||||||
return word;
|
|
||||||
}
|
|
||||||
return word.toLowerCase();
|
|
||||||
})
|
|
||||||
.join(" ");
|
|
||||||
let data = metric.data.at(-1) || 0;
|
|
||||||
if (userPreferences.value.ramAsNumber) {
|
|
||||||
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
|
|
||||||
}
|
|
||||||
switch (true) {
|
|
||||||
case data >= 90:
|
|
||||||
return `Your server's ${title} is very high.`;
|
|
||||||
default:
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const generateOptions = (metric: (typeof metrics.value)[0]) => {
|
|
||||||
let color = "var(--color-brand)";
|
|
||||||
let data = metric.data.at(-1) || 0;
|
|
||||||
if (userPreferences.value.ramAsNumber) {
|
|
||||||
data = (props.data.current.ram_usage_bytes / props.data.current.ram_total_bytes) * 100;
|
|
||||||
}
|
|
||||||
switch (true) {
|
|
||||||
case data >= 90:
|
|
||||||
color = "var(--color-red)";
|
|
||||||
break;
|
|
||||||
case data >= 80:
|
|
||||||
color = "var(--color-orange)";
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
chart: {
|
|
||||||
id: "stats",
|
|
||||||
fontFamily:
|
|
||||||
"Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif",
|
|
||||||
foreColor: "var(--color-base)",
|
|
||||||
toolbar: { show: false },
|
|
||||||
zoom: { enabled: false },
|
|
||||||
sparkline: { enabled: true },
|
|
||||||
animations: {
|
|
||||||
enabled: true,
|
|
||||||
easing: "linear",
|
|
||||||
dynamicAnimation: { speed: 1000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
stroke: { curve: "smooth" },
|
|
||||||
fill: {
|
|
||||||
colors: [color],
|
|
||||||
type: "gradient",
|
|
||||||
opacity: 1,
|
|
||||||
gradient: {
|
|
||||||
shade: "light",
|
|
||||||
type: "vertical",
|
|
||||||
shadeIntensity: 0,
|
|
||||||
gradientToColors: [color],
|
|
||||||
inverseColors: true,
|
|
||||||
opacityFrom: 0.5,
|
|
||||||
opacityTo: 0,
|
|
||||||
stops: [0, 100],
|
|
||||||
colorStops: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
grid: { show: false },
|
|
||||||
legend: { show: false },
|
|
||||||
colors: [color],
|
|
||||||
dataLabels: { enabled: false },
|
|
||||||
xaxis: {
|
|
||||||
type: "numeric",
|
|
||||||
lines: { show: false },
|
|
||||||
axisBorder: { show: false },
|
|
||||||
labels: { show: false },
|
|
||||||
},
|
|
||||||
yaxis: {
|
|
||||||
min: 0,
|
|
||||||
max: 100,
|
|
||||||
tickAmount: 5,
|
|
||||||
labels: { show: false },
|
|
||||||
axisBorder: { show: false },
|
|
||||||
axisTicks: { show: false },
|
|
||||||
},
|
|
||||||
tooltip: { enabled: false },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// watch(
|
|
||||||
// metrics,
|
|
||||||
// () => {
|
|
||||||
// console.log(metrics.value[0].data.at(-1));
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// deep: true,
|
|
||||||
// immediate: true,
|
|
||||||
// },
|
|
||||||
// );
|
|
||||||
|
|
||||||
let interval: number;
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
updateMetrics();
|
|
||||||
interval = window.setInterval(updateMetrics, 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (interval) {
|
|
||||||
clearInterval(interval);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@keyframes chart-enter-animation {
|
.chart {
|
||||||
0% {
|
animation: fadeIn 0.2s ease-out 0.2s forwards;
|
||||||
opacity: 0;
|
margin-left: -24px;
|
||||||
}
|
margin-right: -24px;
|
||||||
100% {
|
width: calc(100% + 48px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-animation {
|
|
||||||
opacity: 0;
|
|
||||||
animation: chart-enter-animation 0.5s ease-out forwards;
|
|
||||||
animation-delay: 1s;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="subdomain"
|
v-if="subdomain && !isHidden"
|
||||||
v-tooltip="'Copy custom URL'"
|
v-tooltip="'Copy custom URL'"
|
||||||
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
||||||
>
|
>
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LinkIcon } from "@modrinth/assets";
|
import { LinkIcon } from "@modrinth/assets";
|
||||||
|
import { useStorage } from "@vueuse/core";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
noSeparator?: boolean;
|
noSeparator?: boolean;
|
||||||
@@ -29,12 +31,18 @@ const copySubdomain = () => {
|
|||||||
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
|
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "servers",
|
group: "servers",
|
||||||
title: "Subdomain copied",
|
title: "Custom URL copied",
|
||||||
text: "Your subdomain has been copied to your clipboard.",
|
text: "Your server's URL has been copied to your clipboard.",
|
||||||
type: "success",
|
type: "success",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
const serverId = computed(() => route.params.id as string);
|
const serverId = computed(() => route.params.id as string);
|
||||||
|
|
||||||
|
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
|
||||||
|
hideSubdomainLabel: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isHidden = computed(() => userPreferences.value.hideSubdomainLabel);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@
|
|||||||
<div
|
<div
|
||||||
v-if="uptimeSeconds || uptimeSeconds !== 0"
|
v-if="uptimeSeconds || uptimeSeconds !== 0"
|
||||||
v-tooltip="`Online for ${verboseUptime}`"
|
v-tooltip="`Online for ${verboseUptime}`"
|
||||||
class="flex min-w-0 flex-row items-center gap-4"
|
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-4"
|
||||||
data-pyro-uptime
|
data-pyro-uptime
|
||||||
>
|
>
|
||||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<UiServersTimer class="flex size-5 shrink-0" />
|
<UiServersIconsTimer class="flex size-5 shrink-0" />
|
||||||
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
|
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
|
||||||
{{ formattedUptime }}
|
{{ formattedUptime }}
|
||||||
</time>
|
</time>
|
||||||
|
|||||||
@@ -1,28 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="relative inline-block h-9 w-full max-w-80">
|
||||||
ref="dropdown"
|
<button
|
||||||
data-pyro-dropdown
|
ref="triggerRef"
|
||||||
tabindex="0"
|
type="button"
|
||||||
role="combobox"
|
aria-haspopup="listbox"
|
||||||
:aria-expanded="dropdownVisible"
|
:aria-expanded="dropdownVisible"
|
||||||
class="relative inline-block h-9 w-full max-w-80"
|
:aria-controls="listboxId"
|
||||||
@focus="onFocus"
|
:aria-labelledby="listboxId"
|
||||||
@blur="onBlur"
|
class="duration-50 flex h-full w-full cursor-pointer select-none appearance-none items-center justify-between gap-4 rounded-xl border-none bg-button-bg px-4 py-2 shadow-sm !outline-none transition-all ease-in-out"
|
||||||
@mousedown.prevent
|
|
||||||
@keydown="handleKeyDown"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
data-pyro-dropdown-trigger
|
|
||||||
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
|
|
||||||
:class="triggerClasses"
|
:class="triggerClasses"
|
||||||
@click="toggleDropdown"
|
@click="toggleDropdown"
|
||||||
|
@keydown="handleTriggerKeyDown"
|
||||||
>
|
>
|
||||||
<span>{{ selectedOption }}</span>
|
<span>{{ selectedOption }}</span>
|
||||||
<DropdownIcon
|
<DropdownIcon
|
||||||
class="transition-transform duration-200 ease-in-out"
|
class="transition-transform duration-200 ease-in-out"
|
||||||
:class="{ 'rotate-180': dropdownVisible }"
|
:class="{ 'rotate-180': dropdownVisible }"
|
||||||
/>
|
/>
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<Teleport to="#teleports">
|
<Teleport to="#teleports">
|
||||||
<transition
|
<transition
|
||||||
@@ -35,27 +30,28 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="dropdownVisible"
|
v-if="dropdownVisible"
|
||||||
|
:id="listboxId"
|
||||||
ref="optionsContainer"
|
ref="optionsContainer"
|
||||||
data-pyro-dropdown-options
|
role="listbox"
|
||||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
tabindex="-1"
|
||||||
|
:aria-activedescendant="activeDescendant"
|
||||||
|
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg outline-none"
|
||||||
:class="{
|
:class="{
|
||||||
'rounded-b-xl': !isRenderingUp,
|
'rounded-b-xl': !isRenderingUp,
|
||||||
'rounded-t-xl': isRenderingUp,
|
'rounded-t-xl': isRenderingUp,
|
||||||
}"
|
}"
|
||||||
:style="positionStyle"
|
:style="positionStyle"
|
||||||
@keydown.stop="handleDropdownKeyDown"
|
@keydown="handleListboxKeyDown"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="overflow-y-auto"
|
class="overflow-y-auto"
|
||||||
:style="{ height: `${virtualListHeight}px` }"
|
:style="{ height: `${virtualListHeight}px` }"
|
||||||
data-pyro-dropdown-options-virtual-scroller
|
|
||||||
@scroll="handleScroll"
|
@scroll="handleScroll"
|
||||||
>
|
>
|
||||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||||
<div
|
<div
|
||||||
v-for="item in visibleOptions"
|
v-for="item in visibleOptions"
|
||||||
:key="item.index"
|
:key="item.index"
|
||||||
data-pyro-dropdown-option
|
|
||||||
:style="{
|
:style="{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -65,32 +61,20 @@
|
|||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
:id="`${listboxId}-option-${item.index}`"
|
||||||
role="option"
|
role="option"
|
||||||
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
|
:aria-selected="selectedValue === item.option"
|
||||||
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
|
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||||
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
|
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
|
||||||
'rounded-t-xl': item.index === 0 && isRenderingUp,
|
'rounded-t-xl': item.index === 0 && isRenderingUp,
|
||||||
}"
|
}"
|
||||||
:aria-selected="selectedValue === item.option"
|
|
||||||
@click="selectOption(item.option, item.index)"
|
@click="selectOption(item.option, item.index)"
|
||||||
@mouseover="focusedOptionIndex = item.index"
|
@mousemove="focusedOptionIndex = item.index"
|
||||||
@focus="focusedOptionIndex = item.index"
|
|
||||||
>
|
>
|
||||||
<input
|
{{ displayName(item.option) }}
|
||||||
:id="`${name}-${item.index}`"
|
|
||||||
v-model="radioValue"
|
|
||||||
type="radio"
|
|
||||||
:value="item.option"
|
|
||||||
:name="name"
|
|
||||||
class="hidden"
|
|
||||||
/>
|
|
||||||
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
|
|
||||||
{{ displayName(item.option) }}
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,13 +124,14 @@ const emit = defineEmits<{
|
|||||||
const dropdownVisible = ref(false);
|
const dropdownVisible = ref(false);
|
||||||
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue);
|
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue);
|
||||||
const focusedOptionIndex = ref<number | null>(null);
|
const focusedOptionIndex = ref<number | null>(null);
|
||||||
const focusedOptionRef = ref<HTMLElement | null>(null);
|
|
||||||
const dropdown = ref<HTMLElement | null>(null);
|
|
||||||
const optionsContainer = ref<HTMLElement | null>(null);
|
const optionsContainer = ref<HTMLElement | null>(null);
|
||||||
const scrollTop = ref(0);
|
const scrollTop = ref(0);
|
||||||
const isRenderingUp = ref(false);
|
const isRenderingUp = ref(false);
|
||||||
const virtualListHeight = ref(300);
|
const virtualListHeight = ref(300);
|
||||||
const lastFocusedElement = ref<HTMLElement | null>(null);
|
const isOpen = ref(false);
|
||||||
|
const openDropdownCount = ref(0);
|
||||||
|
const listboxId = `pyro-listbox-${Math.random().toString(36).substring(2, 11)}`;
|
||||||
|
const triggerRef = ref<HTMLButtonElement | null>(null);
|
||||||
|
|
||||||
const positionStyle = ref<CSSProperties>({
|
const positionStyle = ref<CSSProperties>({
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
@@ -156,41 +141,6 @@ const positionStyle = ref<CSSProperties>({
|
|||||||
zIndex: 999,
|
zIndex: 999,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleOptionRef = (el: HTMLElement | null, index: number) => {
|
|
||||||
if (focusedOptionIndex.value === index) {
|
|
||||||
focusedOptionRef.value = el;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onFocus = async () => {
|
|
||||||
if (!props.disabled) {
|
|
||||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
|
|
||||||
lastFocusedElement.value = document.activeElement as HTMLElement;
|
|
||||||
dropdownVisible.value = true;
|
|
||||||
await updatePosition();
|
|
||||||
nextTick(() => {
|
|
||||||
dropdown.value?.focus();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onBlur = (event: FocusEvent) => {
|
|
||||||
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
|
|
||||||
closeDropdown();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
|
|
||||||
let currentNode: HTMLElement | null = element;
|
|
||||||
while (currentNode) {
|
|
||||||
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
currentNode = currentNode.parentElement;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
|
|
||||||
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT);
|
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT);
|
||||||
|
|
||||||
const visibleOptions = computed(() => {
|
const visibleOptions = computed(() => {
|
||||||
@@ -227,16 +177,16 @@ const radioValue = computed<OptionValue>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const triggerClasses = computed(() => ({
|
const triggerClasses = computed(() => ({
|
||||||
"cursor-not-allowed opacity-50 grayscale": props.disabled,
|
"!cursor-not-allowed opacity-50 grayscale": props.disabled,
|
||||||
"rounded-b-none": dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
"rounded-b-none": dropdownVisible.value && !isRenderingUp.value && !props.disabled,
|
||||||
"rounded-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
"rounded-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const updatePosition = async () => {
|
const updatePosition = async () => {
|
||||||
if (!dropdown.value) return;
|
if (!triggerRef.value) return;
|
||||||
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const triggerRect = dropdown.value.getBoundingClientRect();
|
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||||
const viewportHeight = window.innerHeight;
|
const viewportHeight = window.innerHeight;
|
||||||
const margin = 8;
|
const margin = 8;
|
||||||
|
|
||||||
@@ -263,20 +213,6 @@ const updatePosition = async () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDropdown = async () => {
|
|
||||||
if (!props.disabled) {
|
|
||||||
closeAllDropdowns();
|
|
||||||
dropdownVisible.value = true;
|
|
||||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
|
|
||||||
lastFocusedElement.value = document.activeElement as HTMLElement;
|
|
||||||
await updatePosition();
|
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
updatePosition();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleDropdown = () => {
|
const toggleDropdown = () => {
|
||||||
if (!props.disabled) {
|
if (!props.disabled) {
|
||||||
if (dropdownVisible.value) {
|
if (dropdownVisible.value) {
|
||||||
@@ -300,61 +236,6 @@ const handleScroll = (event: Event) => {
|
|||||||
scrollTop.value = target.scrollTop;
|
scrollTop.value = target.scrollTop;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (!dropdownVisible.value) {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
lastFocusedElement.value = document.activeElement as HTMLElement;
|
|
||||||
toggleDropdown();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
handleDropdownKeyDown(event);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDropdownKeyDown = (event: KeyboardEvent) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
switch (event.key) {
|
|
||||||
case "ArrowDown":
|
|
||||||
event.preventDefault();
|
|
||||||
focusNextOption();
|
|
||||||
break;
|
|
||||||
case "ArrowUp":
|
|
||||||
event.preventDefault();
|
|
||||||
focusPreviousOption();
|
|
||||||
break;
|
|
||||||
case "Enter":
|
|
||||||
event.preventDefault();
|
|
||||||
if (focusedOptionIndex.value !== null) {
|
|
||||||
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "Escape":
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
closeDropdown();
|
|
||||||
break;
|
|
||||||
case "Tab":
|
|
||||||
event.preventDefault();
|
|
||||||
if (event.shiftKey) {
|
|
||||||
focusPreviousOption();
|
|
||||||
} else {
|
|
||||||
focusNextOption();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeDropdown = () => {
|
|
||||||
dropdownVisible.value = false;
|
|
||||||
focusedOptionIndex.value = null;
|
|
||||||
if (lastFocusedElement.value) {
|
|
||||||
lastFocusedElement.value.focus();
|
|
||||||
lastFocusedElement.value = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const closeAllDropdowns = () => {
|
const closeAllDropdowns = () => {
|
||||||
const event = new CustomEvent("close-all-dropdowns");
|
const event = new CustomEvent("close-all-dropdowns");
|
||||||
window.dispatchEvent(event);
|
window.dispatchEvent(event);
|
||||||
@@ -373,9 +254,6 @@ const focusNextOption = () => {
|
|||||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
|
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
|
||||||
}
|
}
|
||||||
scrollToFocused();
|
scrollToFocused();
|
||||||
nextTick(() => {
|
|
||||||
focusedOptionRef.value?.focus();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const focusPreviousOption = () => {
|
const focusPreviousOption = () => {
|
||||||
@@ -386,9 +264,6 @@ const focusPreviousOption = () => {
|
|||||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
|
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
|
||||||
}
|
}
|
||||||
scrollToFocused();
|
scrollToFocused();
|
||||||
nextTick(() => {
|
|
||||||
focusedOptionRef.value?.focus();
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const scrollToFocused = () => {
|
const scrollToFocused = () => {
|
||||||
@@ -407,6 +282,119 @@ const scrollToFocused = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openDropdown = async () => {
|
||||||
|
if (!props.disabled) {
|
||||||
|
closeAllDropdowns();
|
||||||
|
dropdownVisible.value = true;
|
||||||
|
isOpen.value = true;
|
||||||
|
openDropdownCount.value++;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
await updatePosition();
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
optionsContainer.value?.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
dropdownVisible.value = false;
|
||||||
|
isOpen.value = false;
|
||||||
|
openDropdownCount.value--;
|
||||||
|
if (openDropdownCount.value === 0) {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
focusedOptionIndex.value = null;
|
||||||
|
triggerRef.value?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTriggerKeyDown = (event: KeyboardEvent) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case "ArrowDown":
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dropdownVisible.value) {
|
||||||
|
openDropdown();
|
||||||
|
focusedOptionIndex.value = event.key === "ArrowUp" ? props.options.length - 1 : 0;
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
focusNextOption();
|
||||||
|
} else {
|
||||||
|
focusPreviousOption();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
event.preventDefault();
|
||||||
|
if (!dropdownVisible.value) {
|
||||||
|
openDropdown();
|
||||||
|
focusedOptionIndex.value = 0;
|
||||||
|
} else if (focusedOptionIndex.value !== null) {
|
||||||
|
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
event.preventDefault();
|
||||||
|
closeDropdown();
|
||||||
|
break;
|
||||||
|
case "Tab":
|
||||||
|
if (dropdownVisible.value) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleListboxKeyDown = (event: KeyboardEvent) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case "Enter":
|
||||||
|
case " ":
|
||||||
|
event.preventDefault();
|
||||||
|
if (focusedOptionIndex.value !== null) {
|
||||||
|
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "ArrowDown":
|
||||||
|
event.preventDefault();
|
||||||
|
focusNextOption();
|
||||||
|
break;
|
||||||
|
case "ArrowUp":
|
||||||
|
event.preventDefault();
|
||||||
|
focusPreviousOption();
|
||||||
|
break;
|
||||||
|
case "Escape":
|
||||||
|
event.preventDefault();
|
||||||
|
closeDropdown();
|
||||||
|
break;
|
||||||
|
case "Tab":
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
case "Home":
|
||||||
|
event.preventDefault();
|
||||||
|
focusedOptionIndex.value = 0;
|
||||||
|
scrollToFocused();
|
||||||
|
break;
|
||||||
|
case "End":
|
||||||
|
event.preventDefault();
|
||||||
|
focusedOptionIndex.value = props.options.length - 1;
|
||||||
|
scrollToFocused();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (event.key.length === 1) {
|
||||||
|
const char = event.key.toLowerCase();
|
||||||
|
const index = props.options.findIndex((option) =>
|
||||||
|
props.displayName(option).toLowerCase().startsWith(char),
|
||||||
|
);
|
||||||
|
if (index !== -1) {
|
||||||
|
focusedOptionIndex.value = index;
|
||||||
|
scrollToFocused();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener("resize", handleResize);
|
window.addEventListener("resize", handleResize);
|
||||||
window.addEventListener("scroll", handleResize, true);
|
window.addEventListener("scroll", handleResize, true);
|
||||||
@@ -416,6 +404,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.addEventListener("close-all-dropdowns", closeDropdown);
|
window.addEventListener("close-all-dropdowns", closeDropdown);
|
||||||
|
|
||||||
|
if (selectedValue.value) {
|
||||||
|
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@@ -427,7 +419,13 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
window.removeEventListener("close-all-dropdowns", closeDropdown);
|
window.removeEventListener("close-all-dropdowns", closeDropdown);
|
||||||
lastFocusedElement.value = null;
|
|
||||||
|
if (isOpen.value) {
|
||||||
|
openDropdownCount.value--;
|
||||||
|
if (openDropdownCount.value === 0) {
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -443,4 +441,19 @@ watch(dropdownVisible, async (newValue) => {
|
|||||||
scrollTop.value = 0;
|
scrollTop.value = 0;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const activeDescendant = computed(() =>
|
||||||
|
focusedOptionIndex.value !== null ? `${listboxId}-option-${focusedOptionIndex.value}` : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
|
||||||
|
let currentNode: HTMLElement | null = element;
|
||||||
|
while (currentNode) {
|
||||||
|
if (currentNode === triggerRef.value || currentNode === optionsContainer.value) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
currentNode = currentNode.parentElement;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-chevron-down"
|
||||||
|
>
|
||||||
|
<path d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="lucide lucide-chevron-up"
|
||||||
|
>
|
||||||
|
<path d="m18 15-6-6-6 6" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
@@ -21,6 +21,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
|||||||
developerMode: false,
|
developerMode: false,
|
||||||
showVersionFilesInTable: false,
|
showVersionFilesInTable: false,
|
||||||
showAdsWithPlus: false,
|
showAdsWithPlus: false,
|
||||||
|
alwaysShowChecklistAsPopup: true,
|
||||||
|
|
||||||
// Feature toggles
|
// Feature toggles
|
||||||
projectTypesPrimaryNav: false,
|
projectTypesPrimaryNav: false,
|
||||||
|
|||||||
@@ -10,19 +10,111 @@ interface PyroFetchOptions {
|
|||||||
url?: string;
|
url?: string;
|
||||||
token?: string;
|
token?: string;
|
||||||
};
|
};
|
||||||
retry?: boolean;
|
retry?: number | boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> {
|
class PyroServerError extends Error {
|
||||||
|
public readonly errors: Map<string, Error> = new Map();
|
||||||
|
public readonly timestamp: number = Date.now();
|
||||||
|
|
||||||
|
constructor(message?: string) {
|
||||||
|
super(message || "Multiple errors occurred");
|
||||||
|
this.name = "PyroServerError";
|
||||||
|
}
|
||||||
|
|
||||||
|
addError(module: string, error: Error) {
|
||||||
|
this.errors.set(module, error);
|
||||||
|
this.message = this.buildErrorMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
hasErrors() {
|
||||||
|
return this.errors.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildErrorMessage(): string {
|
||||||
|
return Array.from(this.errors.entries())
|
||||||
|
.map(([_module, error]) => error.message)
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PyroServersFetchError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public readonly statusCode?: number,
|
||||||
|
public readonly originalError?: Error,
|
||||||
|
public readonly module?: string,
|
||||||
|
) {
|
||||||
|
let errorMessage = message;
|
||||||
|
let method = "GET";
|
||||||
|
let path = "";
|
||||||
|
|
||||||
|
if (originalError instanceof FetchError) {
|
||||||
|
const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/);
|
||||||
|
if (matches) {
|
||||||
|
method = matches[1];
|
||||||
|
path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMessage = (() => {
|
||||||
|
if (!statusCode) return "Unknown Error";
|
||||||
|
switch (statusCode) {
|
||||||
|
case 400:
|
||||||
|
return "Bad Request";
|
||||||
|
case 401:
|
||||||
|
return "Unauthorized";
|
||||||
|
case 403:
|
||||||
|
return "Forbidden";
|
||||||
|
case 404:
|
||||||
|
return "Not Found";
|
||||||
|
case 408:
|
||||||
|
return "Request Timeout";
|
||||||
|
case 429:
|
||||||
|
return "Too Many Requests";
|
||||||
|
case 500:
|
||||||
|
return "Internal Server Error";
|
||||||
|
case 502:
|
||||||
|
return "Bad Gateway";
|
||||||
|
case 503:
|
||||||
|
return "Service Unavailable";
|
||||||
|
case 504:
|
||||||
|
return "Gateway Timeout";
|
||||||
|
default:
|
||||||
|
return `HTTP ${statusCode}`;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ""}`;
|
||||||
|
} else {
|
||||||
|
errorMessage = `${message}${statusCode ? ` (${statusCode})` : ""}${module ? ` in ${module}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
super(errorMessage);
|
||||||
|
this.name = "PyroServersFetchError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function PyroFetch<T>(
|
||||||
|
path: string,
|
||||||
|
options: PyroFetchOptions = {},
|
||||||
|
module?: string,
|
||||||
|
): Promise<T> {
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const authToken = auth.value?.token;
|
const authToken = auth.value?.token;
|
||||||
|
|
||||||
if (!authToken) {
|
if (!authToken) {
|
||||||
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
|
throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { method = "GET", contentType = "application/json", body, version = 0, override } = options;
|
const {
|
||||||
|
method = "GET",
|
||||||
|
contentType = "application/json",
|
||||||
|
body,
|
||||||
|
version = 0,
|
||||||
|
override,
|
||||||
|
retry = method === "GET" ? 3 : 0,
|
||||||
|
} = options;
|
||||||
|
|
||||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||||
/\/$/,
|
/\/$/,
|
||||||
@@ -30,9 +122,11 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!base) {
|
if (!base) {
|
||||||
throw new PyroFetchError(
|
throw new PyroServersFetchError(
|
||||||
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
|
"Configuration error: Missing PYRO_BASE_URL",
|
||||||
10001,
|
500,
|
||||||
|
undefined,
|
||||||
|
module,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,9 +134,7 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
|||||||
? `https://${override.url}/${path.replace(/^\//, "")}`
|
? `https://${override.url}/${path.replace(/^\//, "")}`
|
||||||
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
|
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
|
||||||
|
|
||||||
type HeadersRecord = Record<string, string>;
|
const headers: Record<string, string> = {
|
||||||
|
|
||||||
const headers: HeadersRecord = {
|
|
||||||
Authorization: `Bearer ${override?.token ?? authToken}`,
|
Authorization: `Bearer ${override?.token ?? authToken}`,
|
||||||
"Access-Control-Allow-Headers": "Authorization",
|
"Access-Control-Allow-Headers": "Authorization",
|
||||||
"User-Agent": "Pyro/1.0 (https://pyro.host)",
|
"User-Agent": "Pyro/1.0 (https://pyro.host)",
|
||||||
@@ -57,43 +149,47 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
|||||||
headers.Origin = window.location.origin;
|
headers.Origin = window.location.origin;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
let attempts = 0;
|
||||||
const response = await $fetch<T>(fullUrl, {
|
const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1;
|
||||||
method,
|
let lastError: Error | null = null;
|
||||||
headers,
|
|
||||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
while (attempts < maxAttempts) {
|
||||||
timeout: 10000,
|
try {
|
||||||
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
|
const response = await $fetch<T>(fullUrl, {
|
||||||
});
|
method,
|
||||||
return response;
|
headers,
|
||||||
} catch (error) {
|
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||||
console.error("[PyroServers/PyroFetch]:", error);
|
timeout: 10000,
|
||||||
if (error instanceof FetchError) {
|
});
|
||||||
const statusCode = error.response?.status;
|
|
||||||
const statusText = error.response?.statusText || "[no status text available]";
|
return response;
|
||||||
const errorMessages: { [key: number]: string } = {
|
} catch (error) {
|
||||||
400: "Bad Request",
|
lastError = error as Error;
|
||||||
401: "Unauthorized",
|
attempts++;
|
||||||
403: "Forbidden",
|
|
||||||
404: "Not Found",
|
if (error instanceof FetchError) {
|
||||||
405: "Method Not Allowed",
|
const statusCode = error.response?.status;
|
||||||
429: "Too Many Requests",
|
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
|
||||||
500: "Internal Server Error",
|
|
||||||
502: "Bad Gateway",
|
if (!isRetryable || attempts >= maxAttempts) {
|
||||||
503: "Service Unavailable",
|
throw new PyroServersFetchError(error.message, statusCode, error, module);
|
||||||
};
|
}
|
||||||
const message =
|
|
||||||
statusCode && statusCode in errorMessages
|
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
|
||||||
? errorMessages[statusCode]
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
: `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`;
|
continue;
|
||||||
throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error);
|
}
|
||||||
|
|
||||||
|
throw new PyroServersFetchError(
|
||||||
|
"Unexpected error during fetch operation",
|
||||||
|
undefined,
|
||||||
|
error as Error,
|
||||||
|
module,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
throw new PyroFetchError(
|
|
||||||
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.",
|
|
||||||
undefined,
|
|
||||||
error as Error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error("Maximum retry attempts reached");
|
||||||
}
|
}
|
||||||
|
|
||||||
const internalServerRefrence = ref<any>(null);
|
const internalServerRefrence = ref<any>(null);
|
||||||
@@ -252,7 +348,7 @@ export interface DirectoryResponse {
|
|||||||
current?: number;
|
current?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContentType = "Mod" | "Plugin";
|
type ContentType = "mod" | "plugin";
|
||||||
|
|
||||||
const constructServerProperties = (properties: any): string => {
|
const constructServerProperties = (properties: any): string => {
|
||||||
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
|
let fileContent = `#Minecraft server properties\n#${new Date().toUTCString()}\n`;
|
||||||
@@ -271,95 +367,96 @@ const constructServerProperties = (properties: any): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const processImage = async (iconUrl: string | undefined) => {
|
const processImage = async (iconUrl: string | undefined) => {
|
||||||
const image = ref<string | null>(null);
|
const sharedImage = useState<string | undefined>(
|
||||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
`server-icon-${internalServerRefrence.value.serverId}`,
|
||||||
try {
|
);
|
||||||
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
|
||||||
override: auth,
|
|
||||||
retry: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (fileData instanceof Blob) {
|
if (sharedImage.value) {
|
||||||
if (import.meta.client) {
|
return sharedImage.value;
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const img = new Image();
|
|
||||||
img.src = URL.createObjectURL(fileData);
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
img.onload = () => {
|
|
||||||
canvas.width = 512;
|
|
||||||
canvas.height = 512;
|
|
||||||
ctx?.drawImage(img, 0, 0, 512, 512);
|
|
||||||
const dataURL = canvas.toDataURL("image/png");
|
|
||||||
internalServerRefrence.value.general.image = dataURL;
|
|
||||||
image.value = dataURL;
|
|
||||||
resolve();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
|
||||||
console.log("[PYROSERVERS] No server icon found");
|
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (image.value === null && iconUrl) {
|
try {
|
||||||
console.log("iconUrl", iconUrl);
|
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(iconUrl);
|
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
||||||
const file = await response.blob();
|
override: auth,
|
||||||
const originalfile = new File([file], "server-icon-original.png", {
|
retry: false,
|
||||||
type: "image/png",
|
|
||||||
});
|
});
|
||||||
if (import.meta.client) {
|
|
||||||
const scaledFile = await new Promise<File>((resolve, reject) => {
|
|
||||||
const canvas = document.createElement("canvas");
|
|
||||||
const ctx = canvas.getContext("2d");
|
|
||||||
const img = new Image();
|
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
img.onload = () => {
|
|
||||||
canvas.width = 64;
|
|
||||||
canvas.height = 64;
|
|
||||||
ctx?.drawImage(img, 0, 0, 64, 64);
|
|
||||||
canvas.toBlob((blob) => {
|
|
||||||
if (blob) {
|
|
||||||
const data = new File([blob], "server-icon.png", { type: "image/png" });
|
|
||||||
resolve(data);
|
|
||||||
} else {
|
|
||||||
reject(new Error("Canvas toBlob failed"));
|
|
||||||
}
|
|
||||||
}, "image/png");
|
|
||||||
};
|
|
||||||
img.onerror = reject;
|
|
||||||
});
|
|
||||||
if (scaledFile) {
|
|
||||||
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
|
|
||||||
method: "POST",
|
|
||||||
contentType: "application/octet-stream",
|
|
||||||
body: scaledFile,
|
|
||||||
override: auth,
|
|
||||||
});
|
|
||||||
|
|
||||||
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
|
if (fileData instanceof Blob) {
|
||||||
method: "POST",
|
if (import.meta.client) {
|
||||||
contentType: "application/octet-stream",
|
const dataURL = await new Promise<string>((resolve) => {
|
||||||
body: originalfile,
|
const canvas = document.createElement("canvas");
|
||||||
override: auth,
|
const ctx = canvas.getContext("2d");
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = 512;
|
||||||
|
canvas.height = 512;
|
||||||
|
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||||
|
const dataURL = canvas.toDataURL("image/png");
|
||||||
|
sharedImage.value = dataURL;
|
||||||
|
resolve(dataURL);
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(fileData);
|
||||||
});
|
});
|
||||||
|
return dataURL;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
|
||||||
console.log("[PYROSERVERS] No server icon found");
|
try {
|
||||||
} else {
|
const response = await fetch(iconUrl);
|
||||||
console.error(error);
|
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||||
|
const file = await response.blob();
|
||||||
|
const originalFile = new File([file], "server-icon-original.png", { type: "image/png" });
|
||||||
|
|
||||||
|
if (import.meta.client) {
|
||||||
|
const dataURL = await new Promise<string>((resolve) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = 64;
|
||||||
|
canvas.height = 64;
|
||||||
|
ctx?.drawImage(img, 0, 0, 64, 64);
|
||||||
|
canvas.toBlob(async (blob) => {
|
||||||
|
if (blob) {
|
||||||
|
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
|
||||||
|
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
|
||||||
|
method: "POST",
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
body: scaledFile,
|
||||||
|
override: auth,
|
||||||
|
});
|
||||||
|
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
|
||||||
|
method: "POST",
|
||||||
|
contentType: "application/octet-stream",
|
||||||
|
body: originalFile,
|
||||||
|
override: auth,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, "image/png");
|
||||||
|
const dataURL = canvas.toDataURL("image/png");
|
||||||
|
sharedImage.value = dataURL;
|
||||||
|
resolve(dataURL);
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
return dataURL;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to process external icon:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to process server icon:", error);
|
||||||
}
|
}
|
||||||
return image.value;
|
|
||||||
|
sharedImage.value = undefined;
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ------------------ GENERAL ------------------ //
|
// ------------------ GENERAL ------------------ //
|
||||||
@@ -519,8 +616,8 @@ const installContent = async (contentType: ContentType, projectId: string, versi
|
|||||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
|
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
install_as: contentType,
|
|
||||||
rinth_ids: { project_id: projectId, version_id: versionId },
|
rinth_ids: { project_id: projectId, version_id: versionId },
|
||||||
|
install_as: contentType,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -529,13 +626,12 @@ const installContent = async (contentType: ContentType, projectId: string, versi
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeContent = async (contentType: ContentType, contentId: string) => {
|
const removeContent = async (path: string) => {
|
||||||
try {
|
try {
|
||||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
|
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
install_as: contentType,
|
path,
|
||||||
path: contentId,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -544,15 +640,11 @@ const removeContent = async (contentType: ContentType, contentId: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const reinstallContent = async (
|
const reinstallContent = async (replace: string, projectId: string, versionId: string) => {
|
||||||
contentType: ContentType,
|
|
||||||
contentId: string,
|
|
||||||
newContentId: string,
|
|
||||||
) => {
|
|
||||||
try {
|
try {
|
||||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/${contentId}`, {
|
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/update`, {
|
||||||
method: "PUT",
|
method: "POST",
|
||||||
body: { install_as: contentType, version_id: newContentId },
|
body: { replace, project_id: projectId, version_id: versionId },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error reinstalling mod:", error);
|
console.error("Error reinstalling mod:", error);
|
||||||
@@ -564,10 +656,14 @@ const reinstallContent = async (
|
|||||||
|
|
||||||
const createBackup = async (backupName: string) => {
|
const createBackup = async (backupName: string) => {
|
||||||
try {
|
try {
|
||||||
const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, {
|
const response = await PyroFetch<{ id: string }>(
|
||||||
method: "POST",
|
`servers/${internalServerRefrence.value.serverId}/backups`,
|
||||||
body: { name: backupName },
|
{
|
||||||
})) as { id: string };
|
method: "POST",
|
||||||
|
body: { name: backupName },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await internalServerRefrence.value.refresh(["backups"]);
|
||||||
return response.id;
|
return response.id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error creating backup:", error);
|
console.error("Error creating backup:", error);
|
||||||
@@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: { name: newName },
|
body: { name: newName },
|
||||||
});
|
});
|
||||||
|
await internalServerRefrence.value.refresh(["backups"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error renaming backup:", error);
|
console.error("Error renaming backup:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => {
|
|||||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
|
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
});
|
});
|
||||||
|
await internalServerRefrence.value.refresh(["backups"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting backup:", error);
|
console.error("Error deleting backup:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await internalServerRefrence.value.refresh(["backups"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error restoring backup:", error);
|
console.error("Error restoring backup:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -644,12 +743,10 @@ const getAutoBackup = async () => {
|
|||||||
|
|
||||||
const lockBackup = async (backupId: string) => {
|
const lockBackup = async (backupId: string) => {
|
||||||
try {
|
try {
|
||||||
return await PyroFetch(
|
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
|
||||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`,
|
method: "POST",
|
||||||
{
|
});
|
||||||
method: "POST",
|
await internalServerRefrence.value.refresh(["backups"]);
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error locking backup:", error);
|
console.error("Error locking backup:", error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => {
|
|||||||
|
|
||||||
const unlockBackup = async (backupId: string) => {
|
const unlockBackup = async (backupId: string) => {
|
||||||
try {
|
try {
|
||||||
return await PyroFetch(
|
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
|
||||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`,
|
method: "POST",
|
||||||
{
|
});
|
||||||
method: "POST",
|
await internalServerRefrence.value.refresh(["backups"]);
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error locking backup:", error);
|
console.error("Error unlocking backup:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
|||||||
try {
|
try {
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof PyroFetchError && error.statusCode === 401) {
|
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
|
||||||
await internalServerRefrence.value.refresh(["fs"]);
|
await internalServerRefrence.value.refresh(["fs"]);
|
||||||
return await requestFn();
|
return await requestFn();
|
||||||
}
|
}
|
||||||
@@ -947,17 +1042,18 @@ const modules: any = {
|
|||||||
general: {
|
general: {
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
try {
|
try {
|
||||||
const data = await PyroFetch<General>(`servers/${serverId}`);
|
const data = await PyroFetch<General>(`servers/${serverId}`, {}, "general");
|
||||||
// TODO: temp hack to fix hydration error
|
|
||||||
if (data.upstream?.project_id) {
|
if (data.upstream?.project_id) {
|
||||||
const res = await $fetch(
|
const res = await $fetch(
|
||||||
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
|
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
|
||||||
);
|
);
|
||||||
data.project = res as Project;
|
data.project = res as Project;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.client) {
|
if (import.meta.client) {
|
||||||
data.image = (await processImage(data.project?.icon_url)) ?? undefined;
|
data.image = (await processImage(data.project?.icon_url)) ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const motd = await getMotd();
|
const motd = await getMotd();
|
||||||
if (motd === "A Minecraft Server") {
|
if (motd === "A Minecraft Server") {
|
||||||
await setMotd(
|
await setMotd(
|
||||||
@@ -967,8 +1063,19 @@ const modules: any = {
|
|||||||
data.motd = motd;
|
data.motd = motd;
|
||||||
return data;
|
return data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
internalServerRefrence.value.setError(error);
|
const fetchError =
|
||||||
return undefined;
|
error instanceof PyroServersFetchError
|
||||||
|
? error
|
||||||
|
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
server_id: serverId,
|
||||||
|
error: {
|
||||||
|
error: fetchError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateName,
|
updateName,
|
||||||
@@ -978,21 +1085,27 @@ const modules: any = {
|
|||||||
suspend: suspendServer,
|
suspend: suspendServer,
|
||||||
getMotd,
|
getMotd,
|
||||||
setMotd,
|
setMotd,
|
||||||
fetchConfigFile,
|
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
try {
|
try {
|
||||||
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`);
|
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`, {}, "content");
|
||||||
return {
|
return {
|
||||||
data:
|
data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")),
|
||||||
internalServerRefrence.value.error === undefined
|
|
||||||
? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? ""))
|
|
||||||
: [],
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
internalServerRefrence.value.setError(error);
|
const fetchError =
|
||||||
return undefined;
|
error instanceof PyroServersFetchError
|
||||||
|
? error
|
||||||
|
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
error: {
|
||||||
|
error: fetchError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
install: installContent,
|
install: installContent,
|
||||||
@@ -1002,10 +1115,22 @@ const modules: any = {
|
|||||||
backups: {
|
backups: {
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
try {
|
try {
|
||||||
return { data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`) };
|
return {
|
||||||
|
data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`, {}, "backups"),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
internalServerRefrence.value.setError(error);
|
const fetchError =
|
||||||
return undefined;
|
error instanceof PyroServersFetchError
|
||||||
|
? error
|
||||||
|
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: [],
|
||||||
|
error: {
|
||||||
|
error: fetchError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
create: createBackup,
|
create: createBackup,
|
||||||
@@ -1021,10 +1146,26 @@ const modules: any = {
|
|||||||
network: {
|
network: {
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
try {
|
try {
|
||||||
return { allocations: await PyroFetch<Allocation[]>(`servers/${serverId}/allocations`) };
|
return {
|
||||||
|
allocations: await PyroFetch<Allocation[]>(
|
||||||
|
`servers/${serverId}/allocations`,
|
||||||
|
{},
|
||||||
|
"network",
|
||||||
|
),
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
internalServerRefrence.value.setError(error);
|
const fetchError =
|
||||||
return undefined;
|
error instanceof PyroServersFetchError
|
||||||
|
? error
|
||||||
|
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allocations: [],
|
||||||
|
error: {
|
||||||
|
error: fetchError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
reserveAllocation,
|
reserveAllocation,
|
||||||
@@ -1036,10 +1177,19 @@ const modules: any = {
|
|||||||
startup: {
|
startup: {
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
try {
|
try {
|
||||||
return await PyroFetch<Startup>(`servers/${serverId}/startup`);
|
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
internalServerRefrence.value.setError(error);
|
const fetchError =
|
||||||
return undefined;
|
error instanceof PyroServersFetchError
|
||||||
|
? error
|
||||||
|
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
error: fetchError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
update: updateStartupSettings,
|
update: updateStartupSettings,
|
||||||
@@ -1047,20 +1197,39 @@ const modules: any = {
|
|||||||
ws: {
|
ws: {
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
try {
|
try {
|
||||||
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`);
|
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
internalServerRefrence.value.setError(error);
|
const fetchError =
|
||||||
return undefined;
|
error instanceof PyroServersFetchError
|
||||||
|
? error
|
||||||
|
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
error: {
|
||||||
|
error: fetchError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
fs: {
|
fs: {
|
||||||
get: async (serverId: string) => {
|
get: async (serverId: string) => {
|
||||||
try {
|
try {
|
||||||
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`) };
|
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
internalServerRefrence.value.setError(error);
|
const fetchError =
|
||||||
return undefined;
|
error instanceof PyroServersFetchError
|
||||||
|
? error
|
||||||
|
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||||
|
|
||||||
|
return {
|
||||||
|
auth: undefined,
|
||||||
|
error: {
|
||||||
|
error: fetchError,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
listDirContents,
|
listDirContents,
|
||||||
@@ -1136,8 +1305,7 @@ type GeneralFunctions = {
|
|||||||
setMotd: (motd: string) => Promise<void>;
|
setMotd: (motd: string) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* INTERNAL: Gets the config file of a server.
|
* @deprecated Use fs.downloadFile instead
|
||||||
* @param fileName - The name of the file.
|
|
||||||
*/
|
*/
|
||||||
fetchConfigFile: (fileName: string) => Promise<any>;
|
fetchConfigFile: (fileName: string) => Promise<any>;
|
||||||
};
|
};
|
||||||
@@ -1160,18 +1328,17 @@ type ContentFunctions = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Removes a mod from a server.
|
* Removes a mod from a server.
|
||||||
* @param contentType - The type of content to remove.
|
* @param path - The path of the mod file.
|
||||||
* @param contentId - The ID of the content.
|
|
||||||
*/
|
*/
|
||||||
remove: (contentType: ContentType, contentId: string) => Promise<void>;
|
remove: (path: string) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reinstalls a mod to a server.
|
* Reinstalls a mod to a server.
|
||||||
* @param contentType - The type of content to reinstall.
|
* @param replace - The path of the mod to replace.
|
||||||
* @param contentId - The ID of the content.
|
* @param projectId - The ID of the content.
|
||||||
* @param newContentId - The ID of the new version.
|
* @param versionId - The ID of the new version.
|
||||||
*/
|
*/
|
||||||
reinstall: (contentType: ContentType, contentId: string, newContentId: string) => Promise<void>;
|
reinstall: (replace: string, projectId: string, versionId: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BackupFunctions = {
|
type BackupFunctions = {
|
||||||
@@ -1370,12 +1537,44 @@ type FSFunctions = {
|
|||||||
downloadFile: (path: string, raw?: boolean) => Promise<any>;
|
downloadFile: (path: string, raw?: boolean) => Promise<any>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GeneralModule = General & GeneralFunctions;
|
type ModuleError = {
|
||||||
type ContentModule = { data: Mod[] } & ContentFunctions;
|
error: PyroServersFetchError;
|
||||||
type BackupsModule = { data: Backup[] } & BackupFunctions;
|
timestamp: number;
|
||||||
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
|
};
|
||||||
type StartupModule = Startup & StartupFunctions;
|
|
||||||
export type FSModule = { auth: JWTAuth } & FSFunctions;
|
type GeneralModule = General &
|
||||||
|
GeneralFunctions & {
|
||||||
|
error?: ModuleError;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ContentModule = {
|
||||||
|
data: Mod[];
|
||||||
|
error?: ModuleError;
|
||||||
|
} & ContentFunctions;
|
||||||
|
|
||||||
|
type BackupsModule = {
|
||||||
|
data: Backup[];
|
||||||
|
error?: ModuleError;
|
||||||
|
} & BackupFunctions;
|
||||||
|
|
||||||
|
type NetworkModule = {
|
||||||
|
allocations: Allocation[];
|
||||||
|
error?: ModuleError;
|
||||||
|
} & NetworkFunctions;
|
||||||
|
|
||||||
|
type StartupModule = Startup &
|
||||||
|
StartupFunctions & {
|
||||||
|
error?: ModuleError;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WSModule = JWTAuth & {
|
||||||
|
error?: ModuleError;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FSModule = {
|
||||||
|
auth: JWTAuth;
|
||||||
|
error?: ModuleError;
|
||||||
|
} & FSFunctions;
|
||||||
|
|
||||||
type ModulesMap = {
|
type ModulesMap = {
|
||||||
general: GeneralModule;
|
general: GeneralModule;
|
||||||
@@ -1383,7 +1582,7 @@ type ModulesMap = {
|
|||||||
backups: BackupsModule;
|
backups: BackupsModule;
|
||||||
network: NetworkModule;
|
network: NetworkModule;
|
||||||
startup: StartupModule;
|
startup: StartupModule;
|
||||||
ws: JWTAuth;
|
ws: WSModule;
|
||||||
fs: FSModule;
|
fs: FSModule;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1395,8 +1594,16 @@ export type Server<T extends avaliableModules> = {
|
|||||||
/**
|
/**
|
||||||
* Refreshes the included modules of the server
|
* Refreshes the included modules of the server
|
||||||
* @param refreshModules - The modules to refresh.
|
* @param refreshModules - The modules to refresh.
|
||||||
|
* @param options - The options to use when refreshing the modules.
|
||||||
*/
|
*/
|
||||||
refresh: (refreshModules?: avaliableModules) => Promise<void>;
|
refresh: (
|
||||||
|
refreshModules?: avaliableModules,
|
||||||
|
options?: {
|
||||||
|
preserveConnection?: boolean;
|
||||||
|
preserveInstallState?: boolean;
|
||||||
|
},
|
||||||
|
) => Promise<void>;
|
||||||
|
loadModules: (modulesToLoad: avaliableModules) => Promise<void>;
|
||||||
setError: (error: Error) => void;
|
setError: (error: Error) => void;
|
||||||
error?: Error;
|
error?: Error;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
@@ -1404,49 +1611,103 @@ export type Server<T extends avaliableModules> = {
|
|||||||
|
|
||||||
export const usePyroServer = async (serverId: string, includedModules: avaliableModules) => {
|
export const usePyroServer = async (serverId: string, includedModules: avaliableModules) => {
|
||||||
const server: Server<typeof includedModules> = reactive({
|
const server: Server<typeof includedModules> = reactive({
|
||||||
refresh: async (refreshModules?: avaliableModules) => {
|
refresh: async (
|
||||||
const promises: Promise<void>[] = [];
|
refreshModules?: avaliableModules,
|
||||||
if (refreshModules) {
|
options?: {
|
||||||
for (const module of refreshModules) {
|
preserveConnection?: boolean;
|
||||||
promises.push(
|
preserveInstallState?: boolean;
|
||||||
(async () => {
|
},
|
||||||
const mods = modules[module];
|
) => {
|
||||||
if (mods.get) {
|
if (server.general?.status === "installing" && !refreshModules) {
|
||||||
const data = await mods.get(serverId);
|
return;
|
||||||
server[module] = { ...server[module], ...data };
|
}
|
||||||
}
|
|
||||||
})(),
|
const modulesToRefresh = [...new Set(refreshModules || includedModules)];
|
||||||
);
|
const serverError = new PyroServerError();
|
||||||
|
|
||||||
|
const modulePromises = modulesToRefresh.map(async (module) => {
|
||||||
|
try {
|
||||||
|
const mods = modules[module];
|
||||||
|
if (!mods?.get) return;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
});
|
||||||
for (const module of includedModules) {
|
|
||||||
promises.push(
|
await Promise.allSettled(modulePromises);
|
||||||
(async () => {
|
|
||||||
const mods = modules[module];
|
if (serverError.hasErrors()) {
|
||||||
if (mods.get) {
|
if (server.error && server.error instanceof PyroServerError) {
|
||||||
const data = await mods.get(serverId);
|
serverError.errors.forEach((error, module) => {
|
||||||
server[module] = { ...server[module], ...data };
|
(server.error as PyroServerError).addError(module, error);
|
||||||
}
|
});
|
||||||
})(),
|
} else {
|
||||||
);
|
server.setError(serverError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await Promise.all(promises);
|
},
|
||||||
|
loadModules: async (modulesToLoad: avaliableModules) => {
|
||||||
|
const newModules = modulesToLoad.filter((module) => !server[module]);
|
||||||
|
if (newModules.length === 0) return;
|
||||||
|
|
||||||
|
newModules.forEach((module) => {
|
||||||
|
server[module] = modules[module];
|
||||||
|
});
|
||||||
|
|
||||||
|
await server.refresh(newModules);
|
||||||
},
|
},
|
||||||
setError: (error: Error) => {
|
setError: (error: Error) => {
|
||||||
server.error = error;
|
if (!server.error) {
|
||||||
|
server.error = error;
|
||||||
|
} else if (error instanceof PyroServerError) {
|
||||||
|
if (!(server.error instanceof PyroServerError)) {
|
||||||
|
const newError = new PyroServerError();
|
||||||
|
newError.addError("previous", server.error);
|
||||||
|
server.error = newError;
|
||||||
|
}
|
||||||
|
error.errors.forEach((err, module) => {
|
||||||
|
(server.error as PyroServerError).addError(module, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
serverId,
|
serverId,
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const module of includedModules) {
|
const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module));
|
||||||
const mods = modules[module];
|
const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module));
|
||||||
server[module] = mods;
|
|
||||||
}
|
initialModules.forEach((module) => {
|
||||||
|
server[module] = modules[module];
|
||||||
|
});
|
||||||
|
|
||||||
internalServerRefrence.value = server;
|
internalServerRefrence.value = server;
|
||||||
|
await server.refresh(initialModules);
|
||||||
|
|
||||||
await server.refresh();
|
if (deferredModules.length > 0) {
|
||||||
|
await server.loadModules(deferredModules);
|
||||||
|
}
|
||||||
|
|
||||||
return server as Server<typeof includedModules>;
|
return server as Server<typeof includedModules>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,27 @@
|
|||||||
<div class="pointer-events-none absolute inset-0 z-[-1]">
|
<div class="pointer-events-none absolute inset-0 z-[-1]">
|
||||||
<div id="absolute-background-teleport" class="relative"></div>
|
<div id="absolute-background-teleport" class="relative"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="pointer-events-none absolute inset-0 z-50">
|
||||||
|
<div
|
||||||
|
class="over-the-top-random-animation"
|
||||||
|
:style="{ '--_r-count': rCount }"
|
||||||
|
:class="{ threshold: rCount > 20, 'rings-expand': rCount >= 40 }"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||||
|
></div>
|
||||||
|
<div
|
||||||
|
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight text-9xl font-extrabold text-contrast"
|
||||||
|
>
|
||||||
|
?
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
|
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
|
||||||
<div
|
<div
|
||||||
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
|
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
|
||||||
@@ -206,7 +227,6 @@
|
|||||||
<template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template>
|
<template #modpacks> <PackageOpenIcon aria-hidden="true" /> Modpacks </template>
|
||||||
</TeleportOverflowMenu>
|
</TeleportOverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="
|
:highlighted="
|
||||||
@@ -231,14 +251,52 @@
|
|||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-1">
|
||||||
|
<ButtonStyled type="transparent">
|
||||||
|
<OverflowMenu
|
||||||
|
v-if="auth.user && isStaff(auth.user)"
|
||||||
|
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||||
|
position="bottom"
|
||||||
|
direction="left"
|
||||||
|
:dropdown-id="`${basePopoutId}-staff`"
|
||||||
|
aria-label="Create new..."
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'review-projects',
|
||||||
|
color: 'orange',
|
||||||
|
link: '/moderation/review',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'review-reports',
|
||||||
|
color: 'orange',
|
||||||
|
link: '/moderation/reports',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: isAdmin(auth.user),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-lookup',
|
||||||
|
color: 'primary',
|
||||||
|
link: '/admin/user_email',
|
||||||
|
shown: isAdmin(auth.user),
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<ModrinthIcon aria-hidden="true" />
|
||||||
|
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||||
|
<template #review-projects> <ScaleIcon aria-hidden="true" /> Review projects </template>
|
||||||
|
<template #review-reports> <ReportIcon aria-hidden="true" /> Reports </template>
|
||||||
|
<template #user-lookup> <UserIcon aria-hidden="true" /> Lookup by email </template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
<ButtonStyled type="transparent">
|
<ButtonStyled type="transparent">
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
v-if="auth.user"
|
v-if="auth.user"
|
||||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||||
position="bottom"
|
position="bottom"
|
||||||
direction="left"
|
direction="left"
|
||||||
:dropdown-id="createPopoutId"
|
:dropdown-id="`${basePopoutId}-create`"
|
||||||
aria-label="Create new..."
|
aria-label="Create new..."
|
||||||
:options="[
|
:options="[
|
||||||
{
|
{
|
||||||
@@ -270,7 +328,7 @@
|
|||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
v-if="auth.user"
|
v-if="auth.user"
|
||||||
:dropdown-id="userPopoutId"
|
:dropdown-id="`${basePopoutId}-user`"
|
||||||
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
class="btn-dropdown-animation flex items-center gap-1 rounded-xl bg-transparent px-2 py-1"
|
||||||
:options="userMenuOptions"
|
:options="userMenuOptions"
|
||||||
>
|
>
|
||||||
@@ -291,15 +349,22 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </template>
|
<template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </template>
|
||||||
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
|
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
|
||||||
<template #moderation> <ModerationIcon aria-hidden="true" /> Moderation </template>
|
<template #moderation> <ScaleIcon aria-hidden="true" /> Moderation </template>
|
||||||
<template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template>
|
<template #sign-out> <LogOutIcon aria-hidden="true" /> Sign out </template>
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
<ButtonStyled v-else color="brand">
|
<template v-else>
|
||||||
<nuxt-link to="/auth/sign-in">
|
<ButtonStyled color="brand">
|
||||||
<LogInIcon aria-hidden="true" />
|
<nuxt-link to="/auth/sign-in">
|
||||||
Sign in
|
<LogInIcon aria-hidden="true" />
|
||||||
</nuxt-link>
|
Sign in
|
||||||
</ButtonStyled>
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<nuxt-link v-tooltip="'Settings'" to="/settings">
|
||||||
|
<SettingsIcon aria-label="Settings" />
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<header class="mobile-navigation mobile-only">
|
<header class="mobile-navigation mobile-only">
|
||||||
@@ -371,7 +436,7 @@
|
|||||||
class="iconified-button"
|
class="iconified-button"
|
||||||
to="/moderation"
|
to="/moderation"
|
||||||
>
|
>
|
||||||
<ModerationIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonMessages.moderationLabel) }}
|
{{ formatMessage(commonMessages.moderationLabel) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags">
|
<NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags">
|
||||||
@@ -432,7 +497,7 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<NotificationIcon aria-hidden="true" />
|
<BellIcon aria-hidden="true" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
to="/dashboard"
|
to="/dashboard"
|
||||||
@@ -451,7 +516,7 @@
|
|||||||
>
|
>
|
||||||
<template v-if="!auth.user">
|
<template v-if="!auth.user">
|
||||||
<HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" />
|
<HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" />
|
||||||
<CrossIcon v-else aria-hidden="true" />
|
<XIcon v-else aria-hidden="true" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -466,108 +531,102 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main class="min-h-[calc(100vh-4.5rem-310.59px)]">
|
||||||
<ModalCreation v-if="auth.user" ref="modal_creation" />
|
<ModalCreation v-if="auth.user" ref="modal_creation" />
|
||||||
<CollectionCreateModal ref="modal_collection_creation" />
|
<CollectionCreateModal ref="modal_collection_creation" />
|
||||||
<OrganizationCreateModal ref="modal_organization_creation" />
|
<OrganizationCreateModal ref="modal_organization_creation" />
|
||||||
<slot id="main" />
|
<slot id="main" />
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer
|
||||||
<div class="logo-info" role="region" aria-label="Modrinth information">
|
class="footer-brand-background experimental-styles-within mt-6 border-0 border-t-[1px] border-solid"
|
||||||
<BrandTextLogo
|
>
|
||||||
aria-hidden="true"
|
<div class="mx-auto flex max-w-screen-xl flex-col gap-6 p-6 pb-12 sm:px-12 md:py-12">
|
||||||
class="text-logo button-base mx-auto mb-4 lg:mx-0"
|
<div
|
||||||
@click="developerModeIncrement()"
|
class="grid grid-cols-1 gap-4 text-primary md:grid-cols-[1fr_2fr] lg:grid-cols-[auto_auto_auto_auto_auto]"
|
||||||
/>
|
>
|
||||||
<p class="mb-4">
|
<div
|
||||||
<IntlFormatted :message-id="footerMessages.openSource">
|
class="flex flex-col items-center gap-3 md:items-start"
|
||||||
<template #github-link="{ children }">
|
role="region"
|
||||||
<a
|
aria-label="Modrinth information"
|
||||||
:target="$external()"
|
|
||||||
href="https://github.com/modrinth"
|
|
||||||
class="text-link"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
<component :is="() => children" />
|
|
||||||
</a>
|
|
||||||
</template>
|
|
||||||
</IntlFormatted>
|
|
||||||
</p>
|
|
||||||
<p class="mb-4">
|
|
||||||
{{ config.public.branch }}@<a
|
|
||||||
:target="$external()"
|
|
||||||
:href="
|
|
||||||
'https://github.com/' +
|
|
||||||
config.public.owner +
|
|
||||||
'/' +
|
|
||||||
config.public.slug +
|
|
||||||
'/tree/' +
|
|
||||||
config.public.hash
|
|
||||||
"
|
|
||||||
class="text-link"
|
|
||||||
rel="noopener"
|
|
||||||
>{{ config.public.hash.substring(0, 7) }}</a
|
|
||||||
>
|
>
|
||||||
</p>
|
<BrandTextLogo
|
||||||
<p>© Rinth, Inc.</p>
|
aria-hidden="true"
|
||||||
</div>
|
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
||||||
<div class="links links-1" role="region" aria-label="Legal">
|
@click="developerModeIncrement()"
|
||||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.companyTitle) }}</h4>
|
/>
|
||||||
<nuxt-link to="/legal/terms"> {{ formatMessage(footerMessages.terms) }}</nuxt-link>
|
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
|
||||||
<nuxt-link to="/legal/privacy"> {{ formatMessage(footerMessages.privacy) }}</nuxt-link>
|
<ButtonStyled
|
||||||
<nuxt-link to="/legal/rules"> {{ formatMessage(footerMessages.rules) }}</nuxt-link>
|
v-for="(social, index) in socialLinks"
|
||||||
<a :target="$external()" href="https://careers.modrinth.com">
|
:key="`footer-social-${index}`"
|
||||||
{{ formatMessage(footerMessages.careers) }}
|
circular
|
||||||
<span v-if="false" class="count-bubble">0</span>
|
type="transparent"
|
||||||
</a>
|
>
|
||||||
</div>
|
<a
|
||||||
<div class="links links-2" role="region" aria-label="Resources">
|
v-tooltip="social.label"
|
||||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.resourcesTitle) }}</h4>
|
:href="social.href"
|
||||||
<a :target="$external()" href="https://support.modrinth.com">
|
target="_blank"
|
||||||
{{ formatMessage(footerMessages.support) }}
|
:rel="`noopener${social.rel ? ` ${social.rel}` : ''}`"
|
||||||
</a>
|
>
|
||||||
<a :target="$external()" href="https://blog.modrinth.com">
|
<component :is="social.icon" class="h-5 w-5" />
|
||||||
{{ formatMessage(footerMessages.blog) }}
|
</a>
|
||||||
</a>
|
</ButtonStyled>
|
||||||
<a :target="$external()" href="https://docs.modrinth.com">
|
</div>
|
||||||
{{ formatMessage(footerMessages.docs) }}
|
<div class="mt-auto flex flex-wrap justify-center gap-3 md:flex-col">
|
||||||
</a>
|
<p class="m-0">
|
||||||
<a :target="$external()" href="https://status.modrinth.com">
|
<IntlFormatted :message-id="footerMessages.openSource">
|
||||||
{{ formatMessage(footerMessages.status) }}
|
<template #github-link="{ children }">
|
||||||
</a>
|
<a
|
||||||
</div>
|
href="https://github.com/modrinth/code"
|
||||||
<div class="links links-3" role="region" aria-label="Interact">
|
class="text-brand hover:underline"
|
||||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.interactTitle) }}</h4>
|
target="_blank"
|
||||||
<a rel="noopener" :target="$external()" href="https://discord.modrinth.com"> Discord </a>
|
rel="noopener"
|
||||||
<a rel="noopener" :target="$external()" href="https://x.com/modrinth"> X (Twitter) </a>
|
>
|
||||||
<a rel="noopener" :target="$external()" href="https://floss.social/@modrinth"> Mastodon </a>
|
<component :is="() => children" />
|
||||||
<a rel="noopener" :target="$external()" href="https://crowdin.com/project/modrinth">
|
</a>
|
||||||
Crowdin
|
</template>
|
||||||
</a>
|
</IntlFormatted>
|
||||||
</div>
|
</p>
|
||||||
<div class="buttons">
|
<p class="m-0">© 2025 Rinth, Inc.</p>
|
||||||
<nuxt-link class="btn btn-outline btn-primary" to="/app">
|
</div>
|
||||||
<DownloadIcon aria-hidden="true" />
|
</div>
|
||||||
{{ formatMessage(messages.getModrinthApp) }}
|
<div class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:contents">
|
||||||
</nuxt-link>
|
<div
|
||||||
<button class="iconified-button raised-button" @click="changeTheme">
|
v-for="group in footerLinks"
|
||||||
<MoonIcon v-if="$theme.active === 'light'" aria-hidden="true" />
|
:key="group.label"
|
||||||
<SunIcon v-else aria-hidden="true" />
|
class="flex flex-col items-center gap-3 sm:items-start"
|
||||||
{{ formatMessage(messages.changeTheme) }}
|
>
|
||||||
</button>
|
<h3 class="m-0 text-base text-contrast">{{ group.label }}</h3>
|
||||||
<nuxt-link class="iconified-button raised-button" to="/settings">
|
<template v-for="item in group.links" :key="item.label">
|
||||||
<SettingsIcon aria-hidden="true" />
|
<nuxt-link
|
||||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
v-if="item.href.startsWith('/')"
|
||||||
</nuxt-link>
|
:to="item.href"
|
||||||
</div>
|
class="w-fit hover:underline"
|
||||||
<div class="not-affiliated-notice">
|
>
|
||||||
{{ formatMessage(footerMessages.legalDisclaimer) }}
|
{{ item.label }}
|
||||||
|
</nuxt-link>
|
||||||
|
<a
|
||||||
|
v-else
|
||||||
|
:href="item.href"
|
||||||
|
class="w-fit hover:underline"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
{{ item.label }}
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-center text-center text-xs font-medium text-secondary opacity-50">
|
||||||
|
{{ formatMessage(footerMessages.legalDisclaimer) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
|
ModrinthIcon,
|
||||||
ArrowBigUpDashIcon,
|
ArrowBigUpDashIcon,
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
@@ -599,12 +658,17 @@ import {
|
|||||||
GlassesIcon,
|
GlassesIcon,
|
||||||
PaintBrushIcon,
|
PaintBrushIcon,
|
||||||
PackageOpenIcon,
|
PackageOpenIcon,
|
||||||
XIcon as CrossIcon,
|
DiscordIcon,
|
||||||
ScaleIcon as ModerationIcon,
|
BlueskyIcon,
|
||||||
BellIcon as NotificationIcon,
|
TumblrIcon,
|
||||||
|
TwitterIcon,
|
||||||
|
MastodonIcon,
|
||||||
|
GitHubIcon,
|
||||||
|
ScaleIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
|
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
|
||||||
|
|
||||||
|
import { isAdmin, isStaff } from "@modrinth/utils";
|
||||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||||
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
|
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
|
||||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||||
@@ -622,10 +686,10 @@ const flags = useFeatureFlags();
|
|||||||
|
|
||||||
const config = useRuntimeConfig();
|
const config = useRuntimeConfig();
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
|
const router = useNativeRouter();
|
||||||
const link = config.public.siteUrl + route.path.replace(/\/+$/, "");
|
const link = config.public.siteUrl + route.path.replace(/\/+$/, "");
|
||||||
|
|
||||||
const createPopoutId = useId();
|
const basePopoutId = useId();
|
||||||
const userPopoutId = useId();
|
|
||||||
|
|
||||||
const verifyEmailBannerMessages = defineMessages({
|
const verifyEmailBannerMessages = defineMessages({
|
||||||
title: {
|
title: {
|
||||||
@@ -708,50 +772,6 @@ const footerMessages = defineMessages({
|
|||||||
id: "layout.footer.open-source",
|
id: "layout.footer.open-source",
|
||||||
defaultMessage: "Modrinth is <github-link>open source</github-link>.",
|
defaultMessage: "Modrinth is <github-link>open source</github-link>.",
|
||||||
},
|
},
|
||||||
companyTitle: {
|
|
||||||
id: "layout.footer.company.title",
|
|
||||||
defaultMessage: "Company",
|
|
||||||
},
|
|
||||||
terms: {
|
|
||||||
id: "layout.footer.company.terms",
|
|
||||||
defaultMessage: "Terms",
|
|
||||||
},
|
|
||||||
privacy: {
|
|
||||||
id: "layout.footer.company.privacy",
|
|
||||||
defaultMessage: "Privacy",
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
id: "layout.footer.company.rules",
|
|
||||||
defaultMessage: "Rules",
|
|
||||||
},
|
|
||||||
careers: {
|
|
||||||
id: "layout.footer.company.careers",
|
|
||||||
defaultMessage: "Careers",
|
|
||||||
},
|
|
||||||
resourcesTitle: {
|
|
||||||
id: "layout.footer.resources.title",
|
|
||||||
defaultMessage: "Resources",
|
|
||||||
},
|
|
||||||
support: {
|
|
||||||
id: "layout.footer.resources.support",
|
|
||||||
defaultMessage: "Support",
|
|
||||||
},
|
|
||||||
blog: {
|
|
||||||
id: "layout.footer.resources.blog",
|
|
||||||
defaultMessage: "Blog",
|
|
||||||
},
|
|
||||||
docs: {
|
|
||||||
id: "layout.footer.resources.docs",
|
|
||||||
defaultMessage: "Docs",
|
|
||||||
},
|
|
||||||
status: {
|
|
||||||
id: "layout.footer.resources.status",
|
|
||||||
defaultMessage: "Status",
|
|
||||||
},
|
|
||||||
interactTitle: {
|
|
||||||
id: "layout.footer.interact.title",
|
|
||||||
defaultMessage: "Interact",
|
|
||||||
},
|
|
||||||
legalDisclaimer: {
|
legalDisclaimer: {
|
||||||
id: "layout.footer.legal-disclaimer",
|
id: "layout.footer.legal-disclaimer",
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
@@ -928,12 +948,57 @@ const isDiscoveringSubpage = computed(
|
|||||||
() => route.name && route.name.startsWith("type-id") && !route.query.sid,
|
() => route.name && route.name.startsWith("type-id") && !route.query.sid,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rCount = ref(0);
|
||||||
|
|
||||||
|
const randomProjects = ref([]);
|
||||||
|
const disableRandomProjects = ref(false);
|
||||||
|
|
||||||
|
const disableRandomProjectsForRoute = computed(
|
||||||
|
() =>
|
||||||
|
route.name.startsWith("servers") ||
|
||||||
|
route.name.includes("settings") ||
|
||||||
|
route.name.includes("admin"),
|
||||||
|
);
|
||||||
|
|
||||||
|
async function onKeyDown(event) {
|
||||||
|
if (disableRandomProjects.value || disableRandomProjectsForRoute.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === "r") {
|
||||||
|
rCount.value++;
|
||||||
|
|
||||||
|
if (randomProjects.value.length < 3) {
|
||||||
|
randomProjects.value = await useBaseFetch("projects_random?count=50").catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rCount.value >= 40) {
|
||||||
|
rCount.value = 0;
|
||||||
|
const randomProject = randomProjects.value[0];
|
||||||
|
await router.push(`/project/${randomProject.slug}`);
|
||||||
|
randomProjects.value.splice(0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeyUp(event) {
|
||||||
|
if (event.key === "r") {
|
||||||
|
rCount.value = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (window && import.meta.client) {
|
if (window && import.meta.client) {
|
||||||
window.history.scrollRestoration = "auto";
|
window.history.scrollRestoration = "auto";
|
||||||
}
|
}
|
||||||
|
|
||||||
runAnalytics();
|
runAnalytics();
|
||||||
|
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
window.addEventListener("keyup", onKeyUp);
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1023,6 +1088,194 @@ const { cycle: changeTheme } = useTheme();
|
|||||||
function hideStagingBanner() {
|
function hideStagingBanner() {
|
||||||
cosmetics.value.hideStagingBanner = true;
|
cosmetics.value.hideStagingBanner = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.social.discord", defaultMessage: "Discord" }),
|
||||||
|
),
|
||||||
|
href: "https://discord.modrinth.com",
|
||||||
|
icon: DiscordIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.social.bluesky", defaultMessage: "Bluesky" }),
|
||||||
|
),
|
||||||
|
href: "https://bsky.app/profile/modrinth.com",
|
||||||
|
icon: BlueskyIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.social.mastodon", defaultMessage: "Mastodon" }),
|
||||||
|
),
|
||||||
|
href: "https://floss.social/@modrinth",
|
||||||
|
icon: MastodonIcon,
|
||||||
|
rel: "me",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
|
||||||
|
),
|
||||||
|
href: "https://tumblr.com/modrinth",
|
||||||
|
icon: TumblrIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
||||||
|
href: "https://x.com/modrinth",
|
||||||
|
icon: TwitterIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
|
||||||
|
),
|
||||||
|
href: "https://github.com/modrinth",
|
||||||
|
icon: GitHubIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const footerLinks = [
|
||||||
|
{
|
||||||
|
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "https://blog.modrinth.com",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/news/changelog",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.about.changelog", defaultMessage: "Changelog" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "https://status.modrinth.com",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.about.status", defaultMessage: "Status" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "https://careers.modrinth.com",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.about.careers", defaultMessage: "Careers" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/legal/cmp-info",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: "layout.footer.about.rewards-program",
|
||||||
|
defaultMessage: "Rewards Program",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.products", defaultMessage: "Products" }),
|
||||||
|
),
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "/plus",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.products.plus", defaultMessage: "Modrinth+" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/app",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.products.app", defaultMessage: "Modrinth App" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/servers",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: "layout.footer.products.servers",
|
||||||
|
defaultMessage: "Modrinth Servers",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.resources", defaultMessage: "Resources" }),
|
||||||
|
),
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "https://support.modrinth.com",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: "layout.footer.resources.help-center",
|
||||||
|
defaultMessage: "Help Center",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "https://crowdin.com/project/modrinth",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.resources.translate", defaultMessage: "Translate" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "https://github.com/modrinth/code/issues",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: "layout.footer.resources.report-issues",
|
||||||
|
defaultMessage: "Report issues",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "https://docs.modrinth.com/api/",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: "layout.footer.resources.api-docs",
|
||||||
|
defaultMessage: "API documentation",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(defineMessage({ id: "layout.footer.legal", defaultMessage: "Legal" })),
|
||||||
|
links: [
|
||||||
|
{
|
||||||
|
href: "/legal/rules",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.legal.rules", defaultMessage: "Content Rules" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/legal/terms",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({ id: "layout.footer.legal.terms-of-use", defaultMessage: "Terms of Use" }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/legal/privacy",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: "layout.footer.legal.privacy-policy",
|
||||||
|
defaultMessage: "Privacy Policy",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
href: "/legal/security",
|
||||||
|
label: formatMessage(
|
||||||
|
defineMessage({
|
||||||
|
id: "layout.footer.legal.security-notice",
|
||||||
|
defaultMessage: "Security Notice",
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -1037,127 +1290,9 @@ function hideStagingBanner() {
|
|||||||
min-height: calc(100vh - var(--spacing-card-bg));
|
min-height: calc(100vh - var(--spacing-card-bg));
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 750px) {
|
|
||||||
margin-bottom: calc(var(--size-mobile-navbar-height) + 2rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
main {
|
||||||
grid-area: main;
|
grid-area: main;
|
||||||
}
|
}
|
||||||
|
|
||||||
footer {
|
|
||||||
margin: 6rem 0 2rem 0;
|
|
||||||
text-align: center;
|
|
||||||
display: grid;
|
|
||||||
grid-template:
|
|
||||||
"logo-info logo-info logo-info" auto
|
|
||||||
"links-1 links-2 links-3" auto
|
|
||||||
"buttons buttons buttons" auto
|
|
||||||
"notice notice notice" auto
|
|
||||||
/ 1fr 1fr 1fr;
|
|
||||||
max-width: 1280px;
|
|
||||||
|
|
||||||
.logo-info {
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
max-width: 15rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
grid-area: logo-info;
|
|
||||||
|
|
||||||
.text-logo {
|
|
||||||
width: 10rem;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.links {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
color: var(--color-text-dark);
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
margin: 0 0 1rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.links-1 {
|
|
||||||
grid-area: links-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.links-2 {
|
|
||||||
grid-area: links-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.links-3 {
|
|
||||||
grid-area: links-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.count-bubble {
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: 5rem;
|
|
||||||
background: var(--color-brand);
|
|
||||||
color: var(--color-text-inverted);
|
|
||||||
padding: 0 0.35rem;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
grid-area: buttons;
|
|
||||||
|
|
||||||
button,
|
|
||||||
a {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.not-affiliated-notice {
|
|
||||||
grid-area: notice;
|
|
||||||
font-size: var(--font-size-xs);
|
|
||||||
text-align: center;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-top: var(--spacing-card-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: 1024px) {
|
|
||||||
display: grid;
|
|
||||||
margin-inline: auto;
|
|
||||||
grid-template:
|
|
||||||
"logo-info links-1 links-2 links-3 buttons" auto
|
|
||||||
"notice notice notice notice notice" auto;
|
|
||||||
text-align: unset;
|
|
||||||
|
|
||||||
.logo-info {
|
|
||||||
margin-right: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.links {
|
|
||||||
margin-right: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.buttons {
|
|
||||||
width: unset;
|
|
||||||
margin-left: 0;
|
|
||||||
|
|
||||||
button,
|
|
||||||
a {
|
|
||||||
margin-right: unset;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.not-affiliated-notice {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
@@ -1444,9 +1579,120 @@ function hideStagingBanner() {
|
|||||||
.mobile-navigation {
|
.mobile-navigation {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
.footer-brand-background {
|
||||||
padding-top: 1.5rem;
|
background: var(--brand-gradient-strong-bg);
|
||||||
|
border-color: var(--brand-gradient-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.over-the-top-random-animation {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
pointer-events: none;
|
||||||
|
scale: 0.5;
|
||||||
|
transition: all 0.5s ease-out;
|
||||||
|
opacity: 0;
|
||||||
|
animation:
|
||||||
|
tilt-shaking calc(0.2s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite,
|
||||||
|
translate-x-shaking calc(0.3s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite,
|
||||||
|
translate-y-shaking calc(0.25s / (max((var(--_r-count) - 20), 1) / 20)) linear infinite;
|
||||||
|
|
||||||
|
&.threshold {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.rings-expand {
|
||||||
|
scale: 0.8;
|
||||||
|
opacity: 0;
|
||||||
|
|
||||||
|
.animation-ring-1 {
|
||||||
|
width: 25rem;
|
||||||
|
height: 25rem;
|
||||||
|
}
|
||||||
|
.animation-ring-2 {
|
||||||
|
width: 50rem;
|
||||||
|
height: 50rem;
|
||||||
|
}
|
||||||
|
.animation-ring-3 {
|
||||||
|
width: 100rem;
|
||||||
|
height: 100rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
> div {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
position: absolute;
|
||||||
|
scale: calc(1 + max((var(--_r-count) - 20), 0) * 0.1);
|
||||||
|
transition: all 0.2s ease-out;
|
||||||
|
width: 20rem;
|
||||||
|
height: 20rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tilt-shaking {
|
||||||
|
0% {
|
||||||
|
rotate: 0deg;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
rotate: calc(1deg * (var(--_r-count) - 20));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
rotate: 0deg;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
rotate: calc(-1deg * (var(--_r-count) - 20));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
rotate: 0deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes translate-x-shaking {
|
||||||
|
0% {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
translate: calc(2px * (var(--_r-count) - 20));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
translate: calc(-2px * (var(--_r-count) - 20));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
translate: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes translate-y-shaking {
|
||||||
|
0% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translateY(calc(2px * (var(--_r-count) - 20)));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translateY(calc(-2px * (var(--_r-count) - 20)));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -287,45 +287,90 @@
|
|||||||
"layout.banner.verify-email.title": {
|
"layout.banner.verify-email.title": {
|
||||||
"message": "For security purposes, please verify your email address on Modrinth."
|
"message": "For security purposes, please verify your email address on Modrinth."
|
||||||
},
|
},
|
||||||
"layout.footer.company.careers": {
|
"layout.footer.about": {
|
||||||
|
"message": "About"
|
||||||
|
},
|
||||||
|
"layout.footer.about.blog": {
|
||||||
|
"message": "Blog"
|
||||||
|
},
|
||||||
|
"layout.footer.about.careers": {
|
||||||
"message": "Careers"
|
"message": "Careers"
|
||||||
},
|
},
|
||||||
"layout.footer.company.privacy": {
|
"layout.footer.about.changelog": {
|
||||||
"message": "Privacy"
|
"message": "Changelog"
|
||||||
},
|
},
|
||||||
"layout.footer.company.rules": {
|
"layout.footer.about.rewards-program": {
|
||||||
"message": "Rules"
|
"message": "Rewards Program"
|
||||||
},
|
},
|
||||||
"layout.footer.company.terms": {
|
"layout.footer.about.status": {
|
||||||
"message": "Terms"
|
"message": "Status"
|
||||||
},
|
},
|
||||||
"layout.footer.company.title": {
|
"layout.footer.legal": {
|
||||||
"message": "Company"
|
"message": "Legal"
|
||||||
},
|
|
||||||
"layout.footer.interact.title": {
|
|
||||||
"message": "Interact"
|
|
||||||
},
|
},
|
||||||
"layout.footer.legal-disclaimer": {
|
"layout.footer.legal-disclaimer": {
|
||||||
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
||||||
},
|
},
|
||||||
|
"layout.footer.legal.privacy-policy": {
|
||||||
|
"message": "Privacy Policy"
|
||||||
|
},
|
||||||
|
"layout.footer.legal.rules": {
|
||||||
|
"message": "Content Rules"
|
||||||
|
},
|
||||||
|
"layout.footer.legal.security-notice": {
|
||||||
|
"message": "Security Notice"
|
||||||
|
},
|
||||||
|
"layout.footer.legal.terms-of-use": {
|
||||||
|
"message": "Terms of Use"
|
||||||
|
},
|
||||||
"layout.footer.open-source": {
|
"layout.footer.open-source": {
|
||||||
"message": "Modrinth is <github-link>open source</github-link>."
|
"message": "Modrinth is <github-link>open source</github-link>."
|
||||||
},
|
},
|
||||||
"layout.footer.resources.blog": {
|
"layout.footer.products": {
|
||||||
"message": "Blog"
|
"message": "Products"
|
||||||
},
|
},
|
||||||
"layout.footer.resources.docs": {
|
"layout.footer.products.app": {
|
||||||
"message": "Docs"
|
"message": "Modrinth App"
|
||||||
},
|
},
|
||||||
"layout.footer.resources.status": {
|
"layout.footer.products.plus": {
|
||||||
"message": "Status"
|
"message": "Modrinth+"
|
||||||
},
|
},
|
||||||
"layout.footer.resources.support": {
|
"layout.footer.products.servers": {
|
||||||
"message": "Support"
|
"message": "Modrinth Servers"
|
||||||
},
|
},
|
||||||
"layout.footer.resources.title": {
|
"layout.footer.resources": {
|
||||||
"message": "Resources"
|
"message": "Resources"
|
||||||
},
|
},
|
||||||
|
"layout.footer.resources.api-docs": {
|
||||||
|
"message": "API documentation"
|
||||||
|
},
|
||||||
|
"layout.footer.resources.help-center": {
|
||||||
|
"message": "Help Center"
|
||||||
|
},
|
||||||
|
"layout.footer.resources.report-issues": {
|
||||||
|
"message": "Report issues"
|
||||||
|
},
|
||||||
|
"layout.footer.resources.translate": {
|
||||||
|
"message": "Translate"
|
||||||
|
},
|
||||||
|
"layout.footer.social.bluesky": {
|
||||||
|
"message": "Bluesky"
|
||||||
|
},
|
||||||
|
"layout.footer.social.discord": {
|
||||||
|
"message": "Discord"
|
||||||
|
},
|
||||||
|
"layout.footer.social.github": {
|
||||||
|
"message": "GitHub"
|
||||||
|
},
|
||||||
|
"layout.footer.social.mastodon": {
|
||||||
|
"message": "Mastodon"
|
||||||
|
},
|
||||||
|
"layout.footer.social.tumblr": {
|
||||||
|
"message": "Tumblr"
|
||||||
|
},
|
||||||
|
"layout.footer.social.x": {
|
||||||
|
"message": "X"
|
||||||
|
},
|
||||||
"layout.menu-toggle.action": {
|
"layout.menu-toggle.action": {
|
||||||
"message": "Toggle menu"
|
"message": "Toggle menu"
|
||||||
},
|
},
|
||||||
@@ -521,6 +566,9 @@
|
|||||||
"report.not-for.bug-reports": {
|
"report.not-for.bug-reports": {
|
||||||
"message": "Bug reports"
|
"message": "Bug reports"
|
||||||
},
|
},
|
||||||
|
"report.not-for.bug-reports.description": {
|
||||||
|
"message": "You can report bugs to their <issues-link>issue tracker</issues-link>."
|
||||||
|
},
|
||||||
"report.not-for.dmca": {
|
"report.not-for.dmca": {
|
||||||
"message": "DMCA takedowns"
|
"message": "DMCA takedowns"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -460,6 +460,10 @@
|
|||||||
class="new-page sidebar"
|
class="new-page sidebar"
|
||||||
:class="{
|
:class="{
|
||||||
'alt-layout': cosmetics.leftContentLayout,
|
'alt-layout': cosmetics.leftContentLayout,
|
||||||
|
'ultimate-sidebar':
|
||||||
|
showModerationChecklist &&
|
||||||
|
!collapsedModerationChecklist &&
|
||||||
|
!flags.alwaysShowChecklistAsPopup,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="normal-page__header relative my-4">
|
<div class="normal-page__header relative my-4">
|
||||||
@@ -674,7 +678,7 @@
|
|||||||
:auth="auth"
|
:auth="auth"
|
||||||
:tags="tags"
|
:tags="tags"
|
||||||
/>
|
/>
|
||||||
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="mb-4">
|
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="my-4">
|
||||||
{{ project.title }} has been archived. {{ project.title }} will not receive any further
|
{{ project.title }} has been archived. {{ project.title }} will not receive any further
|
||||||
updates unless the author decides to unarchive the project.
|
updates unless the author decides to unarchive the project.
|
||||||
</MessageBanner>
|
</MessageBanner>
|
||||||
@@ -805,13 +809,18 @@
|
|||||||
@delete-version="deleteVersion"
|
@delete-version="deleteVersion"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="normal-page__ultimate-sidebar">
|
||||||
|
<ModerationChecklist
|
||||||
|
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||||
|
:project="project"
|
||||||
|
:future-projects="futureProjects"
|
||||||
|
:reset-project="resetProject"
|
||||||
|
:collapsed="collapsedModerationChecklist"
|
||||||
|
@exit="showModerationChecklist = false"
|
||||||
|
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ModerationChecklist
|
|
||||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
|
||||||
:project="project"
|
|
||||||
:future-projects="futureProjects"
|
|
||||||
:reset-project="resetProject"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -1431,6 +1440,7 @@ async function copyId() {
|
|||||||
const collapsedChecklist = ref(false);
|
const collapsedChecklist = ref(false);
|
||||||
|
|
||||||
const showModerationChecklist = ref(false);
|
const showModerationChecklist = ref(false);
|
||||||
|
const collapsedModerationChecklist = ref(false);
|
||||||
const futureProjects = ref([]);
|
const futureProjects = ref([]);
|
||||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||||
showModerationChecklist.value = true;
|
showModerationChecklist.value = true;
|
||||||
|
|||||||
@@ -8,21 +8,25 @@
|
|||||||
<span class="label__subdescription">
|
<span class="label__subdescription">
|
||||||
The description must clearly and honestly describe the purpose and function of the
|
The description must clearly and honestly describe the purpose and function of the
|
||||||
project. See section 2.1 of the
|
project. See section 2.1 of the
|
||||||
<nuxt-link to="/legal/rules" class="text-link" target="_blank">Content Rules</nuxt-link>
|
<nuxt-link class="text-link" target="_blank" to="/legal/rules">Content Rules</nuxt-link>
|
||||||
for the full requirements.
|
for the full requirements.
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
v-model="description"
|
v-model="description"
|
||||||
|
:disabled="
|
||||||
|
!currentMember ||
|
||||||
|
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !==
|
||||||
|
TeamMemberPermission.EDIT_BODY
|
||||||
|
"
|
||||||
:on-image-upload="onUploadHandler"
|
:on-image-upload="onUploadHandler"
|
||||||
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
|
|
||||||
/>
|
/>
|
||||||
<div class="input-group markdown-disclaimer">
|
<div class="input-group markdown-disclaimer">
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
class="iconified-button brand-button"
|
|
||||||
:disabled="!hasChanges"
|
:disabled="!hasChanges"
|
||||||
|
class="iconified-button brand-button"
|
||||||
|
type="button"
|
||||||
@click="saveChanges()"
|
@click="saveChanges()"
|
||||||
>
|
>
|
||||||
<SaveIcon />
|
<SaveIcon />
|
||||||
@@ -33,91 +37,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script lang="ts" setup>
|
||||||
|
import { SaveIcon } from "@modrinth/assets";
|
||||||
import { MarkdownEditor } from "@modrinth/ui";
|
import { MarkdownEditor } from "@modrinth/ui";
|
||||||
import Chips from "~/components/ui/Chips.vue";
|
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
|
||||||
import SaveIcon from "~/assets/images/utils/save.svg?component";
|
import { computed, ref } from "vue";
|
||||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
|
||||||
import { useImageUpload } from "~/composables/image-upload.ts";
|
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const props = defineProps<{
|
||||||
components: {
|
project: Project;
|
||||||
Chips,
|
allMembers: TeamMember[];
|
||||||
SaveIcon,
|
currentMember: TeamMember | undefined;
|
||||||
MarkdownEditor,
|
patchProject: (payload: object, quiet?: boolean) => object;
|
||||||
},
|
}>();
|
||||||
props: {
|
|
||||||
project: {
|
|
||||||
type: Object,
|
|
||||||
default() {
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
allMembers: {
|
|
||||||
type: Array,
|
|
||||||
default() {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
},
|
|
||||||
currentMember: {
|
|
||||||
type: Object,
|
|
||||||
default() {
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
patchProject: {
|
|
||||||
type: Function,
|
|
||||||
default() {
|
|
||||||
return () => {
|
|
||||||
this.$notify({
|
|
||||||
group: "main",
|
|
||||||
title: "An error occurred",
|
|
||||||
text: "Patch project function not found",
|
|
||||||
type: "error",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
description: this.project.body,
|
|
||||||
bodyViewMode: "source",
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
patchData() {
|
|
||||||
const data = {};
|
|
||||||
|
|
||||||
if (this.description !== this.project.body) {
|
const description = ref(props.project.body);
|
||||||
data.body = this.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
const patchRequestPayload = computed(() => {
|
||||||
},
|
const payload: {
|
||||||
hasChanges() {
|
body?: string;
|
||||||
return Object.keys(this.patchData).length > 0;
|
} = {};
|
||||||
},
|
|
||||||
},
|
if (description.value !== props.project.body) {
|
||||||
created() {
|
payload.body = description.value;
|
||||||
this.EDIT_BODY = 1 << 3;
|
}
|
||||||
},
|
|
||||||
methods: {
|
return payload;
|
||||||
renderHighlightedString,
|
|
||||||
saveChanges() {
|
|
||||||
if (this.hasChanges) {
|
|
||||||
this.patchProject(this.patchData);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async onUploadHandler(file) {
|
|
||||||
const response = await useImageUpload(file, {
|
|
||||||
context: "project",
|
|
||||||
projectID: this.project.id,
|
|
||||||
});
|
|
||||||
return response.url;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
return Object.keys(patchRequestPayload.value).length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveChanges() {
|
||||||
|
props.patchProject(patchRequestPayload.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onUploadHandler(file: File) {
|
||||||
|
const response = await useImageUpload(file, {
|
||||||
|
context: "project",
|
||||||
|
projectID: props.project.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.url;
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -1,61 +1,128 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<section class="universal-card">
|
<section class="universal-card">
|
||||||
|
<h2 class="label__title size-card-header">License</h2>
|
||||||
|
<p class="label__description">
|
||||||
|
It is important to choose a proper license for your
|
||||||
|
{{ formatProjectType(project.project_type).toLowerCase() }}. You may choose one from our
|
||||||
|
list or provide a custom license. You may also provide a custom URL to your chosen license;
|
||||||
|
otherwise, the license text will be displayed. See our
|
||||||
|
<a
|
||||||
|
href="https://blog.modrinth.com/licensing-guide/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
class="text-link"
|
||||||
|
>
|
||||||
|
licensing guide
|
||||||
|
</a>
|
||||||
|
for more information.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="adjacent-input">
|
<div class="adjacent-input">
|
||||||
<label for="license-multiselect">
|
<label for="license-multiselect">
|
||||||
<span class="label__title size-card-header">License</span>
|
<span class="label__title">Select a license</span>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
It is very important to choose a proper license for your
|
How users are and aren't allowed to use your project.
|
||||||
{{ $formatProjectType(project.project_type).toLowerCase() }}. You may choose one from
|
|
||||||
our list or provide a custom license. You may also provide a custom URL to your chosen
|
|
||||||
license; otherwise, the license text will be displayed.
|
|
||||||
<span v-if="license && license.friendly === 'Custom'" class="label__subdescription">
|
|
||||||
Enter a valid
|
|
||||||
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
|
|
||||||
SPDX license identifier</a
|
|
||||||
>
|
|
||||||
in the marked area. If your license does not have a SPDX identifier (for example, if
|
|
||||||
you created the license yourself or if the license is Minecraft-specific), simply
|
|
||||||
check the box and enter the name of the license instead.
|
|
||||||
</span>
|
|
||||||
<span class="label__subdescription">
|
|
||||||
Confused? See our
|
|
||||||
<a
|
|
||||||
href="https://blog.modrinth.com/licensing-guide/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
class="text-link"
|
|
||||||
>
|
|
||||||
licensing guide</a
|
|
||||||
>
|
|
||||||
for more information.
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="input-stack">
|
|
||||||
<Multiselect
|
<div class="w-1/2">
|
||||||
id="license-multiselect"
|
<DropdownSelect
|
||||||
v-model="license"
|
v-model="license"
|
||||||
|
name="License selector"
|
||||||
|
:options="builtinLicenses"
|
||||||
|
:display-name="(chosen: BuiltinLicense) => chosen.friendly"
|
||||||
placeholder="Select license..."
|
placeholder="Select license..."
|
||||||
track-by="short"
|
/>
|
||||||
label="friendly"
|
</div>
|
||||||
:options="defaultLicenses"
|
</div>
|
||||||
:searchable="true"
|
|
||||||
:close-on-select="true"
|
<div class="adjacent-input" v-if="license.requiresOnlyOrLater">
|
||||||
:show-labels="false"
|
<label for="or-later-checkbox">
|
||||||
:class="{
|
<span class="label__title">Later editions</span>
|
||||||
'known-error': license?.short === '' && showKnownErrors,
|
<span class="label__description">
|
||||||
}"
|
The license you selected has an "or later" clause. If you check this box, users may use
|
||||||
|
your project under later editions of the license.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Checkbox
|
||||||
|
id="or-later-checkbox"
|
||||||
|
v-model="allowOrLater"
|
||||||
|
:disabled="!hasPermission"
|
||||||
|
description="Allow later editions"
|
||||||
|
class="w-1/2"
|
||||||
|
>
|
||||||
|
Allow later editions
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adjacent-input">
|
||||||
|
<label for="license-url">
|
||||||
|
<span class="label__title">License URL</span>
|
||||||
|
<span class="label__description" v-if="license?.friendly !== 'Custom'">
|
||||||
|
The web location of the full license text. If you don't provide a link, the license text
|
||||||
|
will be displayed instead.
|
||||||
|
</span>
|
||||||
|
<span class="label__description" v-else>
|
||||||
|
The web location of the full license text. You have to provide a link since this is a
|
||||||
|
custom license.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="w-1/2">
|
||||||
|
<input
|
||||||
|
id="license-url"
|
||||||
|
v-model="licenseUrl"
|
||||||
|
type="url"
|
||||||
|
maxlength="2048"
|
||||||
|
:placeholder="license?.friendly !== 'Custom' ? `License URL (optional)` : `License URL`"
|
||||||
|
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adjacent-input" v-if="license?.friendly === 'Custom'">
|
||||||
|
<label for="license-spdx" v-if="!nonSpdxLicense">
|
||||||
|
<span class="label__title">SPDX identifier</span>
|
||||||
|
<span class="label__description">
|
||||||
|
If your license does not have an offical
|
||||||
|
<a href="https://spdx.org/licenses/" target="_blank" rel="noopener" class="text-link">
|
||||||
|
SPDX license identifier</a
|
||||||
|
>, check the box and enter the name of the license instead.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label for="license-name" v-else>
|
||||||
|
<span class="label__title">License name</span>
|
||||||
|
<span class="label__description"
|
||||||
|
>The full name of the license. If the license has a SPDX identifier, please uncheck the
|
||||||
|
checkbox and use the identifier instead.</span
|
||||||
|
>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="input-stack w-1/2">
|
||||||
|
<input
|
||||||
|
v-if="!nonSpdxLicense"
|
||||||
|
v-model="license.short"
|
||||||
|
id="license-spdx"
|
||||||
|
class="w-full"
|
||||||
|
type="text"
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="SPDX identifier"
|
||||||
:disabled="!hasPermission"
|
:disabled="!hasPermission"
|
||||||
/>
|
/>
|
||||||
<Checkbox
|
<input
|
||||||
v-if="license?.requiresOnlyOrLater"
|
v-else
|
||||||
v-model="allowOrLater"
|
v-model="license.short"
|
||||||
|
id="license-name"
|
||||||
|
class="w-full"
|
||||||
|
type="text"
|
||||||
|
maxlength="128"
|
||||||
|
placeholder="License name"
|
||||||
:disabled="!hasPermission"
|
:disabled="!hasPermission"
|
||||||
description="Allow later editions of this license"
|
/>
|
||||||
>
|
|
||||||
Allow later editions of this license
|
|
||||||
</Checkbox>
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
v-if="license?.friendly === 'Custom'"
|
v-if="license?.friendly === 'Custom'"
|
||||||
v-model="nonSpdxLicense"
|
v-model="nonSpdxLicense"
|
||||||
@@ -64,31 +131,18 @@
|
|||||||
>
|
>
|
||||||
License does not have a SPDX identifier
|
License does not have a SPDX identifier
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<input
|
|
||||||
v-if="license?.friendly === 'Custom'"
|
|
||||||
v-model="license.short"
|
|
||||||
type="text"
|
|
||||||
maxlength="2048"
|
|
||||||
:placeholder="nonSpdxLicense ? 'License name' : 'SPDX identifier'"
|
|
||||||
:class="{
|
|
||||||
'known-error': license.short === '' && showKnownErrors,
|
|
||||||
}"
|
|
||||||
:disabled="!hasPermission"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
v-model="licenseUrl"
|
|
||||||
type="url"
|
|
||||||
maxlength="2048"
|
|
||||||
placeholder="License URL (optional)"
|
|
||||||
:disabled="!hasPermission || licenseId === 'LicenseRef-Unknown'"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-stack">
|
<div class="input-stack">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="iconified-button brand-button"
|
class="iconified-button brand-button"
|
||||||
:disabled="!hasChanges || license === null"
|
:disabled="
|
||||||
|
!hasChanges ||
|
||||||
|
!hasPermission ||
|
||||||
|
(license.friendly === 'Custom' && (license.short === '' || licenseUrl === ''))
|
||||||
|
"
|
||||||
@click="saveChanges()"
|
@click="saveChanges()"
|
||||||
>
|
>
|
||||||
<SaveIcon />
|
<SaveIcon />
|
||||||
@@ -99,199 +153,109 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import Multiselect from "vue-multiselect";
|
import { Checkbox, DropdownSelect } from "@modrinth/ui";
|
||||||
import Checkbox from "~/components/ui/Checkbox";
|
import {
|
||||||
|
TeamMemberPermission,
|
||||||
|
builtinLicenses,
|
||||||
|
formatProjectType,
|
||||||
|
type BuiltinLicense,
|
||||||
|
type Project,
|
||||||
|
type TeamMember,
|
||||||
|
} from "@modrinth/utils";
|
||||||
|
import { computed, ref, type Ref } from "vue";
|
||||||
import SaveIcon from "~/assets/images/utils/save.svg?component";
|
import SaveIcon from "~/assets/images/utils/save.svg?component";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
const props = defineProps<{
|
||||||
components: {
|
project: Project;
|
||||||
Multiselect,
|
currentMember: TeamMember | undefined;
|
||||||
Checkbox,
|
patchProject: (payload: Object, quiet?: boolean) => Object;
|
||||||
SaveIcon,
|
}>();
|
||||||
},
|
|
||||||
props: {
|
|
||||||
project: {
|
|
||||||
type: Object,
|
|
||||||
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 {
|
|
||||||
licenseUrl: "",
|
|
||||||
license: { friendly: "", short: "", requiresOnlyOrLater: false },
|
|
||||||
allowOrLater: this.project.license.id.includes("-or-later"),
|
|
||||||
nonSpdxLicense: this.project.license.id.includes("LicenseRef-"),
|
|
||||||
showKnownErrors: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
async setup(props) {
|
|
||||||
const defaultLicenses = shallowRef([
|
|
||||||
{ friendly: "Custom", short: "" },
|
|
||||||
{
|
|
||||||
friendly: "All Rights Reserved/No License",
|
|
||||||
short: "All-Rights-Reserved",
|
|
||||||
},
|
|
||||||
{ friendly: "Apache License 2.0", short: "Apache-2.0" },
|
|
||||||
{
|
|
||||||
friendly: 'BSD 2-Clause "Simplified" License',
|
|
||||||
short: "BSD-2-Clause",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: 'BSD 3-Clause "New" or "Revised" License',
|
|
||||||
short: "BSD-3-Clause",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "CC Zero (Public Domain equivalent)",
|
|
||||||
short: "CC0-1.0",
|
|
||||||
},
|
|
||||||
{ friendly: "CC-BY 4.0", short: "CC-BY-4.0" },
|
|
||||||
{
|
|
||||||
friendly: "CC-BY-SA 4.0",
|
|
||||||
short: "CC-BY-SA-4.0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "CC-BY-NC 4.0",
|
|
||||||
short: "CC-BY-NC-4.0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "CC-BY-NC-SA 4.0",
|
|
||||||
short: "CC-BY-NC-SA-4.0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "CC-BY-ND 4.0",
|
|
||||||
short: "CC-BY-ND-4.0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "CC-BY-NC-ND 4.0",
|
|
||||||
short: "CC-BY-NC-ND-4.0",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "GNU Affero General Public License v3",
|
|
||||||
short: "AGPL-3.0",
|
|
||||||
requiresOnlyOrLater: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "GNU Lesser General Public License v2.1",
|
|
||||||
short: "LGPL-2.1",
|
|
||||||
requiresOnlyOrLater: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "GNU Lesser General Public License v3",
|
|
||||||
short: "LGPL-3.0",
|
|
||||||
requiresOnlyOrLater: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "GNU General Public License v2",
|
|
||||||
short: "GPL-2.0",
|
|
||||||
requiresOnlyOrLater: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
friendly: "GNU General Public License v3",
|
|
||||||
short: "GPL-3.0",
|
|
||||||
requiresOnlyOrLater: true,
|
|
||||||
},
|
|
||||||
{ friendly: "ISC License", short: "ISC" },
|
|
||||||
{ friendly: "MIT License", short: "MIT" },
|
|
||||||
{ friendly: "Mozilla Public License 2.0", short: "MPL-2.0" },
|
|
||||||
{ friendly: "zlib License", short: "Zlib" },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const licenseUrl = ref(props.project.license.url);
|
const licenseUrl = ref(props.project.license.url);
|
||||||
|
const license: Ref<{
|
||||||
const licenseId = props.project.license.id;
|
friendly: string;
|
||||||
const trimmedLicenseId = licenseId
|
short: string;
|
||||||
.replaceAll("-only", "")
|
requiresOnlyOrLater?: boolean;
|
||||||
.replaceAll("-or-later", "")
|
}> = ref({
|
||||||
.replaceAll("LicenseRef-", "");
|
friendly: "",
|
||||||
|
short: "",
|
||||||
const license = ref(
|
requiresOnlyOrLater: false,
|
||||||
defaultLicenses.value.find((x) => x.short === trimmedLicenseId) ?? {
|
|
||||||
friendly: "Custom",
|
|
||||||
short: licenseId.replaceAll("LicenseRef-", ""),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (licenseId === "LicenseRef-Unknown") {
|
|
||||||
license.value = {
|
|
||||||
friendly: "Unknown",
|
|
||||||
short: licenseId.replaceAll("LicenseRef-", ""),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
defaultLicenses,
|
|
||||||
licenseUrl,
|
|
||||||
license,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
hasPermission() {
|
|
||||||
const EDIT_DETAILS = 1 << 2;
|
|
||||||
return (this.currentMember.permissions & EDIT_DETAILS) === EDIT_DETAILS;
|
|
||||||
},
|
|
||||||
licenseId() {
|
|
||||||
let id = "";
|
|
||||||
if (this.license === null) return id;
|
|
||||||
if (
|
|
||||||
(this.nonSpdxLicense && this.license.friendly === "Custom") ||
|
|
||||||
this.license.short === "All-Rights-Reserved" ||
|
|
||||||
this.license.short === "Unknown"
|
|
||||||
) {
|
|
||||||
id += "LicenseRef-";
|
|
||||||
}
|
|
||||||
id += this.license.short;
|
|
||||||
if (this.license.requiresOnlyOrLater) {
|
|
||||||
id += this.allowOrLater ? "-or-later" : "-only";
|
|
||||||
}
|
|
||||||
if (this.nonSpdxLicense && this.license.friendly === "Custom") {
|
|
||||||
id = id.replaceAll(" ", "-");
|
|
||||||
}
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
patchData() {
|
|
||||||
const data = {};
|
|
||||||
|
|
||||||
if (this.licenseId !== this.project.license.id) {
|
|
||||||
data.license_id = this.licenseId;
|
|
||||||
data.license_url = this.licenseUrl ? this.licenseUrl : null;
|
|
||||||
} else if (this.licenseUrl !== this.project.license.url) {
|
|
||||||
data.license_url = this.licenseUrl ? this.licenseUrl : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
},
|
|
||||||
hasChanges() {
|
|
||||||
return Object.keys(this.patchData).length > 0;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
saveChanges() {
|
|
||||||
if (this.hasChanges) {
|
|
||||||
this.patchProject(this.patchData);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const allowOrLater = ref(props.project.license.id.includes("-or-later"));
|
||||||
|
const nonSpdxLicense = ref(props.project.license.id.includes("LicenseRef-"));
|
||||||
|
|
||||||
|
const oldLicenseId = props.project.license.id;
|
||||||
|
const trimmedLicenseId = oldLicenseId
|
||||||
|
.replaceAll("-only", "")
|
||||||
|
.replaceAll("-or-later", "")
|
||||||
|
.replaceAll("LicenseRef-", "");
|
||||||
|
|
||||||
|
license.value = builtinLicenses.find((x) => x.short === trimmedLicenseId) ?? {
|
||||||
|
friendly: "Custom",
|
||||||
|
short: oldLicenseId.replaceAll("LicenseRef-", ""),
|
||||||
|
requiresOnlyOrLater: oldLicenseId.includes("-or-later"),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (oldLicenseId === "LicenseRef-Unknown") {
|
||||||
|
// Mark it as not having a license, forcing the user to select one
|
||||||
|
license.value = {
|
||||||
|
friendly: "",
|
||||||
|
short: oldLicenseId.replaceAll("LicenseRef-", ""),
|
||||||
|
requiresOnlyOrLater: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = computed(() => {
|
||||||
|
return (props.currentMember?.permissions ?? 0) & TeamMemberPermission.EDIT_DETAILS;
|
||||||
|
});
|
||||||
|
|
||||||
|
const licenseId = computed(() => {
|
||||||
|
let id = "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
(nonSpdxLicense && license.value.friendly === "Custom") ||
|
||||||
|
license.value.short === "All-Rights-Reserved" ||
|
||||||
|
license.value.short === "Unknown"
|
||||||
|
) {
|
||||||
|
id += "LicenseRef-";
|
||||||
|
}
|
||||||
|
|
||||||
|
id += license.value.short;
|
||||||
|
if (license.value.requiresOnlyOrLater) {
|
||||||
|
id += allowOrLater.value ? "-or-later" : "-only";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonSpdxLicense && license.value.friendly === "Custom") {
|
||||||
|
id = id.replaceAll(" ", "-");
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchRequestPayload = computed(() => {
|
||||||
|
const payload: {
|
||||||
|
license_id?: string;
|
||||||
|
license_url?: string | null; // null = remove url
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
if (licenseId.value !== props.project.license.id) {
|
||||||
|
payload.license_id = licenseId.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (licenseUrl.value !== props.project.license.url) {
|
||||||
|
payload.license_url = licenseUrl.value ? licenseUrl.value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasChanges = computed(() => {
|
||||||
|
return Object.keys(patchRequestPayload.value).length > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function saveChanges() {
|
||||||
|
props.patchProject(patchRequestPayload.value);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -640,7 +640,6 @@ import Badge from "~/components/ui/Badge.vue";
|
|||||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||||
import Categories from "~/components/ui/search/Categories.vue";
|
import Categories from "~/components/ui/search/Categories.vue";
|
||||||
import Chips from "~/components/ui/Chips.vue";
|
|
||||||
import Checkbox from "~/components/ui/Checkbox.vue";
|
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||||
import FileInput from "~/components/ui/FileInput.vue";
|
import FileInput from "~/components/ui/FileInput.vue";
|
||||||
|
|
||||||
@@ -663,6 +662,7 @@ import Modal from "~/components/ui/Modal.vue";
|
|||||||
import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component";
|
import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component";
|
||||||
|
|
||||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||||
|
|
||||||
export default defineNuxtComponent({
|
export default defineNuxtComponent({
|
||||||
components: {
|
components: {
|
||||||
MarkdownEditor,
|
MarkdownEditor,
|
||||||
@@ -670,7 +670,6 @@ export default defineNuxtComponent({
|
|||||||
FileInput,
|
FileInput,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
ChevronRightIcon,
|
ChevronRightIcon,
|
||||||
Chips,
|
|
||||||
Categories,
|
Categories,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
|
|||||||
@@ -98,6 +98,14 @@
|
|||||||
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
|
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
|
||||||
shown: !currentMember,
|
shown: !currentMember,
|
||||||
},
|
},
|
||||||
|
{ divider: true, shown: currentMember || flags.developerMode },
|
||||||
|
{
|
||||||
|
id: 'copy-id',
|
||||||
|
action: () => {
|
||||||
|
copyToClipboard(version.id);
|
||||||
|
},
|
||||||
|
shown: currentMember || flags.developerMode,
|
||||||
|
},
|
||||||
{ divider: true, shown: currentMember },
|
{ divider: true, shown: currentMember },
|
||||||
{
|
{
|
||||||
id: 'edit',
|
id: 'edit',
|
||||||
@@ -148,6 +156,10 @@
|
|||||||
<TrashIcon aria-hidden="true" />
|
<TrashIcon aria-hidden="true" />
|
||||||
Delete
|
Delete
|
||||||
</template>
|
</template>
|
||||||
|
<template #copy-id>
|
||||||
|
<ClipboardCopyIcon aria-hidden="true" />
|
||||||
|
Copy ID
|
||||||
|
</template>
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</template>
|
</template>
|
||||||
@@ -174,6 +186,7 @@ import {
|
|||||||
ReportIcon,
|
ReportIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import DropArea from "~/components/ui/DropArea.vue";
|
import DropArea from "~/components/ui/DropArea.vue";
|
||||||
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||||
|
|||||||
@@ -40,12 +40,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
||||||
</label>
|
</label>
|
||||||
<Toggle
|
<Toggle id="unprovision" v-model="unprovision" />
|
||||||
id="unprovision"
|
|
||||||
:model-value="unprovision"
|
|
||||||
:checked="unprovision"
|
|
||||||
@update:model-value="() => (unprovision = !unprovision)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
@@ -63,50 +58,137 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NewModal>
|
</NewModal>
|
||||||
<div class="normal-page no-sidebar">
|
<div class="page experimental-styles-within">
|
||||||
<h1>{{ user.username }}'s subscriptions</h1>
|
<div
|
||||||
<div class="normal-page__content">
|
class="mb-4 flex items-center justify-between border-0 border-b border-solid border-divider pb-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Avatar :src="user.avatar_url" :alt="user.username" size="32px" circle />
|
||||||
|
<h1 class="m-0 text-2xl font-extrabold">{{ user.username }}'s subscriptions</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ButtonStyled>
|
||||||
|
<nuxt-link :to="`/user/${user.id}`">
|
||||||
|
<UserIcon aria-hidden="true" />
|
||||||
|
User profile
|
||||||
|
<ExternalIcon class="h-4 w-4" />
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
|
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
|
||||||
<span class="font-extrabold text-contrast">
|
<div class="mb-4 grid grid-cols-[1fr_auto]">
|
||||||
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
<div>
|
||||||
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
<span class="flex items-center gap-2 font-semibold text-contrast">
|
||||||
Modrinth Servers
|
<template v-if="subscription.product.metadata.type === 'midas'">
|
||||||
</template>
|
<ModrinthPlusIcon class="h-7 w-min" />
|
||||||
<template v-else> Unknown product </template>
|
</template>
|
||||||
<template v-if="subscription.interval">
|
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||||
{{ subscription.interval }}
|
<ModrinthServersIcon class="h-7 w-min" />
|
||||||
</template>
|
</template>
|
||||||
</span>
|
<template v-else> Unknown product </template>
|
||||||
<div class="mb-4 mt-2 flex items-center gap-1">
|
</span>
|
||||||
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }}
|
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
|
||||||
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template>
|
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
|
||||||
</div>
|
{{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
|
||||||
<div
|
dayjs(subscription.created).fromNow()
|
||||||
v-for="charge in subscription.charges"
|
}})
|
||||||
:key="charge.id"
|
</div>
|
||||||
class="universal-card recessed flex items-center justify-between gap-4"
|
</div>
|
||||||
>
|
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
|
||||||
<div class="flex w-full items-center justify-between gap-4">
|
<ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
|
||||||
<div class="flex items-center gap-1">
|
<nuxt-link
|
||||||
<Badge
|
:to="`/servers/manage/${subscription.metadata.id}`"
|
||||||
:color="charge.status === 'succeeded' ? 'green' : 'red'"
|
target="_blank"
|
||||||
:type="charge.status"
|
class="w-fit"
|
||||||
/>
|
>
|
||||||
⋅
|
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
|
||||||
{{ charge.type }}
|
</nuxt-link>
|
||||||
⋅
|
</ButtonStyled>
|
||||||
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
|
<CopyCode :text="subscription.metadata.id" />
|
||||||
⋅
|
</div>
|
||||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
</div>
|
||||||
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template>
|
<div class="flex flex-col gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(charge, index) in subscription.charges"
|
||||||
|
:key="charge.id"
|
||||||
|
class="relative overflow-clip rounded-xl bg-bg px-4 py-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute bottom-0 left-0 top-0 w-1"
|
||||||
|
:class="charge.type === 'refund' ? 'bg-purple' : chargeStatuses[charge.status].color"
|
||||||
|
/>
|
||||||
|
<div class="grid w-full grid-cols-[1fr_auto] items-center gap-4">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<span>
|
||||||
|
<span class="font-bold text-contrast">
|
||||||
|
<template v-if="charge.status === 'succeeded'"> Succeeded </template>
|
||||||
|
<template v-else-if="charge.status === 'failed'"> Failed </template>
|
||||||
|
<template v-else-if="charge.status === 'cancelled'"> Cancelled </template>
|
||||||
|
<template v-else-if="charge.status === 'processing'"> Processing </template>
|
||||||
|
<template v-else-if="charge.status === 'open'"> Upcoming </template>
|
||||||
|
<template v-else> {{ charge.status }} </template>
|
||||||
|
</span>
|
||||||
|
⋅
|
||||||
|
<span>
|
||||||
|
<template v-if="charge.type === 'refund'"> Refund </template>
|
||||||
|
<template v-else-if="charge.type === 'subscription'">
|
||||||
|
<template v-if="charge.status === 'cancelled'"> Subscription </template>
|
||||||
|
<template v-else-if="index === subscription.charges.length - 1">
|
||||||
|
Started subscription
|
||||||
|
</template>
|
||||||
|
<template v-else> Subscription renewal </template>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="charge.type === 'one-time'"> One-time charge </template>
|
||||||
|
<template v-else-if="charge.type === 'proration'"> Proration charge </template>
|
||||||
|
<template v-else> {{ charge.status }} </template>
|
||||||
|
</span>
|
||||||
|
<template v-if="charge.status !== 'cancelled'">
|
||||||
|
⋅
|
||||||
|
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-secondary">
|
||||||
|
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||||
|
<span class="text-secondary">({{ dayjs(charge.due).fromNow() }}) </span>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-if="flags.developerMode"
|
||||||
|
class="flex w-full items-center gap-1 text-xs text-secondary"
|
||||||
|
>
|
||||||
|
{{ charge.status }}
|
||||||
|
⋅
|
||||||
|
{{ charge.type }}
|
||||||
|
⋅
|
||||||
|
{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}
|
||||||
|
⋅
|
||||||
|
{{ dayjs(charge.due).format("YYYY-MM-DD h:mma") }}
|
||||||
|
<template v-if="charge.subscription_interval">
|
||||||
|
⋅ {{ charge.subscription_interval }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled
|
||||||
|
v-if="
|
||||||
|
charges.some((x) => x.type === 'refund' && x.parent_charge_id === charge.id)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="button-like disabled"><CheckIcon /> Charge refunded</div>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled
|
||||||
|
v-else-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||||
|
color="red"
|
||||||
|
color-fill="text"
|
||||||
|
>
|
||||||
|
<button @click="showRefundModal(charge)">
|
||||||
|
<CurrencyIcon />
|
||||||
|
Refund options
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
|
||||||
class="btn"
|
|
||||||
@click="showRefundModal(charge)"
|
|
||||||
>
|
|
||||||
Refund charge
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,11 +196,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
|
import { Avatar, ButtonStyled, CopyCode, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
|
||||||
import { formatPrice } from "@modrinth/utils";
|
import { formatCategory, formatPrice } from "@modrinth/utils";
|
||||||
import { CheckIcon, XIcon } from "@modrinth/assets";
|
import {
|
||||||
|
CheckIcon,
|
||||||
|
XIcon,
|
||||||
|
UserIcon,
|
||||||
|
ModrinthPlusIcon,
|
||||||
|
ServerIcon,
|
||||||
|
ExternalIcon,
|
||||||
|
CurrencyIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import dayjs from "dayjs";
|
||||||
import { products } from "~/generated/state.json";
|
import { products } from "~/generated/state.json";
|
||||||
|
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||||
|
|
||||||
|
const flags = useFeatureFlags();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
const vintl = useVIntl();
|
const vintl = useVIntl();
|
||||||
@@ -169,7 +262,10 @@ const subscriptionCharges = computed(() => {
|
|||||||
return subscriptions.value.map((subscription) => {
|
return subscriptions.value.map((subscription) => {
|
||||||
return {
|
return {
|
||||||
...subscription,
|
...subscription,
|
||||||
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id),
|
charges: charges.value
|
||||||
|
.filter((charge) => charge.subscription_id === subscription.id)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => dayjs(b.due).diff(dayjs(a.due))),
|
||||||
product: products.find((product) =>
|
product: products.find((product) =>
|
||||||
product.prices.some((price) => price.id === subscription.price_id),
|
product.prices.some((price) => price.id === subscription.price_id),
|
||||||
),
|
),
|
||||||
@@ -217,4 +313,30 @@ async function refundCharge() {
|
|||||||
}
|
}
|
||||||
refunding.value = false;
|
refunding.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chargeStatuses = {
|
||||||
|
open: {
|
||||||
|
color: "bg-blue",
|
||||||
|
},
|
||||||
|
processing: {
|
||||||
|
color: "bg-orange",
|
||||||
|
},
|
||||||
|
succeeded: {
|
||||||
|
color: "bg-green",
|
||||||
|
},
|
||||||
|
failed: {
|
||||||
|
color: "bg-red",
|
||||||
|
},
|
||||||
|
cancelled: {
|
||||||
|
color: "bg-red",
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
.page {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 56rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
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>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
|
BoxIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
XIcon,
|
|
||||||
SaveIcon,
|
|
||||||
UploadIcon,
|
|
||||||
TrashIcon,
|
|
||||||
LinkIcon,
|
|
||||||
LockIcon,
|
|
||||||
GridIcon,
|
GridIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
ListIcon,
|
|
||||||
UpdatedIcon,
|
|
||||||
LibraryIcon,
|
LibraryIcon,
|
||||||
BoxIcon,
|
LinkIcon,
|
||||||
|
ListIcon,
|
||||||
|
LockIcon,
|
||||||
|
SaveIcon,
|
||||||
|
TrashIcon,
|
||||||
|
UpdatedIcon,
|
||||||
|
UploadIcon,
|
||||||
|
XIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import {
|
import {
|
||||||
PopoutMenu,
|
|
||||||
FileInput,
|
|
||||||
DropdownSelect,
|
|
||||||
Avatar,
|
Avatar,
|
||||||
Button,
|
Button,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
|
DropdownSelect,
|
||||||
|
FileInput,
|
||||||
|
PopoutMenu,
|
||||||
} from "@modrinth/ui";
|
} from "@modrinth/ui";
|
||||||
|
|
||||||
import { isAdmin } from "@modrinth/utils";
|
import { isAdmin } from "@modrinth/utils";
|
||||||
@@ -651,7 +651,7 @@ async function saveChanges() {
|
|||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: {
|
body: {
|
||||||
name: name.value,
|
name: name.value,
|
||||||
description: summary.value,
|
description: summary.value || null,
|
||||||
status: visibility.value,
|
status: visibility.value,
|
||||||
new_projects: newProjectIds,
|
new_projects: newProjectIds,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,7 +49,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
<nuxt-link
|
<nuxt-link
|
||||||
v-for="collection in orderedCollections"
|
v-for="collection in orderedCollections.sort(
|
||||||
|
(a, b) => new Date(b.created) - new Date(a.created),
|
||||||
|
)"
|
||||||
:key="collection.id"
|
:key="collection.id"
|
||||||
:to="`/collection/${collection.id}`"
|
:to="`/collection/${collection.id}`"
|
||||||
class="universal-card recessed collection"
|
class="universal-card recessed collection"
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { Button } from "@modrinth/ui";
|
import { Button, Chips } from "@modrinth/ui";
|
||||||
import { HistoryIcon } from "@modrinth/assets";
|
import { HistoryIcon } from "@modrinth/assets";
|
||||||
import {
|
import {
|
||||||
fetchExtraNotificationData,
|
fetchExtraNotificationData,
|
||||||
@@ -58,7 +58,6 @@ import {
|
|||||||
markAsRead,
|
markAsRead,
|
||||||
} from "~/helpers/notifications.js";
|
} from "~/helpers/notifications.js";
|
||||||
import NotificationItem from "~/components/ui/NotificationItem.vue";
|
import NotificationItem from "~/components/ui/NotificationItem.vue";
|
||||||
import Chips from "~/components/ui/Chips.vue";
|
|
||||||
import CheckCheckIcon from "~/assets/images/utils/check-check.svg?component";
|
import CheckCheckIcon from "~/assets/images/utils/check-check.svg?component";
|
||||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||||
import Pagination from "~/components/ui/Pagination.vue";
|
import Pagination from "~/components/ui/Pagination.vue";
|
||||||
|
|||||||
@@ -1,39 +1,95 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="experimental-styles-within">
|
||||||
<section class="universal-card">
|
<section class="universal-card">
|
||||||
<h2 class="text-2xl">Revenue</h2>
|
<h2 class="text-2xl">Revenue</h2>
|
||||||
<div v-if="userBalance.available >= minWithdraw">
|
<div class="grid-display">
|
||||||
<p>
|
<div class="grid-display__item">
|
||||||
You have
|
<div class="label">Available now</div>
|
||||||
<strong>{{ $formatMoney(userBalance.available) }}</strong>
|
<div class="value">
|
||||||
available to withdraw. <strong>{{ $formatMoney(userBalance.pending) }}</strong> of your
|
{{ $formatMoney(userBalance.available) }}
|
||||||
balance is <nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
|
</div>
|
||||||
</p>
|
</div>
|
||||||
|
<div class="grid-display__item">
|
||||||
|
<div class="label">
|
||||||
|
Total pending
|
||||||
|
<nuxt-link
|
||||||
|
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
|
||||||
|
class="align-middle text-link"
|
||||||
|
to="/legal/cmp-info#pending"
|
||||||
|
>
|
||||||
|
<UnknownIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</div>
|
||||||
|
<div class="value">
|
||||||
|
{{ $formatMoney(userBalance.pending) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid-display__item">
|
||||||
|
<h3 class="label m-0">
|
||||||
|
Available soon
|
||||||
|
<nuxt-link
|
||||||
|
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
|
||||||
|
class="align-middle text-link"
|
||||||
|
to="/legal/cmp-info#pending"
|
||||||
|
>
|
||||||
|
<UnknownIcon />
|
||||||
|
</nuxt-link>
|
||||||
|
</h3>
|
||||||
|
<ul class="m-0 list-none p-0">
|
||||||
|
<li
|
||||||
|
v-for="date in availableSoonDateKeys"
|
||||||
|
:key="date"
|
||||||
|
class="flex items-center justify-between border-0 border-solid border-b-divider p-0 [&:not(:last-child)]:mb-1 [&:not(:last-child)]:border-b-[1px] [&:not(:last-child)]:pb-1"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-tooltip="
|
||||||
|
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1
|
||||||
|
? `Revenue period is ongoing. \nThis amount is not yet finalized.`
|
||||||
|
: null
|
||||||
|
"
|
||||||
|
:class="{
|
||||||
|
'cursor-help':
|
||||||
|
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1,
|
||||||
|
}"
|
||||||
|
class="inline-flex items-center gap-1 font-bold"
|
||||||
|
>
|
||||||
|
{{ $formatMoney(availableSoonDates[date]) }}
|
||||||
|
<template
|
||||||
|
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
|
||||||
|
>
|
||||||
|
<InProgressIcon />
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-secondary">
|
||||||
|
{{ formatDate(dayjs(date)) }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-else>
|
|
||||||
You have made
|
|
||||||
<strong>{{ $formatMoney(userBalance.available) }}</strong
|
|
||||||
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
|
|
||||||
<strong>{{ $formatMoney(userBalance.pending) }}</strong> of your balance is
|
|
||||||
<nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
|
|
||||||
</p>
|
|
||||||
<div class="input-group mt-4">
|
<div class="input-group mt-4">
|
||||||
<nuxt-link
|
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
|
||||||
v-if="userBalance.available >= minWithdraw"
|
<nuxt-link
|
||||||
class="iconified-button brand-button"
|
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||||
to="/dashboard/revenue/withdraw"
|
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
|
||||||
>
|
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||||
<TransferIcon /> Withdraw
|
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
|
||||||
</nuxt-link>
|
class="iconified-button brand-button"
|
||||||
|
to="/dashboard/revenue/withdraw"
|
||||||
|
>
|
||||||
|
<TransferIcon /> Withdraw
|
||||||
|
</nuxt-link>
|
||||||
|
</span>
|
||||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||||
<HistoryIcon /> View transfer history
|
<HistoryIcon />
|
||||||
|
View transfer history
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p class="text-sm text-secondary">
|
||||||
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
|
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
|
||||||
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more
|
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
|
||||||
information on how the rewards system works, see our information page
|
information on how the rewards system works, see our information page
|
||||||
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>.
|
<nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
<section class="universal-card">
|
<section class="universal-card">
|
||||||
@@ -46,12 +102,13 @@
|
|||||||
{{ auth.user.payout_data.paypal_address }}
|
{{ auth.user.payout_data.paypal_address }}
|
||||||
</p>
|
</p>
|
||||||
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
|
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
|
||||||
<XIcon /> Disconnect account
|
<XIcon />
|
||||||
|
Disconnect account
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
|
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
|
||||||
<a class="btn mt-4" :href="`${getAuthUrl('paypal')}&token=${auth.token}`">
|
<a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
|
||||||
<PayPalIcon />
|
<PayPalIcon />
|
||||||
Sign in with PayPal
|
Sign in with PayPal
|
||||||
</a>
|
</a>
|
||||||
@@ -60,7 +117,8 @@
|
|||||||
<p>
|
<p>
|
||||||
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
|
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
|
||||||
visit
|
visit
|
||||||
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>.
|
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
<h3>Venmo</h3>
|
<h3>Venmo</h3>
|
||||||
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
|
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
|
||||||
@@ -68,18 +126,32 @@
|
|||||||
<input
|
<input
|
||||||
id="venmo"
|
id="venmo"
|
||||||
v-model="auth.user.payout_data.venmo_handle"
|
v-model="auth.user.payout_data.venmo_handle"
|
||||||
|
autocomplete="off"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
type="search"
|
|
||||||
name="search"
|
name="search"
|
||||||
placeholder="@example"
|
placeholder="@example"
|
||||||
autocomplete="off"
|
type="search"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button>
|
<button class="btn btn-secondary" @click="updateVenmo">
|
||||||
|
<SaveIcon />
|
||||||
|
Save information
|
||||||
|
</button>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from "@modrinth/assets";
|
import {
|
||||||
|
HistoryIcon,
|
||||||
|
InProgressIcon,
|
||||||
|
PayPalIcon,
|
||||||
|
SaveIcon,
|
||||||
|
TransferIcon,
|
||||||
|
UnknownIcon,
|
||||||
|
XIcon,
|
||||||
|
} from "@modrinth/assets";
|
||||||
|
import { formatDate } from "@modrinth/utils";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { computed } from "vue";
|
||||||
|
|
||||||
const auth = await useAuth();
|
const auth = await useAuth();
|
||||||
const minWithdraw = ref(0.01);
|
const minWithdraw = ref(0.01);
|
||||||
@@ -88,6 +160,33 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
|
|||||||
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
|
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const deadlineEnding = computed(() => {
|
||||||
|
let deadline = dayjs().subtract(2, "month").endOf("month").add(60, "days");
|
||||||
|
if (deadline.isBefore(dayjs().startOf("day"))) {
|
||||||
|
deadline = dayjs().subtract(1, "month").endOf("month").add(60, "days");
|
||||||
|
}
|
||||||
|
return deadline;
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableSoonDates = computed(() => {
|
||||||
|
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
|
||||||
|
const dates = Object.keys(userBalance.value.dates)
|
||||||
|
.filter((date) => {
|
||||||
|
const dateObj = dayjs(date);
|
||||||
|
return (
|
||||||
|
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, "month"))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.sort((a, b) => dayjs(a).diff(dayjs(b)));
|
||||||
|
|
||||||
|
return dates.reduce((acc, date) => {
|
||||||
|
acc[date] = userBalance.value.dates[date];
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value));
|
||||||
|
|
||||||
async function updateVenmo() {
|
async function updateVenmo() {
|
||||||
startLoading();
|
startLoading();
|
||||||
try {
|
try {
|
||||||
@@ -118,4 +217,16 @@ strong {
|
|||||||
color: var(--color-text-dark);
|
color: var(--color-text-dark);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.disabled-cursor-wrapper {
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled-link {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-display {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="markdown-body">
|
<div class="markdown-body">
|
||||||
<h1>Rewards Program Information</h1>
|
<h1>Rewards Program Information</h1>
|
||||||
<p><em>Last modified: Sep 12, 2024</em></p>
|
<p><em>Last modified: Feb 20, 2025</em></p>
|
||||||
<p>
|
<p>
|
||||||
This page was created for transparency for how the rewards program works on Modrinth. Feel
|
This page was created for transparency for how the rewards program works on Modrinth. Feel
|
||||||
free to join our Discord or email
|
free to join our Discord or email
|
||||||
@@ -82,42 +82,41 @@
|
|||||||
<p>
|
<p>
|
||||||
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
|
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
|
||||||
revenue is immediately available to withdraw. We pay creators as soon as we receive the money
|
revenue is immediately available to withdraw. We pay creators as soon as we receive the money
|
||||||
from our ad providers, which is 60 days after the last day of each month. This table outlines
|
from our ad providers, which is 60 days after the last day of each month.
|
||||||
some example dates of how NET 60 payments are made:
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
To understand when revenue becomes available, you can use this calculator to estimate when
|
||||||
|
revenue earned on a specific date will be available for withdrawal. Please be advised that all
|
||||||
|
dates within this calculator are represented at 00:00 UTC.
|
||||||
|
</p>
|
||||||
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<tr>
|
||||||
<tr>
|
<th>Timeline</th>
|
||||||
<th>Date</th>
|
<th>Date</th>
|
||||||
<th>Payment available date</th>
|
</tr>
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<td>Revenue earned on</td>
|
||||||
<tbody>
|
<td>
|
||||||
<tr>
|
<input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
|
||||||
<td>January 1st</td>
|
<noscript
|
||||||
<td>March 31st</td>
|
>(JavaScript must be enabled for the date picker to function, example date: 2024-07-15)
|
||||||
</tr>
|
</noscript>
|
||||||
<tr>
|
</td>
|
||||||
<td>January 15th</td>
|
</tr>
|
||||||
<td>March 31st</td>
|
<tr>
|
||||||
</tr>
|
<td>End of the month</td>
|
||||||
<tr>
|
<td>{{ formatDate(endOfMonthDate) }}</td>
|
||||||
<td>March 3rd</td>
|
</tr>
|
||||||
<td>May 30th</td>
|
<tr>
|
||||||
</tr>
|
<td>NET 60 policy applied</td>
|
||||||
<tr>
|
<td>+ 60 days</td>
|
||||||
<td>June 30th</td>
|
</tr>
|
||||||
<td>August 29th</td>
|
<tr class="final-result">
|
||||||
</tr>
|
<td>Available for withdrawal</td>
|
||||||
<tr>
|
<td>{{ formatDate(withdrawalDate) }}</td>
|
||||||
<td>July 14th</td>
|
</tr>
|
||||||
<td>September 29th</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td>October 12th</td>
|
|
||||||
<td>December 30th</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</table>
|
||||||
<h3>How do I know Modrinth is being transparent about revenue?</h3>
|
<h3>How do I know Modrinth is being transparent about revenue?</h3>
|
||||||
<p>
|
<p>
|
||||||
@@ -127,12 +126,40 @@
|
|||||||
revenue distribution system</a
|
revenue distribution system</a
|
||||||
>. We also have an
|
>. We also have an
|
||||||
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
|
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
|
||||||
to query exact daily revenue for the site.
|
to query exact daily revenue for the site - so far, Modrinth has generated
|
||||||
|
<strong>{{ formatMoney(platformRevenue) }}</strong> in revenue.
|
||||||
</p>
|
</p>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Revenue</th>
|
||||||
|
<th>Creator Revenue (75%)</th>
|
||||||
|
<th>Modrinth's Cut (25%)</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="item in platformRevenueData" :key="item.time">
|
||||||
|
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
|
||||||
|
<td>{{ formatMoney(item.revenue) }}</td>
|
||||||
|
<td>{{ formatMoney(item.creator_revenue) }}</td>
|
||||||
|
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<small
|
||||||
|
>Modrinth's total revenue in the previous 5 days, for the entire dataset, use the
|
||||||
|
aforementioned
|
||||||
|
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>.</small
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script lang="ts" setup>
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { computed, ref } from "vue";
|
||||||
|
import { formatDate, formatMoney } from "@modrinth/utils";
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
|
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
|
||||||
|
|
||||||
@@ -142,4 +169,18 @@ useSeoMeta({
|
|||||||
ogTitle: "Rewards Program Information",
|
ogTitle: "Rewards Program Information",
|
||||||
ogDescription: description,
|
ogDescription: description,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rawSelectedDate = ref(dayjs().format("YYYY-MM-DD"));
|
||||||
|
const selectedDate = computed(() => dayjs(rawSelectedDate.value));
|
||||||
|
const endOfMonthDate = computed(() => selectedDate.value.endOf("month"));
|
||||||
|
const withdrawalDate = computed(() => endOfMonthDate.value.add(60, "days"));
|
||||||
|
|
||||||
|
const { data: transparencyInformation } = await useAsyncData("payout/platform_revenue", () =>
|
||||||
|
useBaseFetch("payout/platform_revenue", {
|
||||||
|
apiVersion: 3,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const platformRevenue = transparencyInformation.value.all_time;
|
||||||
|
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@
|
|||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
<script setup>
|
<script setup>
|
||||||
import Chips from "~/components/ui/Chips.vue";
|
import { Chips } from "@modrinth/ui";
|
||||||
import Avatar from "~/components/ui/Avatar.vue";
|
import Avatar from "~/components/ui/Avatar.vue";
|
||||||
import UnknownIcon from "~/assets/images/utils/unknown.svg?component";
|
import UnknownIcon from "~/assets/images/utils/unknown.svg?component";
|
||||||
import EyeIcon from "~/assets/images/utils/eye.svg?component";
|
import EyeIcon from "~/assets/images/utils/eye.svg?component";
|
||||||
@@ -164,17 +164,45 @@ const projectTypes = computed(() => {
|
|||||||
return [...set];
|
return [...set];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function segmentData(data, segmentSize = 900) {
|
||||||
|
return data.reduce((acc, curr, index) => {
|
||||||
|
const segment = Math.floor(index / segmentSize);
|
||||||
|
|
||||||
|
if (!acc[segment]) {
|
||||||
|
acc[segment] = [];
|
||||||
|
}
|
||||||
|
acc[segment].push(curr);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchSegmented(data, createUrl, options = {}) {
|
||||||
|
return Promise.all(segmentData(data).map((ids) => useBaseFetch(createUrl(ids), options))).then(
|
||||||
|
(results) => results.flat(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asEncodedJsonArray(data) {
|
||||||
|
return encodeURIComponent(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
|
||||||
if (projects.value) {
|
if (projects.value) {
|
||||||
const teamIds = projects.value.map((x) => x.team_id);
|
const teamIds = projects.value.map((x) => x.team_id);
|
||||||
const organizationIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
|
const orgIds = projects.value.filter((x) => x.organization).map((x) => x.organization);
|
||||||
|
|
||||||
const url = `teams?ids=${encodeURIComponent(JSON.stringify(teamIds))}`;
|
const [{ data: teams }, { data: orgs }] = await Promise.all([
|
||||||
const orgUrl = `organizations?ids=${encodeURIComponent(JSON.stringify(organizationIds))}`;
|
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
|
||||||
const { data: result } = await useAsyncData(url, () => useBaseFetch(url));
|
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
|
||||||
const { data: orgs } = await useAsyncData(orgUrl, () => useBaseFetch(orgUrl, { apiVersion: 3 }));
|
),
|
||||||
|
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
|
||||||
|
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||||
|
apiVersion: 3,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
if (result.value) {
|
if (teams.value) {
|
||||||
members.value = result.value;
|
members.value = teams.value;
|
||||||
|
|
||||||
projects.value = projects.value.map((project) => {
|
projects.value = projects.value.map((project) => {
|
||||||
project.owner = members.value
|
project.owner = members.value
|
||||||
|
|||||||
16
apps/frontend/src/pages/news/changelog.vue
Normal file
16
apps/frontend/src/pages/news/changelog.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page experimental-styles-within">
|
||||||
|
<h1 class="m-0 text-3xl font-extrabold">Changelog</h1>
|
||||||
|
<p class="my-3">Keep up-to-date on what's new with Modrinth.</p>
|
||||||
|
<NuxtPage />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.page {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
max-width: 56rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
apps/frontend/src/pages/news/changelog/[product]/[date].vue
Normal file
51
apps/frontend/src/pages/news/changelog/[product]/[date].vue
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { getChangelog } from "@modrinth/utils";
|
||||||
|
import { ChangelogEntry, Timeline } from "@modrinth/ui";
|
||||||
|
import { ChevronLeftIcon } from "@modrinth/assets";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const changelogEntry = computed(() =>
|
||||||
|
route.params.date
|
||||||
|
? getChangelog().find((x) => {
|
||||||
|
if (x.product === route.params.product) {
|
||||||
|
console.log("Found matching product!");
|
||||||
|
|
||||||
|
if (x.version && x.version === route.params.date) {
|
||||||
|
console.log("Found matching version!");
|
||||||
|
return x;
|
||||||
|
} else if (x.date.unix() === Number(route.params.date as string)) {
|
||||||
|
console.log("Found matching date!");
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const isFirst = computed(() => changelogEntry.value?.date === getChangelog()[0].date);
|
||||||
|
|
||||||
|
if (!changelogEntry.value) {
|
||||||
|
createError({ statusCode: 404, statusMessage: "Version not found" });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="changelogEntry">
|
||||||
|
<nuxt-link
|
||||||
|
:to="`/news/changelog?filter=${changelogEntry.product}`"
|
||||||
|
class="mb-4 mt-4 flex w-fit items-center gap-2 text-link"
|
||||||
|
>
|
||||||
|
<ChevronLeftIcon /> View full changelog
|
||||||
|
</nuxt-link>
|
||||||
|
<Timeline fade-out-end :fade-out-start="!isFirst" :class="{ '-mt-8': !isFirst }">
|
||||||
|
<ChangelogEntry
|
||||||
|
:entry="changelogEntry"
|
||||||
|
:first="isFirst"
|
||||||
|
show-type
|
||||||
|
:class="{ 'mt-8': !isFirst }"
|
||||||
|
/>
|
||||||
|
</Timeline>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
65
apps/frontend/src/pages/news/changelog/index.vue
Normal file
65
apps/frontend/src/pages/news/changelog/index.vue
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type Product, getChangelog } from "@modrinth/utils";
|
||||||
|
import { ChangelogEntry } from "@modrinth/ui";
|
||||||
|
import Timeline from "@modrinth/ui/src/components/base/Timeline.vue";
|
||||||
|
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const filter = ref<Product | undefined>(undefined);
|
||||||
|
const allChangelogEntries = ref(getChangelog());
|
||||||
|
|
||||||
|
function updateFilter() {
|
||||||
|
if (route.query.filter) {
|
||||||
|
filter.value = route.query.filter as Product;
|
||||||
|
} else {
|
||||||
|
filter.value = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilter();
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.query,
|
||||||
|
() => updateFilter(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const changelogEntries = computed(() =>
|
||||||
|
allChangelogEntries.value.filter((x) => !filter.value || x.product === filter.value),
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NavTabs
|
||||||
|
:links="[
|
||||||
|
{
|
||||||
|
label: 'All',
|
||||||
|
href: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Website',
|
||||||
|
href: 'web',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Servers',
|
||||||
|
href: 'servers',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'App',
|
||||||
|
href: 'app',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
query="filter"
|
||||||
|
class="mb-4"
|
||||||
|
/>
|
||||||
|
<Timeline fade-out-end>
|
||||||
|
<ChangelogEntry
|
||||||
|
v-for="(entry, index) in changelogEntries"
|
||||||
|
:key="entry.date"
|
||||||
|
:entry="entry"
|
||||||
|
:first="index === 0"
|
||||||
|
:show-type="filter === undefined"
|
||||||
|
has-link
|
||||||
|
/>
|
||||||
|
</Timeline>
|
||||||
|
</template>
|
||||||
@@ -63,7 +63,20 @@
|
|||||||
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.formNotFor) }}</h2>
|
<h2 class="m-0 text-lg font-extrabold">{{ formatMessage(messages.formNotFor) }}</h2>
|
||||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||||
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
||||||
<span>{{ formatMessage(messages.bugReports) }}</span>
|
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span>{{ formatMessage(messages.bugReports) }}</span>
|
||||||
|
<span v-if="itemIssueTracker" class="text-sm font-medium text-secondary">
|
||||||
|
<IntlFormatted :message-id="messages.bugReportsDescription">
|
||||||
|
<template #issues-link="{ children }">
|
||||||
|
<a class="text-link" :href="itemIssueTracker" target="_blank">
|
||||||
|
<component :is="() => children" />
|
||||||
|
<ExternalIcon aria-hidden="true" class="mb-1 ml-1 h-2.5 w-2.5" />
|
||||||
|
</a>
|
||||||
|
</template>
|
||||||
|
</IntlFormatted>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||||
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
||||||
@@ -238,6 +251,7 @@ import {
|
|||||||
AutoLink,
|
AutoLink,
|
||||||
} from "@modrinth/ui";
|
} from "@modrinth/ui";
|
||||||
import {
|
import {
|
||||||
|
ExternalIcon,
|
||||||
LeftArrowIcon,
|
LeftArrowIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
@@ -289,6 +303,7 @@ const itemIcon = ref<string | Component | undefined>();
|
|||||||
const itemName = ref<string | undefined>();
|
const itemName = ref<string | undefined>();
|
||||||
const itemLink = ref<string | undefined>();
|
const itemLink = ref<string | undefined>();
|
||||||
const itemId = ref<string | undefined>();
|
const itemId = ref<string | undefined>();
|
||||||
|
const itemIssueTracker = ref<string | undefined>();
|
||||||
|
|
||||||
const reports = ref<Report[]>([]);
|
const reports = ref<Report[]>([]);
|
||||||
const existingReport = computed(() =>
|
const existingReport = computed(() =>
|
||||||
@@ -319,6 +334,7 @@ async function fetchItem() {
|
|||||||
itemName.value = undefined;
|
itemName.value = undefined;
|
||||||
itemLink.value = undefined;
|
itemLink.value = undefined;
|
||||||
itemId.value = undefined;
|
itemId.value = undefined;
|
||||||
|
itemIssueTracker.value = undefined;
|
||||||
try {
|
try {
|
||||||
if (reportItem.value === "project") {
|
if (reportItem.value === "project") {
|
||||||
const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project;
|
const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project;
|
||||||
@@ -328,6 +344,7 @@ async function fetchItem() {
|
|||||||
itemName.value = project.title;
|
itemName.value = project.title;
|
||||||
itemLink.value = `/project/${project.id}`;
|
itemLink.value = `/project/${project.id}`;
|
||||||
itemId.value = project.id;
|
itemId.value = project.id;
|
||||||
|
itemIssueTracker.value = project.issues_url;
|
||||||
} else if (reportItem.value === "version") {
|
} else if (reportItem.value === "version") {
|
||||||
const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version;
|
const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version;
|
||||||
currentVersion.value = version;
|
currentVersion.value = version;
|
||||||
@@ -540,6 +557,10 @@ const messages = defineMessages({
|
|||||||
id: "report.not-for.bug-reports",
|
id: "report.not-for.bug-reports",
|
||||||
defaultMessage: "Bug reports",
|
defaultMessage: "Bug reports",
|
||||||
},
|
},
|
||||||
|
bugReportsDescription: {
|
||||||
|
id: "report.not-for.bug-reports.description",
|
||||||
|
defaultMessage: "You can report bugs to their <issues-link>issue tracker</issues-link>.",
|
||||||
|
},
|
||||||
dmcaTakedown: {
|
dmcaTakedown: {
|
||||||
id: "report.not-for.dmca",
|
id: "report.not-for.dmca",
|
||||||
defaultMessage: "DMCA takedowns",
|
defaultMessage: "DMCA takedowns",
|
||||||
@@ -586,7 +607,7 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.page {
|
.page {
|
||||||
padding: 0.5rem;
|
padding: 1rem;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
max-width: 56rem;
|
max-width: 56rem;
|
||||||
|
|||||||
@@ -258,7 +258,8 @@
|
|||||||
<button
|
<button
|
||||||
v-if="
|
v-if="
|
||||||
result.installed ||
|
result.installed ||
|
||||||
server.content.data.find((x) => x.project_id === result.project_id) ||
|
(server?.content?.data &&
|
||||||
|
server.content.data.find((x) => x.project_id === result.project_id)) ||
|
||||||
server.general?.project?.id === result.project_id
|
server.general?.project?.id === result.project_id
|
||||||
"
|
"
|
||||||
disabled
|
disabled
|
||||||
@@ -376,7 +377,9 @@ async function updateServerContext() {
|
|||||||
if (!auth.value.user) {
|
if (!auth.value.user) {
|
||||||
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
|
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
|
||||||
} else if (route.query.sid !== null) {
|
} else if (route.query.sid !== null) {
|
||||||
server.value = await usePyroServer(route.query.sid, ["general", "content"]);
|
server.value = await usePyroServer(route.query.sid, ["general", "content"], {
|
||||||
|
waitForModules: true,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,8 +498,8 @@ async function serverInstall(project) {
|
|||||||
) ?? versions[0];
|
) ?? versions[0];
|
||||||
|
|
||||||
if (projectType.value.id === "modpack") {
|
if (projectType.value.id === "modpack") {
|
||||||
await server.value.general?.reinstall(
|
await server.value.general.reinstall(
|
||||||
route.query.sid,
|
server.value.serverId,
|
||||||
false,
|
false,
|
||||||
project.project_id,
|
project.project_id,
|
||||||
version.id,
|
version.id,
|
||||||
@@ -504,7 +507,7 @@ async function serverInstall(project) {
|
|||||||
eraseDataOnInstall.value,
|
eraseDataOnInstall.value,
|
||||||
);
|
);
|
||||||
project.installed = true;
|
project.installed = true;
|
||||||
navigateTo(`/servers/manage/${route.query.sid}/options/loader`);
|
navigateTo(`/servers/manage/${server.value.serverId}/options/loader`);
|
||||||
} else if (projectType.value.id === "mod") {
|
} else if (projectType.value.id === "mod") {
|
||||||
await server.value.content.install("mod", version.project_id, version.id);
|
await server.value.content.install("mod", version.project_id, version.id);
|
||||||
await server.value.refresh(["content"]);
|
await server.value.refresh(["content"]);
|
||||||
|
|||||||
@@ -456,9 +456,10 @@
|
|||||||
Where are Modrinth Servers located? Can I choose a region?
|
Where are Modrinth Servers located? Can I choose a region?
|
||||||
</summary>
|
</summary>
|
||||||
<p class="m-0 !leading-[190%]">
|
<p class="m-0 !leading-[190%]">
|
||||||
Currently, Modrinth Servers are located in New York, Los Angeles, Seattle, and
|
Currently, Modrinth Servers are located throughout the United States in New York,
|
||||||
Miami. More regions are coming soon! Your server's location is currently chosen
|
Los Angelas, Dallas, Miami, and Spokane. More regions are coming soon! Your server's
|
||||||
algorithmically, but you will be able to choose a region in the future.
|
location is currently chosen algorithmically, but you will be able to choose a
|
||||||
|
region in the future.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -494,6 +495,97 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
|
||||||
|
>
|
||||||
|
<div class="faded-brand-line absolute left-0 top-0 h-[1px] w-full"></div>
|
||||||
|
<div class="mx-auto flex w-full max-w-7xl flex-col gap-8">
|
||||||
|
<div class="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
|
||||||
|
>
|
||||||
|
Server Locations
|
||||||
|
</div>
|
||||||
|
<h1 class="relative m-0 max-w-2xl text-4xl leading-[120%] md:text-7xl">
|
||||||
|
Coast-to-Coast Coverage
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-8">
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="grid size-8 place-content-center rounded-full bg-highlight-green">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="text-brand"
|
||||||
|
>
|
||||||
|
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
|
||||||
|
<circle cx="12" cy="10" r="3" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
|
||||||
|
US Coverage
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<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, 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>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="grid size-8 place-content-center rounded-full bg-highlight-blue">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="16"
|
||||||
|
height="16"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
class="text-blue"
|
||||||
|
>
|
||||||
|
<path d="M12 2a10 10 0 1 0 10 10" />
|
||||||
|
<path d="M18 13a6 6 0 0 0-6-6" />
|
||||||
|
<path d="M13 2.05a10 10 0 0 1 2 2" />
|
||||||
|
<path d="M19.5 8.5a10 10 0 0 1 2 2" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h2 class="relative m-0 text-xl font-medium leading-[155%] md:text-2xl">
|
||||||
|
Global Expansion
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="relative m-0 max-w-xl text-base font-normal leading-[155%] text-secondary md:text-[18px]"
|
||||||
|
>
|
||||||
|
We're expanding to Europe and Asia-Pacific regions soon, bringing Modrinth's
|
||||||
|
seamless hosting experience worldwide. Join our Discord to stay updated on new
|
||||||
|
region launches.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Globe />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section
|
<section
|
||||||
id="plan"
|
id="plan"
|
||||||
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
|
class="relative mt-24 flex flex-col bg-[radial-gradient(65%_50%_at_50%_-10%,var(--color-brand-highlight)_0%,var(--color-accent-contrast)_100%)] px-3 pt-24 md:mt-48 md:pt-48"
|
||||||
@@ -511,147 +603,180 @@
|
|||||||
? "We are currently at capacity. Please try again later."
|
? "We are currently at capacity. Please try again later."
|
||||||
: "There's a plan for everyone! Choose the one that fits your needs."
|
: "There's a plan for everyone! Choose the one that fits your needs."
|
||||||
}}
|
}}
|
||||||
<span class="font-bold">
|
|
||||||
Servers are currently US only, in New York, Los Angeles, Seattle, and Miami. More
|
|
||||||
regions coming soon!
|
|
||||||
</span>
|
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul class="m-0 flex w-full flex-col gap-8 p-0 lg:flex-row">
|
<ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
|
||||||
<li class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3">
|
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||||
<div class="flex flex-row items-center justify-between">
|
<div
|
||||||
<h1 class="m-0">Small</h1>
|
v-if="isSmallLowStock"
|
||||||
<div
|
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||||
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue"
|
>
|
||||||
>
|
Only {{ capacityStatuses?.small?.available }} left in stock!
|
||||||
S
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||||
|
:class="{ '!rounded-t-none': isSmallLowStock }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-row items-center justify-between">
|
||||||
|
<h1 class="m-0">Small</h1>
|
||||||
|
<div
|
||||||
|
class="grid size-8 place-content-center rounded-full bg-highlight-blue text-xs font-bold text-blue"
|
||||||
|
>
|
||||||
|
S
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="m-0">
|
||||||
|
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||||
|
<p class="m-0">4 GB RAM</p>
|
||||||
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
|
<p class="m-0">4 vCPUs</p>
|
||||||
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
|
<p class="m-0">32 GB Storage</p>
|
||||||
|
</div>
|
||||||
|
<h2 class="m-0 text-3xl text-contrast">
|
||||||
|
$12<span class="text-sm font-normal text-secondary">/month</span>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<ButtonStyled color="blue" size="large">
|
||||||
|
<a
|
||||||
|
v-if="!loggedOut && isSmallAtCapacity"
|
||||||
|
: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" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="!bg-highlight-blue !font-medium !text-blue"
|
||||||
|
@click="selectProduct('small')"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<p class="m-0">
|
|
||||||
Perfect for vanilla multiplayer, small friend groups, SMPs, and light modding.
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
|
||||||
<p class="m-0">4 GB RAM</p>
|
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
|
||||||
<p class="m-0">4 vCPUs</p>
|
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
|
||||||
<p class="m-0">32 GB Storage</p>
|
|
||||||
</div>
|
|
||||||
<h2 class="m-0 text-3xl text-contrast">
|
|
||||||
$12<span class="text-sm font-normal text-secondary">/month</span>
|
|
||||||
</h2>
|
|
||||||
<ButtonStyled color="blue" size="large">
|
|
||||||
<NuxtLink
|
|
||||||
v-if="!loggedOut && isSmallAtCapacity"
|
|
||||||
:to="outOfStockUrl"
|
|
||||||
target="_blank"
|
|
||||||
class="!bg-highlight-blue !font-medium !text-blue"
|
|
||||||
>
|
|
||||||
Out of Stock
|
|
||||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-blue" />
|
|
||||||
</NuxtLink>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="!bg-highlight-blue !font-medium !text-blue"
|
|
||||||
@click="selectProduct('small')"
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-blue" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li
|
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||||
style="
|
<div
|
||||||
background: radial-gradient(
|
v-if="isMediumLowStock"
|
||||||
86.12% 101.64% at 95.97% 94.07%,
|
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||||
rgba(27, 217, 106, 0.23) 0%,
|
>
|
||||||
rgba(14, 115, 56, 0.2) 100%
|
Only {{ capacityStatuses?.medium?.available }} left in stock!
|
||||||
);
|
</div>
|
||||||
border: 1px solid rgba(12, 107, 52, 0.55);
|
<div
|
||||||
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
|
style="
|
||||||
"
|
background: radial-gradient(
|
||||||
class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3"
|
86.12% 101.64% at 95.97% 94.07%,
|
||||||
>
|
rgba(27, 217, 106, 0.23) 0%,
|
||||||
<div class="flex flex-row items-center justify-between">
|
rgba(14, 115, 56, 0.2) 100%
|
||||||
<h1 class="m-0">Medium</h1>
|
);
|
||||||
<div
|
border: 1px solid rgba(12, 107, 52, 0.55);
|
||||||
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand"
|
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
|
||||||
>
|
"
|
||||||
M
|
class="flex w-full flex-col justify-between gap-4 rounded-2xl p-8 text-left"
|
||||||
|
:class="{ '!rounded-t-none': isMediumLowStock }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-row items-center justify-between">
|
||||||
|
<h1 class="m-0">Medium</h1>
|
||||||
|
<div
|
||||||
|
class="grid size-8 place-content-center rounded-full bg-highlight-green text-xs font-bold text-brand"
|
||||||
|
>
|
||||||
|
M
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="m-0">Great for modded multiplayer and small communities.</p>
|
||||||
|
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||||
|
<p class="m-0">6 GB RAM</p>
|
||||||
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
|
<p class="m-0">6 vCPUs</p>
|
||||||
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
|
<p class="m-0">48 GB Storage</p>
|
||||||
|
</div>
|
||||||
|
<h2 class="m-0 text-3xl text-contrast">
|
||||||
|
$18<span class="text-sm font-normal text-secondary">/month</span>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<ButtonStyled color="brand" size="large">
|
||||||
|
<a
|
||||||
|
v-if="!loggedOut && isMediumAtCapacity"
|
||||||
|
: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" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="!bg-highlight-green !font-medium !text-green"
|
||||||
|
@click="selectProduct('medium')"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
<RightArrowIcon class="!min-h-4 !min-w-4 !text-green" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<p class="m-0">Great for modded multiplayer and small communities.</p>
|
|
||||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
|
||||||
<p class="m-0">6 GB RAM</p>
|
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
|
||||||
<p class="m-0">6 vCPUs</p>
|
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
|
||||||
<p class="m-0">48 GB Storage</p>
|
|
||||||
</div>
|
|
||||||
<h2 class="m-0 text-3xl text-contrast">
|
|
||||||
$18<span class="text-sm font-normal text-secondary">/month</span>
|
|
||||||
</h2>
|
|
||||||
<ButtonStyled color="brand" size="large">
|
|
||||||
<NuxtLink
|
|
||||||
v-if="!loggedOut && isMediumAtCapacity"
|
|
||||||
:to="outOfStockUrl"
|
|
||||||
target="_blank"
|
|
||||||
class="!bg-highlight-green !font-medium !text-green"
|
|
||||||
>
|
|
||||||
Out of Stock
|
|
||||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-green" />
|
|
||||||
</NuxtLink>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="!bg-highlight-green !font-medium !text-green"
|
|
||||||
@click="selectProduct('medium')"
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-green" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
<li class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3">
|
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||||
<div class="flex flex-row items-center justify-between">
|
<div
|
||||||
<h1 class="m-0">Large</h1>
|
v-if="isLargeLowStock"
|
||||||
<div
|
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||||
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple"
|
>
|
||||||
>
|
Only {{ capacityStatuses?.large?.available }} left in stock!
|
||||||
L
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex w-full flex-col justify-between gap-4 rounded-2xl bg-bg p-8 text-left"
|
||||||
|
:class="{ '!rounded-t-none': isLargeLowStock }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-row items-center justify-between">
|
||||||
|
<h1 class="m-0">Large</h1>
|
||||||
|
<div
|
||||||
|
class="grid size-8 place-content-center rounded-full bg-highlight-purple text-xs font-bold text-purple"
|
||||||
|
>
|
||||||
|
L
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
|
||||||
|
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
||||||
|
<p class="m-0">8 GB RAM</p>
|
||||||
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
|
<p class="m-0">8 vCPUs</p>
|
||||||
|
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
||||||
|
<p class="m-0">64 GB Storage</p>
|
||||||
|
</div>
|
||||||
|
<h2 class="m-0 text-3xl text-contrast">
|
||||||
|
$24<span class="text-sm font-normal text-secondary">/month</span>
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<ButtonStyled color="brand" size="large">
|
||||||
|
<a
|
||||||
|
v-if="!loggedOut && isLargeAtCapacity"
|
||||||
|
: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" />
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="!bg-highlight-purple !font-medium !text-purple"
|
||||||
|
@click="selectProduct('large')"
|
||||||
|
>
|
||||||
|
Get Started
|
||||||
|
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<p class="m-0">Ideal for larger communities, modpacks, and heavy modding.</p>
|
|
||||||
<div class="flex flex-row flex-wrap items-center gap-3 text-nowrap">
|
|
||||||
<p class="m-0">8 GB RAM</p>
|
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
|
||||||
<p class="m-0">8 vCPUs</p>
|
|
||||||
<div class="size-1.5 rounded-full bg-secondary opacity-25"></div>
|
|
||||||
<p class="m-0">64 GB Storage</p>
|
|
||||||
</div>
|
|
||||||
<h2 class="m-0 text-3xl text-contrast">
|
|
||||||
$24<span class="text-sm font-normal text-secondary">/month</span>
|
|
||||||
</h2>
|
|
||||||
<ButtonStyled color="brand" size="large">
|
|
||||||
<NuxtLink
|
|
||||||
v-if="!loggedOut && isLargeAtCapacity"
|
|
||||||
:to="outOfStockUrl"
|
|
||||||
target="_blank"
|
|
||||||
class="!bg-highlight-purple !font-medium !text-purple"
|
|
||||||
>
|
|
||||||
Out of Stock
|
|
||||||
<ExternalIcon class="!min-h-4 !min-w-4 !text-purple" />
|
|
||||||
</NuxtLink>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="!bg-highlight-purple !font-medium !text-purple"
|
|
||||||
@click="selectProduct('large')"
|
|
||||||
>
|
|
||||||
Get Started
|
|
||||||
<RightArrowIcon class="!min-h-4 !min-w-4 !text-purple" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
@@ -697,6 +822,7 @@ import {
|
|||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { products } from "~/generated/state.json";
|
import { products } from "~/generated/state.json";
|
||||||
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
|
import LoaderIcon from "~/components/ui/servers/icons/LoaderIcon.vue";
|
||||||
|
import Globe from "~/components/ui/servers/Globe.vue";
|
||||||
|
|
||||||
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
|
const pyroProducts = products.filter((p) => p.metadata.type === "pyro");
|
||||||
const pyroPlanProducts = pyroProducts.filter(
|
const pyroPlanProducts = pyroProducts.filter(
|
||||||
@@ -746,7 +872,7 @@ const deletingSpeed = 25;
|
|||||||
const pauseTime = 2000;
|
const pauseTime = 2000;
|
||||||
|
|
||||||
const loggedOut = computed(() => !auth.value.user);
|
const loggedOut = computed(() => !auth.value.user);
|
||||||
const outOfStockUrl = "https://support.modrinth.com";
|
const outOfStockUrl = "https://discord.modrinth.com";
|
||||||
|
|
||||||
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
|
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
|
||||||
try {
|
try {
|
||||||
@@ -760,9 +886,16 @@ const { data: hasServers } = await useAsyncData("ServerListCountCheck", async ()
|
|||||||
|
|
||||||
async function fetchCapacityStatuses(customProduct = null) {
|
async function fetchCapacityStatuses(customProduct = null) {
|
||||||
try {
|
try {
|
||||||
const productsToCheck = customProduct?.metadata ? [customProduct] : pyroPlanProducts;
|
const productsToCheck = customProduct?.metadata
|
||||||
|
? [customProduct]
|
||||||
|
: [
|
||||||
|
...pyroPlanProducts,
|
||||||
|
pyroProducts.reduce((min, product) =>
|
||||||
|
product.metadata.ram < min.metadata.ram ? product : min,
|
||||||
|
),
|
||||||
|
];
|
||||||
const capacityChecks = productsToCheck.map((product) =>
|
const capacityChecks = productsToCheck.map((product) =>
|
||||||
usePyroFetch("capacity", {
|
usePyroFetch("stock", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: {
|
body: {
|
||||||
cpu: product.metadata.cpu,
|
cpu: product.metadata.cpu,
|
||||||
@@ -774,6 +907,7 @@ async function fetchCapacityStatuses(customProduct = null) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const results = await Promise.all(capacityChecks);
|
const results = await Promise.all(capacityChecks);
|
||||||
|
|
||||||
if (customProduct?.metadata) {
|
if (customProduct?.metadata) {
|
||||||
return {
|
return {
|
||||||
custom: results[0],
|
custom: results[0],
|
||||||
@@ -783,6 +917,7 @@ async function fetchCapacityStatuses(customProduct = null) {
|
|||||||
small: results[0],
|
small: results[0],
|
||||||
medium: results[1],
|
medium: results[1],
|
||||||
large: results[2],
|
large: results[2],
|
||||||
|
custom: results[3],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -804,6 +939,22 @@ const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData(
|
|||||||
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);
|
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);
|
||||||
const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.available === 0);
|
const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.available === 0);
|
||||||
const isLargeAtCapacity = computed(() => capacityStatuses.value?.large?.available === 0);
|
const isLargeAtCapacity = computed(() => capacityStatuses.value?.large?.available === 0);
|
||||||
|
const isCustomAtCapacity = computed(() => capacityStatuses.value?.custom?.available === 0);
|
||||||
|
|
||||||
|
const isSmallLowStock = computed(() => {
|
||||||
|
const available = capacityStatuses.value?.small?.available;
|
||||||
|
return available !== undefined && available > 0 && available < 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isMediumLowStock = computed(() => {
|
||||||
|
const available = capacityStatuses.value?.medium?.available;
|
||||||
|
return available !== undefined && available > 0 && available < 8;
|
||||||
|
});
|
||||||
|
|
||||||
|
const isLargeLowStock = computed(() => {
|
||||||
|
const available = capacityStatuses.value?.large?.available;
|
||||||
|
return available !== undefined && available > 0 && available < 8;
|
||||||
|
});
|
||||||
|
|
||||||
const startTyping = () => {
|
const startTyping = () => {
|
||||||
const currentWord = words[currentWordIndex.value];
|
const currentWord = words[currentWordIndex.value];
|
||||||
@@ -907,7 +1058,9 @@ const selectProduct = async (product) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await refreshCapacity();
|
await refreshCapacity();
|
||||||
if (isAtCapacity.value) {
|
console.log(capacityStatuses.value);
|
||||||
|
|
||||||
|
if ((product === "custom" && isCustomAtCapacity.value) || isAtCapacity.value) {
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "main",
|
group: "main",
|
||||||
title: "Server Capacity Full",
|
title: "Server Capacity Full",
|
||||||
|
|||||||
@@ -10,10 +10,10 @@
|
|||||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||||
<TransferIcon class="size-12 text-blue" />
|
<TransferIcon class="size-12 text-blue" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Upgrading</h1>
|
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg text-secondary">
|
<p class="text-lg text-secondary">
|
||||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
Your server's hardware is currently being upgraded and will be back online shortly!
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,17 +47,18 @@
|
|||||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||||
<LockIcon class="size-12 text-orange" />
|
<LockIcon class="size-12 text-orange" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Suspended</h1>
|
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-lg text-secondary">
|
<p class="text-lg text-secondary">
|
||||||
{{
|
{{
|
||||||
serverData.suspension_reason
|
serverData.suspension_reason === "cancelled"
|
||||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
? "Your subscription has been cancelled."
|
||||||
: "Your server has been suspended."
|
: serverData.suspension_reason
|
||||||
|
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||||
|
: "Your server has been suspended."
|
||||||
}}
|
}}
|
||||||
<br />
|
<br />
|
||||||
This is most likely due to a billing issue. Please check your billing information and
|
Contact Modrinth support if you believe this is an error.
|
||||||
contact Modrinth support if you believe this is an error.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
||||||
@@ -66,7 +67,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="server.error && server.error.message.includes('Forbidden')"
|
v-else-if="
|
||||||
|
server.general?.error?.error.statusCode === 403 ||
|
||||||
|
server.general?.error?.error.statusCode === 404
|
||||||
|
"
|
||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||||
@@ -82,21 +86,22 @@
|
|||||||
this is an error, please contact Modrinth support.
|
this is an error, please contact Modrinth support.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
|
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||||
|
|
||||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
|
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
|
||||||
<button class="mt-6 !w-full">Go back to all servers</button>
|
<button class="mt-6 !w-full">Go back to all servers</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="server.error && server.error.message.includes('Service Unavailable')"
|
v-else-if="server.general?.error?.error.statusCode === 503"
|
||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||||
<div class="flex flex-col items-center text-center">
|
<div class="flex flex-col items-center text-center">
|
||||||
<div class="flex flex-col items-center gap-4">
|
<div class="flex flex-col items-center gap-4">
|
||||||
<div class="grid place-content-center rounded-full bg-bg-red p-4">
|
<div class="grid place-content-center rounded-full bg-bg-red p-4">
|
||||||
<PanelErrorIcon class="size-12 text-red" />
|
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
|
||||||
</div>
|
</div>
|
||||||
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||||
</div>
|
</div>
|
||||||
@@ -141,7 +146,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="server.error"
|
v-else-if="server.general?.error"
|
||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||||
@@ -164,7 +169,7 @@
|
|||||||
temporary network issue. You'll be reconnected automatically.
|
temporary network issue. You'll be reconnected automatically.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
|
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
:disabled="formattedTime !== '00'"
|
:disabled="formattedTime !== '00'"
|
||||||
size="large"
|
size="large"
|
||||||
@@ -228,7 +233,7 @@
|
|||||||
:show-loader-label="showLoaderLabel"
|
:show-loader-label="showLoaderLabel"
|
||||||
:uptime-seconds="uptimeSeconds"
|
:uptime-seconds="uptimeSeconds"
|
||||||
:linked="true"
|
:linked="true"
|
||||||
class="flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +348,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="isReconnecting"
|
v-if="isReconnecting"
|
||||||
data-pyro-server-ws-reconnecting
|
data-pyro-server-ws-reconnecting
|
||||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
|
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
|
||||||
>
|
>
|
||||||
<UiServersPanelSpinner />
|
<UiServersPanelSpinner />
|
||||||
Hang on, we're reconnecting to your server.
|
Hang on, we're reconnecting to your server.
|
||||||
@@ -352,12 +357,17 @@
|
|||||||
<div
|
<div
|
||||||
v-if="serverData.status === 'installing'"
|
v-if="serverData.status === 'installing'"
|
||||||
data-pyro-server-installing
|
data-pyro-server-installing
|
||||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-contrast"
|
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-blue p-4 text-sm text-contrast"
|
||||||
>
|
>
|
||||||
<UiServersPanelSpinner />
|
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
|
||||||
We're preparing your server, this may take a few minutes.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-lg font-bold"> We're preparing your server! </span>
|
||||||
|
<div class="flex flex-row items-center gap-2">
|
||||||
|
<UiServersPanelSpinner class="!h-3 !w-3" /> <LazyUiServersInstallingTicker />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<NuxtPage
|
<NuxtPage
|
||||||
:route="route"
|
:route="route"
|
||||||
:is-connected="isConnected"
|
:is-connected="isConnected"
|
||||||
@@ -392,10 +402,9 @@ import {
|
|||||||
import DOMPurify from "dompurify";
|
import DOMPurify from "dompurify";
|
||||||
import { ButtonStyled } from "@modrinth/ui";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
import { Intercom, shutdown } from "@intercom/messenger-js-sdk";
|
||||||
import { reloadNuxtApp } from "#app";
|
import { reloadNuxtApp, navigateTo } from "#app";
|
||||||
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
import type { ServerState, Stats, WSEvent, WSInstallationResultEvent } from "~/types/servers";
|
||||||
import { usePyroConsole } from "~/store/console.ts";
|
import { usePyroConsole } from "~/store/console.ts";
|
||||||
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
|
|
||||||
|
|
||||||
const socket = ref<WebSocket | null>(null);
|
const socket = ref<WebSocket | null>(null);
|
||||||
const isReconnecting = ref(false);
|
const isReconnecting = ref(false);
|
||||||
@@ -420,21 +429,25 @@ const createdAt = ref(
|
|||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const serverId = route.params.id as string;
|
const serverId = route.params.id as string;
|
||||||
const server = await usePyroServer(serverId, [
|
|
||||||
"general",
|
const server = await usePyroServer(serverId, ["general", "ws"]);
|
||||||
"content",
|
|
||||||
"backups",
|
const loadModulesPromise = Promise.resolve().then(() => {
|
||||||
"network",
|
if (server.general?.status === "suspended") {
|
||||||
"startup",
|
return;
|
||||||
"ws",
|
}
|
||||||
"fs",
|
return server.loadModules(["content", "backups", "network", "startup", "fs"]);
|
||||||
]);
|
});
|
||||||
|
|
||||||
|
provide("modulesLoaded", loadModulesPromise);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => server.error,
|
() => [server.general?.error, server.ws?.error],
|
||||||
(newError) => {
|
([generalError, wsError]) => {
|
||||||
if (server.general?.status === "suspended") return;
|
if (server.general?.status === "suspended") return;
|
||||||
if (newError && !newError.message.includes("Forbidden")) {
|
|
||||||
|
const error = generalError?.error || wsError?.error;
|
||||||
|
if (error && error.statusCode !== 403) {
|
||||||
startPolling();
|
startPolling();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -445,11 +458,9 @@ const errorMessage = ref("An unexpected error occurred.");
|
|||||||
const errorLog = ref("");
|
const errorLog = ref("");
|
||||||
const errorLogFile = ref("");
|
const errorLogFile = ref("");
|
||||||
const serverData = computed(() => server.general);
|
const serverData = computed(() => server.general);
|
||||||
const error = ref<Error | null>(null);
|
|
||||||
const isConnected = ref(false);
|
const isConnected = ref(false);
|
||||||
const isWSAuthIncorrect = ref(false);
|
const isWSAuthIncorrect = ref(false);
|
||||||
const pyroConsole = usePyroConsole();
|
const pyroConsole = usePyroConsole();
|
||||||
console.log("||||||||||||||||||||||| console", pyroConsole.output);
|
|
||||||
const cpuData = ref<number[]>([]);
|
const cpuData = ref<number[]>([]);
|
||||||
const ramData = ref<number[]>([]);
|
const ramData = ref<number[]>([]);
|
||||||
const isActioning = ref(false);
|
const isActioning = ref(false);
|
||||||
@@ -460,6 +471,7 @@ const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>();
|
|||||||
const uptimeSeconds = ref(0);
|
const uptimeSeconds = ref(0);
|
||||||
const firstConnect = ref(true);
|
const firstConnect = ref(true);
|
||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
|
const error = ref<Error | null>(null);
|
||||||
|
|
||||||
const initialConsoleMessage = [
|
const initialConsoleMessage = [
|
||||||
" __________________________________________________",
|
" __________________________________________________",
|
||||||
@@ -660,24 +672,71 @@ const newLoader = ref<string | null>(null);
|
|||||||
const newLoaderVersion = ref<string | null>(null);
|
const newLoaderVersion = ref<string | null>(null);
|
||||||
const newMCVersion = ref<string | null>(null);
|
const newMCVersion = ref<string | null>(null);
|
||||||
|
|
||||||
|
const onReinstall = (potentialArgs: any) => {
|
||||||
|
if (!serverData.value) return;
|
||||||
|
|
||||||
|
serverData.value.status = "installing";
|
||||||
|
|
||||||
|
if (potentialArgs?.loader) {
|
||||||
|
newLoader.value = potentialArgs.loader;
|
||||||
|
}
|
||||||
|
if (potentialArgs?.lVersion) {
|
||||||
|
newLoaderVersion.value = potentialArgs.lVersion;
|
||||||
|
}
|
||||||
|
if (potentialArgs?.mVersion) {
|
||||||
|
newMCVersion.value = potentialArgs.mVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
error.value = null;
|
||||||
|
errorTitle.value = "Error";
|
||||||
|
errorMessage.value = "An unexpected error occurred.";
|
||||||
|
};
|
||||||
|
|
||||||
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||||
switch (data.result) {
|
switch (data.result) {
|
||||||
case "ok":
|
case "ok": {
|
||||||
if (!serverData.value) break;
|
if (!serverData.value) break;
|
||||||
serverData.value.status = "available";
|
|
||||||
|
|
||||||
if (!isFirstMount.value) {
|
stopPolling();
|
||||||
await server.refresh();
|
|
||||||
}
|
try {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
if (server.general) {
|
|
||||||
if (newLoader.value) server.general.loader = newLoader.value;
|
let attempts = 0;
|
||||||
if (newLoaderVersion.value) server.general.loader_version = newLoaderVersion.value;
|
const maxAttempts = 3;
|
||||||
if (newMCVersion.value) server.general.mc_version = newMCVersion.value;
|
let hasValidData = false;
|
||||||
|
|
||||||
|
while (!hasValidData && attempts < maxAttempts) {
|
||||||
|
attempts++;
|
||||||
|
|
||||||
|
await server.refresh(["general"], {
|
||||||
|
preserveConnection: true,
|
||||||
|
preserveInstallState: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (serverData.value?.loader && serverData.value?.mc_version) {
|
||||||
|
hasValidData = true;
|
||||||
|
serverData.value.status = "available";
|
||||||
|
await server.refresh(["content", "startup"]);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasValidData) {
|
||||||
|
console.error("Failed to get valid server data after installation");
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
console.error("Error refreshing data after installation:", err);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
newLoader.value = null;
|
||||||
|
newLoaderVersion.value = null;
|
||||||
|
newMCVersion.value = null;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "err": {
|
case "err": {
|
||||||
console.log("failed to install");
|
console.log("failed to install");
|
||||||
console.log(data);
|
console.log(data);
|
||||||
@@ -706,43 +765,6 @@ const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onReinstall = (potentialArgs: any) => {
|
|
||||||
if (!serverData.value) return;
|
|
||||||
serverData.value.status = "installing";
|
|
||||||
// serverData.value.loader = potentialArgs.loader;
|
|
||||||
// serverData.value.loader_version = potentialArgs.lVersion;
|
|
||||||
// serverData.value.mc_version = potentialArgs.mVersion;
|
|
||||||
// if (potentialArgs?.loader) {
|
|
||||||
// console.log("setting loadeconsole
|
|
||||||
// serverData.value.loader = potentialArgs.loader;
|
|
||||||
// }
|
|
||||||
// if (potentialArgs?.lVersion) {
|
|
||||||
// serverData.value.loader_version = potentialArgs.lVersion;
|
|
||||||
// }
|
|
||||||
// if (potentialArgs?.mVersion) {
|
|
||||||
// serverData.value.mc_version = potentialArgs.mVersion;
|
|
||||||
// }
|
|
||||||
if (potentialArgs?.loader) {
|
|
||||||
newLoader.value = potentialArgs.loader;
|
|
||||||
}
|
|
||||||
if (potentialArgs?.lVersion) {
|
|
||||||
newLoaderVersion.value = potentialArgs.lVersion;
|
|
||||||
}
|
|
||||||
if (potentialArgs?.mVersion) {
|
|
||||||
newMCVersion.value = potentialArgs.mVersion;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isFirstMount.value) {
|
|
||||||
server.refresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
error.value = null;
|
|
||||||
errorTitle.value = "Error";
|
|
||||||
errorMessage.value = "An unexpected error occurred.";
|
|
||||||
|
|
||||||
console.log(serverData.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateStats = (currentStats: Stats["current"]) => {
|
const updateStats = (currentStats: Stats["current"]) => {
|
||||||
isConnected.value = true;
|
isConnected.value = true;
|
||||||
stats.value = {
|
stats.value = {
|
||||||
@@ -762,7 +784,6 @@ const updatePowerState = (
|
|||||||
state: ServerState,
|
state: ServerState,
|
||||||
details?: { oom_killed?: boolean; exit_code?: number },
|
details?: { oom_killed?: boolean; exit_code?: number },
|
||||||
) => {
|
) => {
|
||||||
console.log("Power state:", state, details);
|
|
||||||
serverPowerState.value = state;
|
serverPowerState.value = state;
|
||||||
|
|
||||||
if (state === "crashed") {
|
if (state === "crashed") {
|
||||||
@@ -910,6 +931,10 @@ const cleanup = () => {
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
isMounted.value = true;
|
isMounted.value = true;
|
||||||
|
if (server.general?.status === "suspended") {
|
||||||
|
isLoading.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (server.error) {
|
if (server.error) {
|
||||||
if (!server.error.message.includes("Forbidden")) {
|
if (!server.error.message.includes("Forbidden")) {
|
||||||
startPolling();
|
startPolling();
|
||||||
@@ -959,17 +984,15 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => serverData.value?.status,
|
() => serverData.value?.status,
|
||||||
(newStatus) => {
|
(newStatus, oldStatus) => {
|
||||||
if (isFirstMount.value) {
|
if (isFirstMount.value) {
|
||||||
isFirstMount.value = false;
|
isFirstMount.value = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (newStatus === "installing") {
|
if (newStatus === "installing" && oldStatus !== "installing") {
|
||||||
|
countdown.value = 15;
|
||||||
startPolling();
|
startPolling();
|
||||||
} else {
|
|
||||||
stopPolling();
|
|
||||||
server.refresh();
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -979,7 +1002,7 @@ definePageMeta({
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style>
|
||||||
@keyframes server-action-buttons-anim {
|
@keyframes server-action-buttons-anim {
|
||||||
0% {
|
0% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -996,7 +1019,16 @@ definePageMeta({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mobile-blurred-servericon::before {
|
.mobile-blurred-servericon::before {
|
||||||
@apply absolute left-0 top-0 block h-36 w-full bg-cover bg-center bg-no-repeat blur-2xl sm:hidden;
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
display: block;
|
||||||
|
height: 9rem;
|
||||||
|
width: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
filter: blur(1rem);
|
||||||
content: "";
|
content: "";
|
||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
@@ -1005,4 +1037,10 @@ definePageMeta({
|
|||||||
),
|
),
|
||||||
var(--server-bg-image);
|
var(--server-bg-image);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media screen and (min-width: 640px) {
|
||||||
|
.mobile-blurred-servericon::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contents">
|
<div class="contents">
|
||||||
<div v-if="data" class="contents">
|
<div
|
||||||
|
v-if="server.backups?.error"
|
||||||
|
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||||
|
<IssuesIcon class="size-12 text-orange" />
|
||||||
|
</div>
|
||||||
|
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg text-secondary">
|
||||||
|
We couldn't load your server's backups. Here's what went wrong:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
|
||||||
|
</p>
|
||||||
|
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
|
||||||
|
<button class="mt-6 !w-full">Retry</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="data" class="contents">
|
||||||
<LazyUiServersBackupCreateModal
|
<LazyUiServersBackupCreateModal
|
||||||
ref="createBackupModal"
|
ref="createBackupModal"
|
||||||
:server="server"
|
:server="server"
|
||||||
@@ -53,7 +77,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
|
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
|
||||||
<ButtonStyled type="standard">
|
<ButtonStyled type="standard">
|
||||||
<button @click="showbackupSettingsModal">
|
<button
|
||||||
|
:disabled="server.general?.status === 'installing'"
|
||||||
|
@click="showbackupSettingsModal"
|
||||||
|
>
|
||||||
<SettingsIcon class="h-5 w-5" />
|
<SettingsIcon class="h-5 w-5" />
|
||||||
Auto backups
|
Auto backups
|
||||||
</button>
|
</button>
|
||||||
@@ -63,13 +90,16 @@
|
|||||||
v-tooltip="
|
v-tooltip="
|
||||||
isServerRunning && !userPreferences.backupWhileRunning
|
isServerRunning && !userPreferences.backupWhileRunning
|
||||||
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
|
? 'Cannot create backup while server is running. You can disable this from your server Options > Preferences.'
|
||||||
: ''
|
: server.general?.status === 'installing'
|
||||||
|
? 'Cannot create backups while server is being installed'
|
||||||
|
: ''
|
||||||
"
|
"
|
||||||
class="w-full sm:w-fit"
|
class="w-full sm:w-fit"
|
||||||
:disabled="
|
:disabled="
|
||||||
(isServerRunning && !userPreferences.backupWhileRunning) ||
|
(isServerRunning && !userPreferences.backupWhileRunning) ||
|
||||||
data.used_backup_quota >= data.backup_quota ||
|
data.used_backup_quota >= data.backup_quota ||
|
||||||
backups.some((backup) => backup.ongoing)
|
backups.some((backup) => backup.ongoing) ||
|
||||||
|
server.general?.status === 'installing'
|
||||||
"
|
"
|
||||||
@click="showCreateModel"
|
@click="showCreateModel"
|
||||||
>
|
>
|
||||||
@@ -90,108 +120,111 @@
|
|||||||
automatically refresh when the backup is complete.
|
automatically refresh when the backup is complete.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<li
|
<div class="flex w-full flex-col gap-2">
|
||||||
v-for="(backup, index) in backups"
|
<li
|
||||||
:key="backup.id"
|
v-for="(backup, index) in backups"
|
||||||
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-4 shadow-md"
|
:key="backup.id"
|
||||||
>
|
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-2 shadow-md"
|
||||||
<div class="flex flex-col gap-4">
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex min-w-0 flex-row items-center gap-4">
|
<div class="flex items-center justify-between">
|
||||||
<div
|
<div class="flex min-w-0 flex-row items-center gap-4">
|
||||||
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
|
<div
|
||||||
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
|
class="grid size-14 shrink-0 place-content-center overflow-hidden rounded-xl border-[1px] border-solid border-button-border shadow-sm"
|
||||||
>
|
:class="backup.ongoing ? 'text-green [&&]:bg-bg-green' : 'bg-button-bg'"
|
||||||
<UiServersIconsLoadingIcon
|
>
|
||||||
v-if="backup.ongoing"
|
<UiServersIconsLoadingIcon
|
||||||
v-tooltip="'Backup in progress'"
|
v-if="backup.ongoing"
|
||||||
class="size-6 animate-spin"
|
v-tooltip="'Backup in progress'"
|
||||||
/>
|
class="size-6 animate-spin"
|
||||||
<LockIcon v-else-if="backup.locked" class="size-8" />
|
/>
|
||||||
<BoxIcon v-else class="size-8" />
|
<LockIcon v-else-if="backup.locked" class="size-8" />
|
||||||
</div>
|
<BoxIcon v-else class="size-8" />
|
||||||
<div class="flex min-w-0 flex-col gap-2">
|
</div>
|
||||||
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
<div class="flex min-w-0 flex-col gap-2">
|
||||||
<div class="max-w-full truncate text-xl font-bold text-contrast">
|
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
{{ backup.name }}
|
<div class="max-w-full truncate font-bold text-contrast">
|
||||||
</div>
|
{{ backup.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="index == 0"
|
v-if="index == 0"
|
||||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||||
>
|
>
|
||||||
<CheckIcon class="size-4" /> Latest
|
<CheckIcon class="size-4" /> Latest
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 text-xs">
|
||||||
|
<CalendarIcon class="size-4" />
|
||||||
|
{{
|
||||||
|
new Date(backup.created_at).toLocaleString("en-US", {
|
||||||
|
month: "numeric",
|
||||||
|
day: "numeric",
|
||||||
|
year: "2-digit",
|
||||||
|
hour: "numeric",
|
||||||
|
minute: "numeric",
|
||||||
|
hour12: true,
|
||||||
|
})
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<CalendarIcon class="size-4" />
|
|
||||||
{{
|
|
||||||
new Date(backup.created_at).toLocaleString("en-US", {
|
|
||||||
month: "numeric",
|
|
||||||
day: "numeric",
|
|
||||||
year: "2-digit",
|
|
||||||
hour: "numeric",
|
|
||||||
minute: "numeric",
|
|
||||||
hour12: true,
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
|
||||||
|
<UiServersTeleportOverflowMenu
|
||||||
|
direction="left"
|
||||||
|
position="bottom"
|
||||||
|
class="bg-transparent"
|
||||||
|
:disabled="backups.some((b) => b.ongoing)"
|
||||||
|
:options="[
|
||||||
|
{
|
||||||
|
id: 'rename',
|
||||||
|
action: () => {
|
||||||
|
renameBackupName = backup.name;
|
||||||
|
currentBackup = backup.id;
|
||||||
|
renameBackupModal?.show();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'restore',
|
||||||
|
action: () => {
|
||||||
|
currentBackup = backup.id;
|
||||||
|
restoreBackupModal?.show();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'download', action: () => initiateDownload(backup.id) },
|
||||||
|
{
|
||||||
|
id: 'lock',
|
||||||
|
action: () => {
|
||||||
|
if (backup.locked) {
|
||||||
|
unlockBackup(backup.id);
|
||||||
|
} else {
|
||||||
|
lockBackup(backup.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
action: () => {
|
||||||
|
currentBackup = backup.id;
|
||||||
|
deleteBackupModal?.show();
|
||||||
|
},
|
||||||
|
color: 'red',
|
||||||
|
},
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||||
|
<template #rename> <EditIcon /> Rename </template>
|
||||||
|
<template #restore> <ClipboardCopyIcon /> Restore </template>
|
||||||
|
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
|
||||||
|
<template v-else #lock> <LockIcon /> Lock </template>
|
||||||
|
<template #download> <DownloadIcon /> Download </template>
|
||||||
|
<template #delete> <TrashIcon /> Delete </template>
|
||||||
|
</UiServersTeleportOverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled v-if="!backup.ongoing" circular type="transparent">
|
|
||||||
<UiServersTeleportOverflowMenu
|
|
||||||
direction="left"
|
|
||||||
position="bottom"
|
|
||||||
class="bg-transparent"
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'rename',
|
|
||||||
action: () => {
|
|
||||||
renameBackupName = backup.name;
|
|
||||||
currentBackup = backup.id;
|
|
||||||
renameBackupModal?.show();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'restore',
|
|
||||||
action: () => {
|
|
||||||
currentBackup = backup.id;
|
|
||||||
restoreBackupModal?.show();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ id: 'download', action: () => initiateDownload(backup.id) },
|
|
||||||
{
|
|
||||||
id: 'lock',
|
|
||||||
action: () => {
|
|
||||||
if (backup.locked) {
|
|
||||||
unlockBackup(backup.id);
|
|
||||||
} else {
|
|
||||||
lockBackup(backup.id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'delete',
|
|
||||||
action: () => {
|
|
||||||
currentBackup = backup.id;
|
|
||||||
deleteBackupModal?.show();
|
|
||||||
},
|
|
||||||
color: 'red',
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
|
||||||
<template #rename> <EditIcon /> Rename </template>
|
|
||||||
<template #restore> <ClipboardCopyIcon /> Restore </template>
|
|
||||||
<template v-if="backup.locked" #lock> <LockOpenIcon /> Unlock </template>
|
|
||||||
<template v-else #lock> <LockIcon /> Lock </template>
|
|
||||||
<template #download> <DownloadIcon /> Download </template>
|
|
||||||
<template #delete> <TrashIcon /> Delete </template>
|
|
||||||
</UiServersTeleportOverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</li>
|
||||||
</li>
|
</div>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -232,6 +265,7 @@ import {
|
|||||||
BoxIcon,
|
BoxIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
LockOpenIcon,
|
LockOpenIcon,
|
||||||
|
IssuesIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ref, computed } from "vue";
|
import { ref, computed } from "vue";
|
||||||
import type { Server } from "~/composables/pyroServers";
|
import type { Server } from "~/composables/pyroServers";
|
||||||
@@ -288,33 +322,37 @@ const showbackupSettingsModal = () => {
|
|||||||
backupSettingsModal.value?.show();
|
backupSettingsModal.value?.show();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackupCreated = (payload: { success: boolean; message: string }) => {
|
const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
|
||||||
if (payload.success) {
|
if (payload.success) {
|
||||||
addNotification({ type: "success", text: payload.message });
|
addNotification({ type: "success", text: payload.message });
|
||||||
|
await props.server.refresh(["backups"]);
|
||||||
} else {
|
} else {
|
||||||
addNotification({ type: "error", text: payload.message });
|
addNotification({ type: "error", text: payload.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackupRenamed = (payload: { success: boolean; message: string }) => {
|
const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
|
||||||
if (payload.success) {
|
if (payload.success) {
|
||||||
addNotification({ type: "success", text: payload.message });
|
addNotification({ type: "success", text: payload.message });
|
||||||
|
await props.server.refresh(["backups"]);
|
||||||
} else {
|
} else {
|
||||||
addNotification({ type: "error", text: payload.message });
|
addNotification({ type: "error", text: payload.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackupRestored = (payload: { success: boolean; message: string }) => {
|
const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
|
||||||
if (payload.success) {
|
if (payload.success) {
|
||||||
addNotification({ type: "success", text: payload.message });
|
addNotification({ type: "success", text: payload.message });
|
||||||
|
await props.server.refresh(["backups"]);
|
||||||
} else {
|
} else {
|
||||||
addNotification({ type: "error", text: payload.message });
|
addNotification({ type: "error", text: payload.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackupDeleted = (payload: { success: boolean; message: string }) => {
|
const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
|
||||||
if (payload.success) {
|
if (payload.success) {
|
||||||
addNotification({ type: "success", text: payload.message });
|
addNotification({ type: "success", text: payload.message });
|
||||||
|
await props.server.refresh(["backups"]);
|
||||||
} else {
|
} else {
|
||||||
addNotification({ type: "error", text: payload.message });
|
addNotification({ type: "error", text: payload.message });
|
||||||
}
|
}
|
||||||
@@ -378,8 +416,8 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hasOngoingBackups) {
|
if (hasOngoingBackups) {
|
||||||
refreshInterval.value = setInterval(() => {
|
refreshInterval.value = setInterval(async () => {
|
||||||
props.server.refresh(["backups"]);
|
await props.server.refresh(["backups"]);
|
||||||
}, 10000);
|
}, 10000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,59 +1,39 @@
|
|||||||
<template>
|
<template>
|
||||||
<NewModal ref="modModal" header="Editing mod version">
|
<UiServersContentVersionEditModal
|
||||||
<div>
|
v-if="!invalidModal"
|
||||||
<div class="mb-4 flex flex-col gap-4">
|
ref="versionEditModal"
|
||||||
<div class="inline-flex flex-wrap items-center">
|
:type="type"
|
||||||
You're changing the version of
|
:mod-pack="Boolean(props.server.general?.upstream)"
|
||||||
<div class="inline-flex flex-wrap items-center gap-1 text-nowrap pl-2">
|
:game-version="props.server.general?.mc_version ?? ''"
|
||||||
<UiAvatar
|
:loader="props.server.general?.loader?.toLowerCase() ?? ''"
|
||||||
:src="currentMod?.icon_url"
|
:server-id="props.server.serverId"
|
||||||
size="24px"
|
@change-version="changeModVersion($event)"
|
||||||
class="inline-block"
|
/>
|
||||||
alt="Server Icon"
|
|
||||||
/>
|
<div
|
||||||
<strong>{{ currentMod?.name + "." }}</strong>
|
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>
|
</div>
|
||||||
|
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<p class="text-lg text-secondary">
|
||||||
<div v-if="props.server.general?.upstream" class="flex gap-2">
|
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
|
||||||
<InfoIcon class="hidden sm:block" />
|
<span class="break-all font-mono">{{ JSON.stringify(server.content.error) }}</span>
|
||||||
<span class="text-sm text-secondary">
|
</p>
|
||||||
Changing the mod version may cause unexpected issues. Because your server was created
|
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
|
||||||
from a modpack, it is recommended to use the modpack's version of the mod.
|
<button class="mt-6 !w-full">Retry</button>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<UiServersTeleportDropdownMenu
|
|
||||||
v-model="currentVersion"
|
|
||||||
name="Project"
|
|
||||||
:options="currentVersions"
|
|
||||||
placeholder="Select project..."
|
|
||||||
class="!w-full"
|
|
||||||
:display-name="
|
|
||||||
(version) => (typeof version === 'object' ? version?.version_number : version)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-4 flex flex-row items-center gap-4">
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button :disabled="currentMod.changing" @click="changeModVersion">
|
|
||||||
<PlusIcon />
|
|
||||||
Install
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button @click="modModal.value.hide()">
|
|
||||||
<XIcon />
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NewModal>
|
</div>
|
||||||
|
|
||||||
<div v-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
<div v-else-if="server.general && localMods" class="relative isolate flex h-full w-full flex-col">
|
||||||
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
<div ref="pyroContentSentinel" class="sentinel" data-pyro-content-sentinel />
|
||||||
<div class="relative flex h-full w-full flex-col">
|
<div class="relative flex h-full w-full flex-col">
|
||||||
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
|
<div class="sticky top-0 z-20 -mt-3 flex items-center justify-between bg-bg py-3">
|
||||||
@@ -123,7 +103,7 @@
|
|||||||
class="rounded-xl bg-bg-raised"
|
class="rounded-xl bg-bg-raised"
|
||||||
:margin-bottom="16"
|
:margin-bottom="16"
|
||||||
:file-type="type"
|
:file-type="type"
|
||||||
:current-path="'/mods'"
|
:current-path="`/${type.toLocaleLowerCase()}s`"
|
||||||
:fs="props.server.fs"
|
:fs="props.server.fs"
|
||||||
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
||||||
@upload-complete="() => props.server.refresh(['content'])"
|
@upload-complete="() => props.server.refresh(['content'])"
|
||||||
@@ -149,7 +129,7 @@
|
|||||||
:to="
|
:to="
|
||||||
mod.project_id
|
mod.project_id
|
||||||
? `/project/${mod.project_id}/version/${mod.version_id}`
|
? `/project/${mod.project_id}/version/${mod.version_id}`
|
||||||
: `files?path=mods`
|
: `files?path=${type.toLocaleLowerCase()}s`
|
||||||
"
|
"
|
||||||
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
|
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
@@ -162,9 +142,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="flex min-w-0 flex-col gap-1">
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
|
<span class="text-md flex min-w-0 items-center gap-2 font-bold">
|
||||||
<span class="truncate text-contrast">{{
|
<span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
|
||||||
mod.name || mod.filename.replace(".disabled", "")
|
|
||||||
}}</span>
|
|
||||||
<span
|
<span
|
||||||
v-if="mod.disabled"
|
v-if="mod.disabled"
|
||||||
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
|
class="hidden rounded-full bg-button-bg p-1 px-2 text-xs text-contrast sm:block"
|
||||||
@@ -174,19 +152,21 @@
|
|||||||
<div class="min-w-0 text-xs text-secondary">
|
<div class="min-w-0 text-xs text-secondary">
|
||||||
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
|
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
|
||||||
<span class="block font-semibold sm:hidden">
|
<span class="block font-semibold sm:hidden">
|
||||||
{{ mod.version_number || "External mod" }}
|
{{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
|
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
|
||||||
<div class="truncate font-semibold text-contrast">
|
<div class="truncate font-semibold text-contrast">
|
||||||
<span v-tooltip="'Mod version'">{{
|
<span v-tooltip="`${type} version`">{{
|
||||||
mod.version_number || "External mod"
|
mod.version_number || `External ${type.toLocaleLowerCase()}`
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="truncate">
|
<div class="truncate">
|
||||||
<span v-tooltip="'Mod file name'">{{ mod.filename }}</span>
|
<span v-tooltip="`${type} file name`">
|
||||||
|
{{ mod.filename }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -194,7 +174,7 @@
|
|||||||
>
|
>
|
||||||
<ButtonStyled color="red" type="transparent">
|
<ButtonStyled color="red" type="transparent">
|
||||||
<button
|
<button
|
||||||
v-tooltip="'Delete mod'"
|
v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
|
||||||
:disabled="mod.changing"
|
:disabled="mod.changing"
|
||||||
class="!hidden sm:!block"
|
class="!hidden sm:!block"
|
||||||
@click="removeMod(mod)"
|
@click="removeMod(mod)"
|
||||||
@@ -205,14 +185,16 @@
|
|||||||
<ButtonStyled type="transparent">
|
<ButtonStyled type="transparent">
|
||||||
<button
|
<button
|
||||||
v-tooltip="
|
v-tooltip="
|
||||||
mod.project_id ? 'Edit mod version' : 'External mods cannot be edited'
|
mod.project_id
|
||||||
|
? `Edit ${type.toLocaleLowerCase()} version`
|
||||||
|
: `External ${type.toLocaleLowerCase()}s cannot be edited`
|
||||||
"
|
"
|
||||||
:disabled="mod.changing || !mod.project_id"
|
:disabled="mod.changing || !mod.project_id"
|
||||||
class="!hidden sm:!block"
|
class="!hidden sm:!block"
|
||||||
@click="beginChangeModVersion(mod)"
|
@click="showVersionModal(mod)"
|
||||||
>
|
>
|
||||||
<template v-if="mod.changing">
|
<template v-if="mod.changing">
|
||||||
<UiServersIconsLoadingIcon />
|
<UiServersIconsLoadingIcon class="animate-spin" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<EditIcon />
|
<EditIcon />
|
||||||
@@ -232,7 +214,7 @@
|
|||||||
:options="[
|
:options="[
|
||||||
{
|
{
|
||||||
id: 'edit',
|
id: 'edit',
|
||||||
action: () => beginChangeModVersion(mod),
|
action: () => showVersionModal(mod),
|
||||||
shown: !!(mod.project_id && !mod.changing),
|
shown: !!(mod.project_id && !mod.changing),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -357,16 +339,15 @@ import {
|
|||||||
PackageClosedIcon,
|
PackageClosedIcon,
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
InfoIcon,
|
|
||||||
XIcon,
|
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
CompassIcon,
|
CompassIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
ListIcon,
|
ListIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
|
IssuesIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
import { ButtonStyled } from "@modrinth/ui";
|
||||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||||
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
||||||
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
|
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
|
||||||
@@ -401,6 +382,64 @@ const filterMethod = ref("all");
|
|||||||
|
|
||||||
const uploadDropdownRef = ref();
|
const uploadDropdownRef = ref();
|
||||||
|
|
||||||
|
const versionEditModal = ref();
|
||||||
|
const currentEditMod = ref<ContentItem | null>(null);
|
||||||
|
const invalidModal = computed(
|
||||||
|
() => !props.server.general?.mc_version || !props.server.general?.loader,
|
||||||
|
);
|
||||||
|
async function changeModVersion(event: string) {
|
||||||
|
const mod = currentEditMod.value;
|
||||||
|
|
||||||
|
if (mod) mod.changing = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
versionEditModal.value.hide();
|
||||||
|
|
||||||
|
// This will be used instead once backend implementation is done
|
||||||
|
// await props.server.content?.reinstall(
|
||||||
|
// `/${type.value.toLowerCase()}s/${event.fileName}`,
|
||||||
|
// currentMod.value.project_id,
|
||||||
|
// currentVersion.value.id,
|
||||||
|
// );
|
||||||
|
|
||||||
|
await props.server.content?.install(
|
||||||
|
type.value.toLowerCase() as "mod" | "plugin",
|
||||||
|
mod?.project_id || "",
|
||||||
|
event,
|
||||||
|
);
|
||||||
|
|
||||||
|
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod?.filename}`);
|
||||||
|
|
||||||
|
await props.server.refresh(["general", "content"]);
|
||||||
|
} catch (error) {
|
||||||
|
const errmsg = `Error changing mod version: ${error}`;
|
||||||
|
console.error(errmsg);
|
||||||
|
addNotification({
|
||||||
|
text: errmsg,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mod) mod.changing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showVersionModal(mod: ContentItem) {
|
||||||
|
if (invalidModal.value || !mod?.project_id || !mod?.filename) {
|
||||||
|
const errmsg = invalidModal.value
|
||||||
|
? "Data required for changing mod version was not found."
|
||||||
|
: `${!mod?.project_id ? "No mod project ID found" : "No mod filename found"} for ${friendlyModName(mod!)}`;
|
||||||
|
console.error(errmsg);
|
||||||
|
addNotification({
|
||||||
|
text: errmsg,
|
||||||
|
type: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEditMod.value = mod;
|
||||||
|
versionEditModal.value.show(mod);
|
||||||
|
}
|
||||||
|
|
||||||
const handleDroppedFiles = (files: File[]) => {
|
const handleDroppedFiles = (files: File[]) => {
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
uploadDropdownRef.value?.uploadFile(file);
|
uploadDropdownRef.value?.uploadFile(file);
|
||||||
@@ -529,17 +568,30 @@ const debouncedSearch = debounce(() => {
|
|||||||
}
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
|
|
||||||
|
function friendlyModName(mod: ContentItem) {
|
||||||
|
if (mod.name) return mod.name;
|
||||||
|
|
||||||
|
// remove .disabled if at the end of the filename
|
||||||
|
let cleanName = mod.filename.endsWith(".disabled") ? mod.filename.slice(0, -9) : mod.filename;
|
||||||
|
|
||||||
|
// remove everything after the last dot
|
||||||
|
const lastDotIndex = cleanName.lastIndexOf(".");
|
||||||
|
if (lastDotIndex !== -1) cleanName = cleanName.substring(0, lastDotIndex);
|
||||||
|
return cleanName;
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleMod(mod: ContentItem) {
|
async function toggleMod(mod: ContentItem) {
|
||||||
mod.changing = true;
|
mod.changing = true;
|
||||||
|
|
||||||
const originalFilename = mod.filename;
|
const originalFilename = mod.filename;
|
||||||
try {
|
try {
|
||||||
const newFilename = mod.filename.endsWith(".disabled")
|
const newFilename = mod.filename.endsWith(".disabled")
|
||||||
? mod.filename.replace(".disabled", "")
|
? mod.filename.slice(0, -9)
|
||||||
: `${mod.filename}.disabled`;
|
: `${mod.filename}.disabled`;
|
||||||
|
|
||||||
const sourcePath = `/mods/${mod.filename}`;
|
const folder = `${type.value.toLocaleLowerCase()}s`;
|
||||||
const destinationPath = `/mods/${newFilename}`;
|
const sourcePath = `/${folder}/${mod.filename}`;
|
||||||
|
const destinationPath = `/${folder}/${newFilename}`;
|
||||||
|
|
||||||
mod.disabled = newFilename.endsWith(".disabled");
|
mod.disabled = newFilename.endsWith(".disabled");
|
||||||
mod.filename = newFilename;
|
mod.filename = newFilename;
|
||||||
@@ -553,7 +605,7 @@ async function toggleMod(mod: ContentItem) {
|
|||||||
|
|
||||||
console.error("Error toggling mod:", error);
|
console.error("Error toggling mod:", error);
|
||||||
addNotification({
|
addNotification({
|
||||||
text: `Something went wrong toggling ${mod.name || mod.filename.replace(".disabled", "")}`,
|
text: `Something went wrong toggling ${friendlyModName(mod)}`,
|
||||||
type: "error",
|
type: "error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -565,10 +617,7 @@ async function removeMod(mod: ContentItem) {
|
|||||||
mod.changing = true;
|
mod.changing = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await props.server.content?.remove(
|
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`);
|
||||||
type.value as "Mod" | "Plugin",
|
|
||||||
`/${type.value.toLowerCase()}s/${mod.filename}`,
|
|
||||||
);
|
|
||||||
await props.server.refresh(["general", "content"]);
|
await props.server.refresh(["general", "content"]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error removing mod:", error);
|
console.error("Error removing mod:", error);
|
||||||
@@ -582,41 +631,6 @@ async function removeMod(mod: ContentItem) {
|
|||||||
mod.changing = false;
|
mod.changing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modModal = ref();
|
|
||||||
const currentMod = ref();
|
|
||||||
const currentVersions = ref();
|
|
||||||
const currentVersion = ref();
|
|
||||||
|
|
||||||
async function beginChangeModVersion(mod: Mod) {
|
|
||||||
currentMod.value = mod;
|
|
||||||
currentVersions.value = await useBaseFetch(`project/${mod.project_id}/version`, {}, false);
|
|
||||||
|
|
||||||
currentVersions.value = currentVersions.value.filter((version: any) =>
|
|
||||||
version.loaders.includes(props.server.general?.loader?.toLowerCase()),
|
|
||||||
);
|
|
||||||
|
|
||||||
currentVersion.value = currentVersions.value.find(
|
|
||||||
(version: any) => version.id === mod.version_id,
|
|
||||||
);
|
|
||||||
modModal.value.show();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function changeModVersion() {
|
|
||||||
currentMod.value.changing = true;
|
|
||||||
try {
|
|
||||||
modModal.value.hide();
|
|
||||||
await props.server.content?.reinstall(
|
|
||||||
type.value,
|
|
||||||
currentMod.value.version_id,
|
|
||||||
currentVersion.value.id,
|
|
||||||
);
|
|
||||||
await props.server.refresh(["general", "content"]);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error changing mod version:", error);
|
|
||||||
}
|
|
||||||
currentMod.value.changing = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasMods = computed(() => {
|
const hasMods = computed(() => {
|
||||||
return localMods.value?.length > 0;
|
return localMods.value?.length > 0;
|
||||||
});
|
});
|
||||||
@@ -646,9 +660,7 @@ const filteredMods = computed(() => {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
return statusFilteredMods.sort((a, b) => {
|
return statusFilteredMods.sort((a, b) => {
|
||||||
const aName = a.name || a.filename.replace(".disabled", "");
|
return friendlyModName(a).localeCompare(friendlyModName(b));
|
||||||
const bName = b.name || b.filename.replace(".disabled", "");
|
|
||||||
return aName.localeCompare(bName);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,13 +34,18 @@
|
|||||||
<UiServersFilesBrowseNavbar
|
<UiServersFilesBrowseNavbar
|
||||||
:breadcrumb-segments="breadcrumbSegments"
|
:breadcrumb-segments="breadcrumbSegments"
|
||||||
:search-query="searchQuery"
|
:search-query="searchQuery"
|
||||||
:sort-method="sortMethod"
|
:current-filter="viewFilter"
|
||||||
@navigate="navigateToSegment"
|
@navigate="navigateToSegment"
|
||||||
@sort="sortFiles"
|
|
||||||
@create="showCreateModal"
|
@create="showCreateModal"
|
||||||
@upload="initiateFileUpload"
|
@upload="initiateFileUpload"
|
||||||
|
@filter="handleFilter"
|
||||||
@update:search-query="searchQuery = $event"
|
@update:search-query="searchQuery = $event"
|
||||||
/>
|
/>
|
||||||
|
<UiServersFilesLabelBar
|
||||||
|
:sort-field="sortMethod"
|
||||||
|
:sort-desc="sortDesc"
|
||||||
|
@sort="handleSort"
|
||||||
|
/>
|
||||||
<FilesUploadDropdown
|
<FilesUploadDropdown
|
||||||
v-if="props.server.fs"
|
v-if="props.server.fs"
|
||||||
ref="uploadDropdownRef"
|
ref="uploadDropdownRef"
|
||||||
@@ -94,7 +99,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
||||||
<UiServersFilesLabelBar />
|
|
||||||
<UiServersFileVirtualList
|
<UiServersFileVirtualList
|
||||||
:items="filteredItems"
|
:items="filteredItems"
|
||||||
@delete="showDeleteModal"
|
@delete="showDeleteModal"
|
||||||
@@ -185,6 +189,8 @@ const props = defineProps<{
|
|||||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -196,7 +202,8 @@ const operationHistory = ref<Operation[]>([]);
|
|||||||
const redoStack = ref<Operation[]>([]);
|
const redoStack = ref<Operation[]>([]);
|
||||||
|
|
||||||
const searchQuery = ref("");
|
const searchQuery = ref("");
|
||||||
const sortMethod = ref("default");
|
const sortMethod = ref("name");
|
||||||
|
const sortDesc = ref(false);
|
||||||
|
|
||||||
const maxResults = 100;
|
const maxResults = 100;
|
||||||
const currentPage = ref(1);
|
const currentPage = ref(1);
|
||||||
@@ -227,11 +234,21 @@ const uploadDropdownRef = ref();
|
|||||||
|
|
||||||
const data = computed(() => props.server.general);
|
const data = computed(() => props.server.general);
|
||||||
|
|
||||||
|
const viewFilter = ref("all");
|
||||||
|
|
||||||
|
const handleFilter = (type: string) => {
|
||||||
|
viewFilter.value = type;
|
||||||
|
sortMethod.value = "name";
|
||||||
|
sortDesc.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
|
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
|
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
|
||||||
|
await modulesLoaded;
|
||||||
|
|
||||||
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
|
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
|
||||||
try {
|
try {
|
||||||
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
|
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
|
||||||
@@ -567,6 +584,51 @@ const applyDefaultSort = (items: DirectoryItem[]) => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
if (sortMethod.value === field) {
|
||||||
|
sortDesc.value = !sortDesc.value;
|
||||||
|
} else {
|
||||||
|
sortMethod.value = field;
|
||||||
|
sortDesc.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applySort = (items: DirectoryItem[]) => {
|
||||||
|
let result = [...items];
|
||||||
|
|
||||||
|
switch (viewFilter.value) {
|
||||||
|
case "filesOnly":
|
||||||
|
result = result.filter((item) => item.type !== "directory");
|
||||||
|
break;
|
||||||
|
case "foldersOnly":
|
||||||
|
result = result.filter((item) => item.type === "directory");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const compareItems = (a: DirectoryItem, b: DirectoryItem) => {
|
||||||
|
if (viewFilter.value === "all") {
|
||||||
|
if (a.type === "directory" && b.type !== "directory") return -1;
|
||||||
|
if (a.type !== "directory" && b.type === "directory") return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (sortMethod.value) {
|
||||||
|
case "modified":
|
||||||
|
return sortDesc.value
|
||||||
|
? new Date(a.modified).getTime() - new Date(b.modified).getTime()
|
||||||
|
: new Date(b.modified).getTime() - new Date(a.modified).getTime();
|
||||||
|
case "created":
|
||||||
|
return sortDesc.value
|
||||||
|
? new Date(a.created).getTime() - new Date(b.created).getTime()
|
||||||
|
: new Date(b.created).getTime() - new Date(a.created).getTime();
|
||||||
|
default:
|
||||||
|
return sortDesc.value ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
result.sort(compareItems);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
const filteredItems = computed(() => {
|
const filteredItems = computed(() => {
|
||||||
let result = [...items.value];
|
let result = [...items.value];
|
||||||
|
|
||||||
@@ -575,24 +637,7 @@ const filteredItems = computed(() => {
|
|||||||
result = result.filter((item) => item.name.toLowerCase().includes(query));
|
result = result.filter((item) => item.name.toLowerCase().includes(query));
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (sortMethod.value) {
|
return applySort(result);
|
||||||
case "modified":
|
|
||||||
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
||||||
break;
|
|
||||||
case "created":
|
|
||||||
result.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime());
|
|
||||||
break;
|
|
||||||
case "filesOnly":
|
|
||||||
result = result.filter((item) => item.type !== "directory");
|
|
||||||
break;
|
|
||||||
case "foldersOnly":
|
|
||||||
result = result.filter((item) => item.type === "directory");
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
result = applyDefaultSort(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { reset } = useInfiniteScroll(
|
const { reset } = useInfiniteScroll(
|
||||||
@@ -656,10 +701,6 @@ const onAnywhereClicked = (e: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sortFiles = (method: string) => {
|
|
||||||
sortMethod.value = method;
|
|
||||||
};
|
|
||||||
|
|
||||||
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"];
|
const imageExtensions = ["png", "jpg", "jpeg", "gif", "webp"];
|
||||||
|
|
||||||
const editFile = async (item: { name: string; type: string; path: string }) => {
|
const editFile = async (item: { name: string; type: string; path: string }) => {
|
||||||
@@ -682,7 +723,22 @@ const editFile = async (item: { name: string; type: string; path: string }) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initializeFileEdit = async () => {
|
||||||
|
if (!route.query.editing || !props.server.fs) return;
|
||||||
|
|
||||||
|
const filePath = route.query.editing as string;
|
||||||
|
await editFile({
|
||||||
|
name: filePath.split("/").pop() || "",
|
||||||
|
type: "file",
|
||||||
|
path: filePath,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
await modulesLoaded;
|
||||||
|
|
||||||
|
await initializeFileEdit();
|
||||||
|
|
||||||
await import("ace-builds");
|
await import("ace-builds");
|
||||||
await import("ace-builds/src-noconflict/mode-json");
|
await import("ace-builds/src-noconflict/mode-json");
|
||||||
await import("ace-builds/src-noconflict/mode-yaml");
|
await import("ace-builds/src-noconflict/mode-yaml");
|
||||||
@@ -717,7 +773,9 @@ watch(
|
|||||||
async (newQuery) => {
|
async (newQuery) => {
|
||||||
currentPage.value = 1;
|
currentPage.value = 1;
|
||||||
searchQuery.value = "";
|
searchQuery.value = "";
|
||||||
sortMethod.value = "default";
|
viewFilter.value = "all";
|
||||||
|
sortMethod.value = "name";
|
||||||
|
sortDesc.value = false;
|
||||||
|
|
||||||
currentPath.value = Array.isArray(newQuery.path)
|
currentPath.value = Array.isArray(newQuery.path)
|
||||||
? newQuery.path.join("")
|
? newQuery.path.join("")
|
||||||
|
|||||||
@@ -80,14 +80,19 @@
|
|||||||
<div class="flex flex-col-reverse gap-6 md:flex-col">
|
<div class="flex flex-col-reverse gap-6 md:flex-col">
|
||||||
<UiServersServerStats :data="stats" />
|
<UiServersServerStats :data="stats" />
|
||||||
<div
|
<div
|
||||||
class="relative flex h-[600px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
|
class="relative flex h-[700px] w-full flex-col gap-3 overflow-hidden rounded-2xl border border-divider bg-bg-raised p-4 transition-all duration-300 ease-in-out md:p-8"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
||||||
|
|
||||||
<UiServersPanelServerStatus :state="serverPowerState" />
|
<UiServersPanelServerStatus :state="serverPowerState" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- <div class="flex flex-row items-center gap-2 text-sm font-medium">
|
||||||
|
<InfoIcon class="hidden sm:block" />
|
||||||
|
Click and drag to select lines, then CMD+C to copy
|
||||||
|
</div> -->
|
||||||
<UiServersPanelTerminal :full-screen="fullScreen">
|
<UiServersPanelTerminal :full-screen="fullScreen">
|
||||||
<div class="relative w-full px-4 pt-4">
|
<div class="relative w-full px-4 pt-4">
|
||||||
<ul
|
<ul
|
||||||
@@ -164,7 +169,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UiServersPanelOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
|
<UiServersOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
|
||||||
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
|
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
|
||||||
<h2>Could not connect to the server.</h2>
|
<h2>Could not connect to the server.</h2>
|
||||||
<p>
|
<p>
|
||||||
@@ -239,19 +244,31 @@ interface ErrorData {
|
|||||||
const inspectingError = ref<ErrorData | null>(null);
|
const inspectingError = ref<ErrorData | null>(null);
|
||||||
|
|
||||||
const inspectError = async () => {
|
const inspectError = async () => {
|
||||||
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
try {
|
||||||
// @ts-ignore
|
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
||||||
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, {
|
if (!log) return;
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/x-www-form-urlencoded",
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
content: log,
|
|
||||||
}),
|
|
||||||
})) as ErrorData;
|
|
||||||
|
|
||||||
inspectingError.value = analysis;
|
// @ts-ignore
|
||||||
|
const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
content: log,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
|
||||||
|
inspectingError.value = response as ErrorData;
|
||||||
|
} else {
|
||||||
|
inspectingError.value = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to analyze logs:", error);
|
||||||
|
inspectingError.value = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearError = () => {
|
const clearError = () => {
|
||||||
@@ -261,7 +278,7 @@ const clearError = () => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.serverPowerState,
|
() => props.serverPowerState,
|
||||||
(newVal) => {
|
(newVal) => {
|
||||||
if (newVal === "crashed") {
|
if (newVal === "crashed" && !props.powerStateDetails?.oom_killed) {
|
||||||
inspectError();
|
inspectError();
|
||||||
} else {
|
} else {
|
||||||
clearError();
|
clearError();
|
||||||
@@ -269,7 +286,7 @@ watch(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (props.serverPowerState === "crashed") {
|
if (props.serverPowerState === "crashed" && !props.powerStateDetails?.oom_killed) {
|
||||||
inspectError();
|
inspectError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="card flex flex-col gap-4">
|
<div class="card flex flex-col gap-4">
|
||||||
<label for="server-name-field" class="flex flex-col gap-2">
|
<label for="server-name-field" class="flex flex-col gap-2">
|
||||||
<span class="text-lg font-bold text-contrast">Server name</span>
|
<span class="text-lg font-bold text-contrast">Server name</span>
|
||||||
<span> Change your server's name. This name is only visible on Modrinth.</span>
|
<span> This name is only visible on Modrinth.</span>
|
||||||
</label>
|
</label>
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<input
|
<input
|
||||||
@@ -64,10 +64,7 @@
|
|||||||
<div class="card flex flex-col gap-4">
|
<div class="card flex flex-col gap-4">
|
||||||
<label for="server-icon-field" class="flex flex-col gap-2">
|
<label for="server-icon-field" class="flex flex-col gap-2">
|
||||||
<span class="text-lg font-bold text-contrast">Server icon</span>
|
<span class="text-lg font-bold text-contrast">Server icon</span>
|
||||||
<span>
|
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
|
||||||
Change your server's icon. Changes will be visible on the Minecraft server list and on
|
|
||||||
Modrinth.
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<div
|
<div
|
||||||
@@ -91,20 +88,7 @@
|
|||||||
>
|
>
|
||||||
<EditIcon class="h-8 w-8 text-contrast" />
|
<EditIcon class="h-8 w-8 text-contrast" />
|
||||||
</div>
|
</div>
|
||||||
<img
|
<UiServersServerIcon :image="icon" />
|
||||||
v-if="icon"
|
|
||||||
no-shadow
|
|
||||||
alt="Server Icon"
|
|
||||||
class="h-[6rem] w-[6rem] rounded-xl"
|
|
||||||
:src="icon"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
v-else
|
|
||||||
no-shadow
|
|
||||||
alt="Server Icon"
|
|
||||||
class="h-[6rem] w-[6rem] rounded-xl"
|
|
||||||
src="~/assets/images/servers/minecraft_server_icon.png"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
|
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
|
||||||
@@ -116,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<UiServersPyroLoading v-else />
|
<div v-else />
|
||||||
<UiServersSaveBanner
|
<UiServersSaveBanner
|
||||||
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
|
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
|
||||||
:server="props.server"
|
:server="props.server"
|
||||||
@@ -234,67 +218,106 @@ const resetGeneral = () => {
|
|||||||
|
|
||||||
const uploadFile = async (e: Event) => {
|
const uploadFile = async (e: Event) => {
|
||||||
const file = (e.target as HTMLInputElement).files?.[0];
|
const file = (e.target as HTMLInputElement).files?.[0];
|
||||||
// down scale the image to 64x64
|
if (!file) {
|
||||||
|
addNotification({
|
||||||
|
group: "serverOptions",
|
||||||
|
type: "error",
|
||||||
|
title: "No file selected",
|
||||||
|
text: "Please select a file to upload.",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const scaledFile = await new Promise<File>((resolve, reject) => {
|
const scaledFile = await new Promise<File>((resolve, reject) => {
|
||||||
if (!file) {
|
|
||||||
addNotification({
|
|
||||||
group: "serverOptions",
|
|
||||||
type: "error",
|
|
||||||
title: "No file selected",
|
|
||||||
text: "Please select a file to upload.",
|
|
||||||
});
|
|
||||||
reject(new Error("No file selected"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
const ctx = canvas.getContext("2d");
|
const ctx = canvas.getContext("2d");
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.src = URL.createObjectURL(file);
|
|
||||||
img.onload = () => {
|
img.onload = () => {
|
||||||
canvas.width = 64;
|
canvas.width = 64;
|
||||||
canvas.height = 64;
|
canvas.height = 64;
|
||||||
ctx?.drawImage(img, 0, 0, 64, 64);
|
ctx?.drawImage(img, 0, 0, 64, 64);
|
||||||
// turn the downscaled image back to a png file
|
|
||||||
canvas.toBlob((blob) => {
|
canvas.toBlob((blob) => {
|
||||||
if (blob) {
|
if (blob) {
|
||||||
const data = new File([blob], "server-icon.png", { type: "image/png" });
|
resolve(new File([blob], "server-icon.png", { type: "image/png" }));
|
||||||
resolve(data);
|
|
||||||
} else {
|
} else {
|
||||||
reject(new Error("Canvas toBlob failed"));
|
reject(new Error("Canvas toBlob failed"));
|
||||||
}
|
}
|
||||||
}, "image/png");
|
}, "image/png");
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
};
|
};
|
||||||
img.onerror = reject;
|
img.onerror = reject;
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
});
|
});
|
||||||
if (!file) return;
|
|
||||||
if (data.value?.image) {
|
|
||||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
|
||||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
|
||||||
}
|
|
||||||
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
|
|
||||||
await props.server.fs?.uploadFile("/server-icon-original.png", file);
|
|
||||||
await props.server.refresh();
|
|
||||||
|
|
||||||
addNotification({
|
try {
|
||||||
group: "serverOptions",
|
if (data.value?.image) {
|
||||||
type: "success",
|
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||||
title: "Server icon updated",
|
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||||
text: "Your server icon was successfully changed.",
|
}
|
||||||
});
|
|
||||||
|
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
|
||||||
|
await props.server.fs?.uploadFile("/server-icon-original.png", file);
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
img.onload = () => {
|
||||||
|
canvas.width = 512;
|
||||||
|
canvas.height = 512;
|
||||||
|
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||||
|
const dataURL = canvas.toDataURL("image/png");
|
||||||
|
useState(`server-icon-${props.server.serverId}`).value = dataURL;
|
||||||
|
if (data.value) data.value.image = dataURL;
|
||||||
|
resolve();
|
||||||
|
URL.revokeObjectURL(img.src);
|
||||||
|
};
|
||||||
|
img.src = URL.createObjectURL(file);
|
||||||
|
});
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
group: "serverOptions",
|
||||||
|
type: "success",
|
||||||
|
title: "Server icon updated",
|
||||||
|
text: "Your server icon was successfully changed.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error uploading icon:", error);
|
||||||
|
addNotification({
|
||||||
|
group: "serverOptions",
|
||||||
|
type: "error",
|
||||||
|
title: "Upload failed",
|
||||||
|
text: "Failed to upload server icon.",
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetIcon = async () => {
|
const resetIcon = async () => {
|
||||||
if (data.value?.image) {
|
if (data.value?.image) {
|
||||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
try {
|
||||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||||
await reloadNuxtApp();
|
|
||||||
addNotification({
|
useState(`server-icon-${props.server.serverId}`).value = undefined;
|
||||||
group: "serverOptions",
|
if (data.value) data.value.image = undefined;
|
||||||
type: "success",
|
|
||||||
title: "Server icon reset",
|
await props.server.refresh(["general"]);
|
||||||
text: "Your server icon was successfully reset.",
|
|
||||||
});
|
addNotification({
|
||||||
|
group: "serverOptions",
|
||||||
|
type: "success",
|
||||||
|
title: "Server icon reset",
|
||||||
|
text: "Your server icon was successfully reset.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error resetting icon:", error);
|
||||||
|
addNotification({
|
||||||
|
group: "serverOptions",
|
||||||
|
type: "error",
|
||||||
|
title: "Reset failed",
|
||||||
|
text: "Failed to reset server icon.",
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
{{ data?.sftp_host }}
|
{{ data?.sftp_host }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span class="text-xs uppercase text-secondary">server address</span>
|
<span class="text-xs text-secondary">Server Address</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ButtonStyled type="transparent">
|
<ButtonStyled type="transparent">
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
|
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
|
||||||
<div
|
<div
|
||||||
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
|
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow px-4 py-2"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex h-8 items-center justify-between">
|
||||||
<span class="font-bold text-contrast">
|
<span class="font-bold text-contrast">
|
||||||
{{ data?.sftp_username }}
|
{{ data?.sftp_username }}
|
||||||
</span>
|
</span>
|
||||||
@@ -57,12 +57,12 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs uppercase text-secondary">username</span>
|
<span class="text-xs text-secondary">Username</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
|
class="flex w-full flex-col justify-center gap-2 rounded-xl bg-table-alternateRow p-4"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex h-8 items-center justify-between">
|
||||||
<span class="font-bold text-contrast">
|
<span class="font-bold text-contrast">
|
||||||
{{
|
{{
|
||||||
showPassword ? data?.sftp_password : "*".repeat(data?.sftp_password?.length ?? 0)
|
showPassword ? data?.sftp_password : "*".repeat(data?.sftp_password?.length ?? 0)
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs uppercase text-secondary">password</span>
|
<span class="text-xs text-secondary">Password</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</NewModal>
|
</NewModal>
|
||||||
|
|
||||||
<NewModal ref="editAllocationModal" header="Edit Allocation">
|
<NewModal ref="editAllocationModal" header="Edit allocation">
|
||||||
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
|
<form class="flex flex-col gap-2 md:w-[600px]" @submit.prevent="editAllocation">
|
||||||
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
|
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
|
||||||
<input
|
<input
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button :disabled="!newAllocationName" type="submit">
|
<button :disabled="!newAllocationName" type="submit">
|
||||||
<SaveIcon /> Update Allocation
|
<SaveIcon /> Update allocation
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
@@ -59,7 +59,29 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="relative h-full w-full overflow-y-auto">
|
<div class="relative h-full w-full overflow-y-auto">
|
||||||
<div v-if="data" class="flex h-full w-full flex-col justify-between gap-4">
|
<div
|
||||||
|
v-if="server.network?.error"
|
||||||
|
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||||
|
<div class="flex flex-col items-center text-center">
|
||||||
|
<div class="flex flex-col items-center gap-4">
|
||||||
|
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||||
|
<IssuesIcon class="size-12 text-orange" />
|
||||||
|
</div>
|
||||||
|
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg text-secondary">
|
||||||
|
We couldn't load your server's network settings. Here's what we know:
|
||||||
|
<span class="break-all font-mono">{{ JSON.stringify(server.network.error) }}</span>
|
||||||
|
</p>
|
||||||
|
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
|
||||||
|
<button class="mt-6 !w-full">Retry</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
|
||||||
<div class="flex h-full flex-col">
|
<div class="flex h-full flex-col">
|
||||||
<!-- Subdomain section -->
|
<!-- Subdomain section -->
|
||||||
<div class="card flex flex-col gap-4">
|
<div class="card flex flex-col gap-4">
|
||||||
@@ -94,7 +116,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow p-4"
|
class="flex max-w-full flex-none overflow-auto rounded-xl bg-table-alternateRow px-4 py-2"
|
||||||
>
|
>
|
||||||
<table
|
<table
|
||||||
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
|
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
|
||||||
@@ -108,7 +130,7 @@
|
|||||||
>
|
>
|
||||||
{{ record.type }}
|
{{ record.type }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs uppercase text-secondary">type</span>
|
<span class="text-xs text-secondary">Type</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="w-2/6 py-3 md:w-1/3">
|
<td class="w-2/6 py-3 md:w-1/3">
|
||||||
@@ -118,7 +140,7 @@
|
|||||||
>
|
>
|
||||||
{{ record.name }}
|
{{ record.name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs uppercase text-secondary">name</span>
|
<span class="text-xs text-secondary">Name</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
|
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
|
||||||
@@ -128,7 +150,7 @@
|
|||||||
>
|
>
|
||||||
{{ record.content }}
|
{{ record.content }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-xs uppercase text-secondary">content</span>
|
<span class="text-xs text-secondary">Content</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -155,7 +177,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ButtonStyled type="standard" color="brand" @click="showNewAllocationModal">
|
<ButtonStyled type="standard" @click="showNewAllocationModal">
|
||||||
<button class="!w-full sm:!w-auto">
|
<button class="!w-full sm:!w-auto">
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
<span>New allocation</span>
|
<span>New allocation</span>
|
||||||
@@ -190,7 +212,7 @@
|
|||||||
<span class="text-md font-bold tracking-wide text-contrast">
|
<span class="text-md font-bold tracking-wide text-contrast">
|
||||||
{{ allocation.name }}
|
{{ allocation.name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="hidden text-xs uppercase text-secondary sm:block">name</span>
|
<span class="hidden text-xs text-secondary sm:block">Name</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<span
|
<span
|
||||||
@@ -198,7 +220,7 @@
|
|||||||
>
|
>
|
||||||
{{ allocation.port }}
|
{{ allocation.port }}
|
||||||
</span>
|
</span>
|
||||||
<span class="hidden text-xs uppercase text-secondary sm:block">port</span>
|
<span class="hidden text-xs text-secondary sm:block">Port</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +269,7 @@ import {
|
|||||||
SaveIcon,
|
SaveIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
UploadIcon,
|
UploadIcon,
|
||||||
|
IssuesIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
|
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
|
||||||
import { ref, computed, nextTick } from "vue";
|
import { ref, computed, nextTick } from "vue";
|
||||||
@@ -286,12 +309,11 @@ const addNewAllocation = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await props.server.network?.reserveAllocation(newAllocationName.value);
|
await props.server.network?.reserveAllocation(newAllocationName.value);
|
||||||
|
await props.server.refresh(["network"]);
|
||||||
|
|
||||||
newAllocationModal.value?.hide();
|
newAllocationModal.value?.hide();
|
||||||
newAllocationName.value = "";
|
newAllocationName.value = "";
|
||||||
|
|
||||||
await props.server.refresh();
|
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "serverOptions",
|
group: "serverOptions",
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -332,8 +354,8 @@ const confirmDeleteAllocation = async () => {
|
|||||||
if (allocationToDelete.value === null) return;
|
if (allocationToDelete.value === null) return;
|
||||||
|
|
||||||
await props.server.network?.deleteAllocation(allocationToDelete.value);
|
await props.server.network?.deleteAllocation(allocationToDelete.value);
|
||||||
|
await props.server.refresh(["network"]);
|
||||||
|
|
||||||
await props.server.refresh();
|
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "serverOptions",
|
group: "serverOptions",
|
||||||
type: "success",
|
type: "success",
|
||||||
@@ -349,12 +371,11 @@ const editAllocation = async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
|
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
|
||||||
|
await props.server.refresh(["network"]);
|
||||||
|
|
||||||
editAllocationModal.value?.hide();
|
editAllocationModal.value?.hide();
|
||||||
newAllocationName.value = "";
|
newAllocationName.value = "";
|
||||||
|
|
||||||
await props.server.refresh();
|
|
||||||
|
|
||||||
addNotification({
|
addNotification({
|
||||||
group: "serverOptions",
|
group: "serverOptions",
|
||||||
type: "success",
|
type: "success",
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ const preferences = {
|
|||||||
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
|
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
|
||||||
implemented: true,
|
implemented: true,
|
||||||
},
|
},
|
||||||
|
hideSubdomainLabel: {
|
||||||
|
displayName: "Hide subdomain label",
|
||||||
|
description: "When enabled, the subdomain label will be hidden from the server header.",
|
||||||
|
implemented: true,
|
||||||
|
},
|
||||||
autoRestart: {
|
autoRestart: {
|
||||||
displayName: "Auto restart",
|
displayName: "Auto restart",
|
||||||
description: "When enabled, your server will automatically restart if it crashes.",
|
description: "When enabled, your server will automatically restart if it crashes.",
|
||||||
@@ -84,6 +89,7 @@ type UserPreferences = {
|
|||||||
|
|
||||||
const defaultPreferences: UserPreferences = {
|
const defaultPreferences: UserPreferences = {
|
||||||
ramAsNumber: false,
|
ramAsNumber: false,
|
||||||
|
hideSubdomainLabel: false,
|
||||||
autoRestart: false,
|
autoRestart: false,
|
||||||
powerDontAskAgain: false,
|
powerDontAskAgain: false,
|
||||||
backupWhileRunning: false,
|
backupWhileRunning: false,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user