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
|
||||
[target.'cfg(windows)']
|
||||
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
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
env:
|
||||
SQLX_OFFLINE: true
|
||||
with:
|
||||
context: ./apps/labrinth
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
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:
|
||||
- 'v*'
|
||||
paths:
|
||||
- .github/workflows/app-release.yml
|
||||
- .github/workflows/theseus-release.yml
|
||||
- 'apps/app/**'
|
||||
- 'apps/app-frontend/**'
|
||||
- 'apps/labrinth/src/common/**'
|
||||
- 'apps/labrinth/Cargo.toml'
|
||||
- 'packages/app-lib/**'
|
||||
- 'packages/app-macros/**'
|
||||
- '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/tests" isTestSource="true" />
|
||||
<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" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</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/daedalus_client',
|
||||
'./packages/daedalus',
|
||||
'./packages/ariadne',
|
||||
]
|
||||
|
||||
# Optimize for speed and reduce size on release builds
|
||||
@@ -21,4 +22,4 @@ strip = true # Remove debug symbols
|
||||
opt-level = 3
|
||||
|
||||
[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>
|
||||
import { XIcon, HammerIcon, LogInIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import {
|
||||
CheckIcon,
|
||||
DropdownIcon,
|
||||
XIcon,
|
||||
HammerIcon,
|
||||
LogInIcon,
|
||||
UpdatedIcon,
|
||||
CopyIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ChatIcon } from '@/assets/icons'
|
||||
import { ref } from 'vue'
|
||||
import { ButtonStyled, Collapsible } from '@modrinth/ui'
|
||||
import { ref, computed } from 'vue'
|
||||
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
@@ -13,6 +22,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
const closable = ref(true)
|
||||
const errorCollapsed = ref(false)
|
||||
|
||||
const title = ref('An error occurred')
|
||||
const errorType = ref('unknown')
|
||||
@@ -118,6 +128,26 @@ async function repairInstance() {
|
||||
}
|
||||
loadingRepair.value = false
|
||||
}
|
||||
|
||||
const hasDebugInfo = computed(
|
||||
() =>
|
||||
errorType.value === 'directory_move' ||
|
||||
errorType.value === 'minecraft_auth' ||
|
||||
errorType.value === 'state_init' ||
|
||||
errorType.value === 'no_loader_version',
|
||||
)
|
||||
|
||||
const debugInfo = computed(() => error.value.message ?? error.value ?? 'No error message.')
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -244,16 +274,9 @@ async function repairInstance() {
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ error.message ?? error }}
|
||||
{{ debugInfo }}
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
errorType === 'directory_move' ||
|
||||
errorType === 'minecraft_auth' ||
|
||||
errorType === 'state_init' ||
|
||||
errorType === 'no_loader_version'
|
||||
"
|
||||
>
|
||||
<template v-if="hasDebugInfo">
|
||||
<hr />
|
||||
<p>
|
||||
If nothing is working and you need help, visit
|
||||
@@ -261,16 +284,39 @@ async function repairInstance() {
|
||||
and start a chat using the widget in the bottom right and we will be more than happy to
|
||||
assist! Make sure to provide the following debug information to the agent:
|
||||
</p>
|
||||
<details>
|
||||
<summary>Debug information</summary>
|
||||
{{ error.message ?? error }}
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
<div class="input-group push-right">
|
||||
<a :href="supportLink" class="btn" @click="errorModal.hide()"><ChatIcon /> Get support</a>
|
||||
<button v-if="closable" class="btn" @click="errorModal.hide()"><XIcon /> Close</button>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled>
|
||||
<a :href="supportLink" @click="errorModal.hide()"><ChatIcon /> Get support</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="closable">
|
||||
<button @click="errorModal.hide()"><XIcon /> Close</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="hasDebugInfo">
|
||||
<button :disabled="copied" @click="copyToClipboard(debugInfo)">
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
|
||||
<template v-else> <CopyIcon /> Copy debug info </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
|
||||
<button
|
||||
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
|
||||
@click="errorCollapsed = !errorCollapsed"
|
||||
>
|
||||
<span class="text-contrast font-extrabold m-0">Debug information:</span>
|
||||
<DropdownIcon
|
||||
class="h-5 w-5 text-secondary transition-transform"
|
||||
:class="{ 'rotate-180': !errorCollapsed }"
|
||||
/>
|
||||
</button>
|
||||
<Collapsible :collapsed="errorCollapsed">
|
||||
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { get } from '@/helpers/settings'
|
||||
import { edit } from '@/helpers/profile'
|
||||
import type { InstanceSettingsTabProps, AppSettings } from '../../../helpers/types'
|
||||
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -114,7 +114,6 @@ const messages = defineMessages({
|
||||
<Toggle
|
||||
id="fullscreen"
|
||||
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
|
||||
:checked="fullscreenSetting"
|
||||
:disabled="!overrideWindowSettings"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
|
||||
@@ -43,7 +43,7 @@ function onModalHide() {
|
||||
if (props.showAdOnClose) {
|
||||
show_ads_window()
|
||||
}
|
||||
props.onHide()
|
||||
props.onHide?.()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { Toggle, ThemeSelector, TeleportDropdownMenu } from '@modrinth/ui'
|
||||
import { TeleportDropdownMenu, ThemeSelector, Toggle } from '@modrinth/ui'
|
||||
import { useTheming } from '@/store/state'
|
||||
import { get, set } from '@/helpers/settings'
|
||||
import { watch, ref } from 'vue'
|
||||
import { ref, watch } from 'vue'
|
||||
import { getOS } from '@/helpers/utils'
|
||||
|
||||
const themeStore = useTheming()
|
||||
@@ -46,7 +46,6 @@ watch(
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="themeStore.advancedRendering"
|
||||
:checked="themeStore.advancedRendering"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
themeStore.advancedRendering = e
|
||||
@@ -61,16 +60,7 @@ watch(
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Native Decorations</h2>
|
||||
<p class="m-0 mt-1">Use system window frame (app restart required).</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="native-decorations"
|
||||
:model-value="settings.native_decorations"
|
||||
:checked="settings.native_decorations"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.native_decorations = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Toggle id="native-decorations" v-model="settings.native_decorations" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
@@ -78,16 +68,7 @@ watch(
|
||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Minimize launcher</h2>
|
||||
<p class="m-0 mt-1">Minimize the launcher when a Minecraft process starts.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="minimize-launcher"
|
||||
:model-value="settings.hide_on_process_start"
|
||||
:checked="settings.hide_on_process_start"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.hide_on_process_start = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Toggle id="minimize-launcher" v-model="settings.hide_on_process_start" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between">
|
||||
@@ -111,7 +92,6 @@ watch(
|
||||
<Toggle
|
||||
id="toggle-sidebar"
|
||||
:model-value="settings.toggle_sidebar"
|
||||
:checked="settings.toggle_sidebar"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.toggle_sidebar = e
|
||||
|
||||
@@ -57,16 +57,7 @@ watch(
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Toggle
|
||||
id="fullscreen"
|
||||
:model-value="settings.force_fullscreen"
|
||||
:checked="settings.force_fullscreen"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.force_fullscreen = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Toggle id="fullscreen" v-model="settings.force_fullscreen" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
|
||||
@@ -37,7 +37,6 @@ watch(
|
||||
<Toggle
|
||||
id="advanced-rendering"
|
||||
:model-value="getStoreValue(option)"
|
||||
:checked="getStoreValue(option)"
|
||||
@update:model-value="() => setStoreValue(option, !themeStore.featureFlags[option])"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -30,16 +30,7 @@ watch(
|
||||
option, you opt out and ads will no longer be shown based on your interests.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="personalized-ads"
|
||||
:model-value="settings.personalized_ads"
|
||||
:checked="settings.personalized_ads"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.personalized_ads = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Toggle id="personalized-ads" v-model="settings.personalized_ads" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex items-center justify-between gap-4">
|
||||
@@ -51,16 +42,7 @@ watch(
|
||||
longer be collected.
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="opt-out-analytics"
|
||||
:model-value="settings.telemetry"
|
||||
:checked="settings.telemetry"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
settings.telemetry = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
<Toggle id="opt-out-analytics" v-model="settings.telemetry" />
|
||||
</div>
|
||||
|
||||
<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)
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="disable-discord-rpc"
|
||||
v-model="settings.discord_rpc"
|
||||
:checked="settings.discord_rpc"
|
||||
/>
|
||||
<Toggle id="disable-discord-rpc" v-model="settings.discord_rpc" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -179,7 +179,6 @@
|
||||
<Toggle
|
||||
class="!mx-2"
|
||||
:model-value="!item.data.disabled"
|
||||
:checked="!item.data.disabled"
|
||||
@update:model-value="toggleDisableMod(item.data)"
|
||||
/>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
|
||||
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"
|
||||
)]
|
||||
|
||||
use std::time::Duration;
|
||||
use theseus::prelude::*;
|
||||
|
||||
use theseus::profile::create::profile_create;
|
||||
use tokio::signal::ctrl_c;
|
||||
|
||||
// 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)
|
||||
@@ -41,54 +41,21 @@ async fn main() -> theseus::Result<()> {
|
||||
// Initialize state
|
||||
State::init().await?;
|
||||
|
||||
if minecraft_auth::users().await?.is_empty() {
|
||||
println!("No users found, authenticating.");
|
||||
authenticate_run().await?; // could take credentials from here direct, but also deposited in state users
|
||||
}
|
||||
//
|
||||
// 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?;
|
||||
loop {
|
||||
if State::get().await?.friends_socket.is_connected().await {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
println!("Creating/adding profile.");
|
||||
tracing::info!("Starting host");
|
||||
|
||||
let name = "Example".to_string();
|
||||
let game_version = "1.16.1".to_string();
|
||||
let modloader = ModLoader::Forge;
|
||||
let loader_version = "stable".to_string();
|
||||
let socket = State::get().await?.friends_socket.open_port(25565).await?;
|
||||
tracing::info!("Running host on socket {}", socket.socket_id());
|
||||
|
||||
let profile_path = profile_create(
|
||||
name,
|
||||
game_version,
|
||||
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?;
|
||||
ctrl_c().await?;
|
||||
tracing::info!("Stopping host");
|
||||
socket.shutdown().await?;
|
||||
|
||||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.3",
|
||||
"@astrojs/starlight": "^0.26.3",
|
||||
"@astrojs/check": "^0.9.4",
|
||||
"@astrojs/starlight": "^0.32.2",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"astro": "^4.10.2",
|
||||
"sharp": "^0.32.5",
|
||||
"starlight-openapi": "^0.7.0",
|
||||
"typescript": "^5.5.4"
|
||||
"astro": "^5.4.1",
|
||||
"sharp": "^0.33.5",
|
||||
"starlight-openapi": "^0.14.0",
|
||||
"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",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
"@types/three": "^0.172.0",
|
||||
"vue-multiselect": "3.0.0-alpha.2",
|
||||
"vue-typed-virtual-list": "^1.0.10",
|
||||
"vue3-ace-editor": "^2.2.4",
|
||||
|
||||
@@ -133,6 +133,21 @@
|
||||
"sidebar"
|
||||
/ 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) {
|
||||
&.sidebar {
|
||||
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 {
|
||||
grid-area: sidebar;
|
||||
}
|
||||
|
||||
@@ -19,10 +19,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="additional-information" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Summary
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span class="text-lg font-semibold text-contrast"> Summary </span>
|
||||
<span>A sentence or two that describes your collection.</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
@@ -52,8 +49,8 @@
|
||||
</NewModal>
|
||||
</template>
|
||||
<script setup>
|
||||
import { XIcon, PlusIcon } from "@modrinth/assets";
|
||||
import { NewModal, ButtonStyled } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
|
||||
const router = useNativeRouter();
|
||||
|
||||
@@ -78,7 +75,7 @@ async function create() {
|
||||
method: "POST",
|
||||
body: {
|
||||
name: name.value.trim(),
|
||||
description: description.value.trim(),
|
||||
description: description.value.trim() || undefined,
|
||||
projects: props.projectIds,
|
||||
},
|
||||
apiVersion: 3,
|
||||
|
||||
@@ -1,329 +1,366 @@
|
||||
<template>
|
||||
<div class="card moderation-checklist">
|
||||
<h1>Moderation checklist</h1>
|
||||
<div v-if="done">
|
||||
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
|
||||
<div
|
||||
class="moderation-checklist flex w-[600px] max-w-full flex-col rounded-2xl border-[1px] border-solid border-orange bg-bg-raised p-4 transition-all delay-200 duration-200 ease-in-out"
|
||||
:class="collapsed ? `sm:max-w-[300px]` : 'sm:max-w-[600px]'"
|
||||
>
|
||||
<div class="flex grow-0 items-center gap-2">
|
||||
<h1 class="m-0 mr-auto flex items-center gap-2 text-2xl font-extrabold text-contrast">
|
||||
<ScaleIcon class="text-orange" /> Moderation
|
||||
</h1>
|
||||
<ButtonStyled circular color="red" color-fill="none" hover-color-fill="background">
|
||||
<button v-tooltip="`Exit moderation`" @click="exitModeration">
|
||||
<CrossIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<button v-tooltip="collapsed ? `Expand` : `Collapse`" @click="emit('toggleCollapsed')">
|
||||
<DropdownIcon class="transition-transform" :class="{ 'rotate-180': collapsed }" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-else-if="generatedMessage">
|
||||
<p>
|
||||
Enter your moderation message here. Remember to check the Moderation tab to answer any
|
||||
questions an author might have!
|
||||
</p>
|
||||
<div class="markdown-editor-spacing">
|
||||
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
|
||||
<Collapsible base-class="grow" class="flex grow flex-col" :collapsed="collapsed">
|
||||
<div class="my-4 h-[1px] w-full bg-divider" />
|
||||
<div v-if="done">
|
||||
<p>You are done moderating this project! There are {{ futureProjects.length }} left.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
|
||||
<h2 v-if="modPackData">
|
||||
Modpack permissions
|
||||
<template v-if="modPackIndex + 1 <= modPackData.length">
|
||||
({{ modPackIndex + 1 }} / {{ modPackData.length }})
|
||||
</template>
|
||||
</h2>
|
||||
<div v-if="!modPackData">Loading data...</div>
|
||||
<div v-else-if="modPackData.length === 0">
|
||||
<p>All permissions obtained. You may skip this step!</p>
|
||||
<div v-else-if="generatedMessage">
|
||||
<p>
|
||||
Enter your moderation message here. Remember to check the Moderation tab to answer any
|
||||
questions an author might have!
|
||||
</p>
|
||||
<div class="markdown-editor-spacing">
|
||||
<MarkdownEditor v-model="message" :placeholder="'Enter moderation message'" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!modPackData[modPackIndex]">
|
||||
<p>All permission checks complete!</p>
|
||||
<div class="input-group modpack-buttons">
|
||||
<button class="btn" @click="modPackIndex -= 1">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
<div v-else-if="steps[currentStepIndex].id === 'modpack-permissions'">
|
||||
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
|
||||
Modpack permissions
|
||||
<template v-if="modPackIndex + 1 <= modPackData.length">
|
||||
({{ modPackIndex + 1 }} / {{ modPackData.length }})
|
||||
</template>
|
||||
</h2>
|
||||
<div v-if="!modPackData">Loading data...</div>
|
||||
<div v-else-if="modPackData.length === 0">
|
||||
<p>All permissions obtained. You may skip this step!</p>
|
||||
</div>
|
||||
<div v-else-if="!modPackData[modPackIndex]">
|
||||
<p>All permission checks complete!</p>
|
||||
<div class="input-group modpack-buttons">
|
||||
<ButtonStyled>
|
||||
<button @click="modPackIndex -= 1">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="modPackData[modPackIndex].type === 'unknown'">
|
||||
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].status = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="modPackData[modPackIndex].status !== 'unidentified'"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<label for="proof">
|
||||
<span class="label__title">Proof</span>
|
||||
</label>
|
||||
<input
|
||||
id="proof"
|
||||
v-model="modPackData[modPackIndex].proof"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter proof of status..."
|
||||
/>
|
||||
<label for="link">
|
||||
<span class="label__title">Link</span>
|
||||
</label>
|
||||
<input
|
||||
id="link"
|
||||
v-model="modPackData[modPackIndex].url"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter link of project..."
|
||||
/>
|
||||
<label for="title">
|
||||
<span class="label__title">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="modPackData[modPackIndex].title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter title of project..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
|
||||
<p>
|
||||
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
|
||||
:href="modPackData[modPackIndex].url"
|
||||
target="_blank"
|
||||
class="text-link"
|
||||
>{{ modPackData[modPackIndex].url }}</a
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].status = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
|
||||
"
|
||||
>
|
||||
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
|
||||
Does this project provide identification and permission for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
|
||||
Does this project provide attribution for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else>
|
||||
Does this project provide proof of permission for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in filePermissionTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].approved === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].approved = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="blue">
|
||||
<button :disabled="!modPackData[modPackIndex].status" @click="modPackIndex += 1">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next project
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="modPackData[modPackIndex].type === 'unknown'">
|
||||
<p>What is the approval type of {{ modPackData[modPackIndex].file_name }}?</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].status = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="modPackData[modPackIndex].status !== 'unidentified'">
|
||||
<div class="universal-labels"></div>
|
||||
<label for="proof">
|
||||
<span class="label__title">Proof</span>
|
||||
</label>
|
||||
<input
|
||||
id="proof"
|
||||
v-model="modPackData[modPackIndex].proof"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter proof of status..."
|
||||
/>
|
||||
<label for="link">
|
||||
<span class="label__title">Link</span>
|
||||
</label>
|
||||
<input
|
||||
id="link"
|
||||
v-model="modPackData[modPackIndex].url"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter link of project..."
|
||||
/>
|
||||
<label for="title">
|
||||
<span class="label__title">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="modPackData[modPackIndex].title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter title of project..."
|
||||
/>
|
||||
<h2 class="m-0 mb-2 text-lg font-extrabold">{{ steps[currentStepIndex].question }}</h2>
|
||||
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
|
||||
<strong>Guidance:</strong>
|
||||
<ul class="mb-3 mt-2 leading-tight">
|
||||
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
|
||||
{{ rule }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template
|
||||
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
|
||||
>
|
||||
<strong>Reject things like:</strong>
|
||||
<ul class="mb-3 mt-2 leading-tight">
|
||||
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
|
||||
{{ example }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template
|
||||
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
|
||||
>
|
||||
<strong>Exceptions:</strong>
|
||||
<ul class="mb-3 mt-2 leading-tight">
|
||||
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
|
||||
{{ exception }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-if="steps[currentStepIndex].id === 'title'">
|
||||
<strong>Title:</strong> {{ project.title }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'slug'">
|
||||
<strong>Slug:</strong> {{ project.slug }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'summary'">
|
||||
<strong>Summary:</strong> {{ project.description }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'links'">
|
||||
<template v-if="project.issues_url">
|
||||
<strong>Issues: </strong>
|
||||
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="modPackData[modPackIndex].type === 'flame'">
|
||||
<p>
|
||||
What is the approval type of {{ modPackData[modPackIndex].title }} (<a
|
||||
:href="modPackData[modPackIndex].url"
|
||||
target="_blank"
|
||||
class="text-link"
|
||||
>{{ modPackData[modPackIndex].url }}</a
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].status === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].status = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="project.source_url">
|
||||
<strong>Source: </strong>
|
||||
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.wiki_url">
|
||||
<strong>Wiki: </strong>
|
||||
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.discord_url">
|
||||
<strong>Discord: </strong>
|
||||
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
|
||||
<br />
|
||||
</template>
|
||||
<template v-for="(donation, index) in project.donation_urls" :key="index">
|
||||
<strong>{{ donation.platform }}: </strong>
|
||||
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
|
||||
<br />
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'categories'">
|
||||
<strong>Categories:</strong>
|
||||
<Categories
|
||||
:categories="project.categories.concat(project.additional_categories)"
|
||||
:type="project.actualProjectType"
|
||||
class="categories"
|
||||
/>
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'side-types'">
|
||||
<strong>Client side:</strong> {{ project.client_side }} <br />
|
||||
<strong>Server side:</strong> {{ project.server_side }}
|
||||
</p>
|
||||
<div class="options input-group">
|
||||
<button
|
||||
v-for="(option, index) in steps[currentStepIndex].options"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected':
|
||||
selectedOptions[steps[currentStepIndex].id] &&
|
||||
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
|
||||
}"
|
||||
@click="toggleOption(steps[currentStepIndex].id, option)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
['unidentified', 'no', 'with-attribution'].includes(modPackData[modPackIndex].status)
|
||||
selectedOptions[steps[currentStepIndex].id] &&
|
||||
selectedOptions[steps[currentStepIndex].id].length > 0
|
||||
"
|
||||
class="inputs universal-labels"
|
||||
>
|
||||
<p v-if="modPackData[modPackIndex].status === 'unidentified'">
|
||||
Does this project provide identification and permission for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else-if="modPackData[modPackIndex].status === 'with-attribution'">
|
||||
Does this project provide attribution for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else>
|
||||
Does this project provide proof of permission for
|
||||
<strong>{{ modPackData[modPackIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<button
|
||||
v-for="(option, index) in filePermissionTypes"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected': modPackData[modPackIndex].approved === option.id,
|
||||
}"
|
||||
@click="modPackData[modPackIndex].approved = option.id"
|
||||
>
|
||||
{{ option.name }}
|
||||
<div
|
||||
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
|
||||
(x) => x.fillers && x.fillers.length > 0,
|
||||
)"
|
||||
:key="index"
|
||||
>
|
||||
<div v-for="(filler, idx) in option.fillers" :key="idx">
|
||||
<label :for="filler.id">
|
||||
<span class="label__title">
|
||||
{{ filler.question }}
|
||||
<span v-if="filler.required" class="required">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="filler.large" class="markdown-editor-spacing">
|
||||
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
|
||||
</div>
|
||||
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto">
|
||||
<div
|
||||
class="mt-4 flex grow justify-between gap-2 border-0 border-t-[1px] border-solid border-divider pt-4"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="!done">
|
||||
<button aria-label="Skip" @click="goToNextProject">
|
||||
<ExitIcon aria-hidden="true" />
|
||||
<template v-if="futureProjects.length > 0">Skip</template>
|
||||
<template v-else>Exit</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="currentStepIndex > 0">
|
||||
<button @click="previousPage() && !done">
|
||||
<LeftArrowIcon aria-hidden="true" /> Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<ButtonStyled v-if="currentStepIndex < steps.length - 1 && !done" color="brand">
|
||||
<button @click="nextPage()"><RightArrowIcon aria-hidden="true" /> Next</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="!generatedMessage" color="brand">
|
||||
<button :disabled="loadingMessage" @click="generateMessage">
|
||||
<UpdatedIcon aria-hidden="true" /> Generate message
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-if="generatedMessage && !done">
|
||||
<ButtonStyled color="green">
|
||||
<button @click="sendMessage(project.requested_status ?? 'approved')">
|
||||
<CheckIcon aria-hidden="true" /> Approve
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled color="red">
|
||||
<button @click="sendMessage('rejected')">
|
||||
<CrossIcon aria-hidden="true" /> Reject
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<OverflowMenu
|
||||
class="btn-dropdown-animation"
|
||||
:options="[
|
||||
{
|
||||
id: 'withhold',
|
||||
color: 'danger',
|
||||
action: () => sendMessage('withheld'),
|
||||
hoverFilled: true,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<DropdownIcon style="rotate: 180deg" />
|
||||
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
|
||||
Next project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group modpack-buttons">
|
||||
<button class="btn" :disabled="modPackIndex <= 0" @click="modPackIndex -= 1">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-blue"
|
||||
:disabled="!modPackData[modPackIndex].status"
|
||||
@click="modPackIndex += 1"
|
||||
>
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
Next project
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h2>{{ steps[currentStepIndex].question }}</h2>
|
||||
<template v-if="steps[currentStepIndex].rules && steps[currentStepIndex].rules.length > 0">
|
||||
<strong>Rules guidance:</strong>
|
||||
<ul>
|
||||
<li v-for="(rule, index) in steps[currentStepIndex].rules" :key="index">
|
||||
{{ rule }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template
|
||||
v-if="steps[currentStepIndex].examples && steps[currentStepIndex].examples.length > 0"
|
||||
>
|
||||
<strong>Examples of what to reject:</strong>
|
||||
<ul>
|
||||
<li v-for="(example, index) in steps[currentStepIndex].examples" :key="index">
|
||||
{{ example }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<template
|
||||
v-if="steps[currentStepIndex].exceptions && steps[currentStepIndex].exceptions.length > 0"
|
||||
>
|
||||
<strong>Exceptions:</strong>
|
||||
<ul>
|
||||
<li v-for="(exception, index) in steps[currentStepIndex].exceptions" :key="index">
|
||||
{{ exception }}
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
<p v-if="steps[currentStepIndex].id === 'title'">
|
||||
<strong>Title:</strong> {{ project.title }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'slug'"><strong>Slug:</strong> {{ project.slug }}</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'summary'">
|
||||
<strong>Summary:</strong> {{ project.description }}
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'links'">
|
||||
<template v-if="project.issues_url">
|
||||
<strong>Issues: </strong>
|
||||
<a class="text-link" :href="project.issues_url">{{ project.issues_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.source_url">
|
||||
<strong>Source: </strong>
|
||||
<a class="text-link" :href="project.source_url">{{ project.source_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.wiki_url">
|
||||
<strong>Wiki: </strong>
|
||||
<a class="text-link" :href="project.wiki_url">{{ project.wiki_url }}</a> <br />
|
||||
</template>
|
||||
<template v-if="project.discord_url">
|
||||
<strong>Discord: </strong>
|
||||
<a class="text-link" :href="project.discord_url">{{ project.discord_url }}</a>
|
||||
<br />
|
||||
</template>
|
||||
<template v-for="(donation, index) in project.donation_urls" :key="index">
|
||||
<strong>{{ donation.platform }}: </strong>
|
||||
<a class="text-link" :href="donation.url">{{ donation.url }}</a>
|
||||
<br />
|
||||
</template>
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'categories'">
|
||||
<strong>Categories:</strong>
|
||||
<Categories
|
||||
:categories="project.categories.concat(project.additional_categories)"
|
||||
:type="project.actualProjectType"
|
||||
class="categories"
|
||||
/>
|
||||
</p>
|
||||
<p v-if="steps[currentStepIndex].id === 'side-types'">
|
||||
<strong>Client side:</strong> {{ project.client_side }} <br />
|
||||
<strong>Server side:</strong> {{ project.server_side }}
|
||||
</p>
|
||||
<div class="options input-group">
|
||||
<button
|
||||
v-for="(option, index) in steps[currentStepIndex].options"
|
||||
:key="index"
|
||||
class="btn"
|
||||
:class="{
|
||||
'option-selected':
|
||||
selectedOptions[steps[currentStepIndex].id] &&
|
||||
selectedOptions[steps[currentStepIndex].id].find((x) => x.name === option.name),
|
||||
}"
|
||||
@click="toggleOption(steps[currentStepIndex].id, option)"
|
||||
>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
selectedOptions[steps[currentStepIndex].id] &&
|
||||
selectedOptions[steps[currentStepIndex].id].length > 0
|
||||
"
|
||||
class="inputs universal-labels"
|
||||
>
|
||||
<div
|
||||
v-for="(option, index) in selectedOptions[steps[currentStepIndex].id].filter(
|
||||
(x) => x.fillers && x.fillers.length > 0,
|
||||
)"
|
||||
:key="index"
|
||||
>
|
||||
<div v-for="(filler, idx) in option.fillers" :key="idx">
|
||||
<label :for="filler.id">
|
||||
<span class="label__title">
|
||||
{{ filler.question }}
|
||||
<span v-if="filler.required" class="required">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<div v-if="filler.large" class="markdown-editor-spacing">
|
||||
<MarkdownEditor v-model="filler.value" :placeholder="'Enter moderation message'" />
|
||||
</div>
|
||||
<input v-else :id="filler.id" v-model="filler.value" type="text" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group modpack-buttons">
|
||||
<button v-if="!done" class="btn skip-btn" aria-label="Skip" @click="goToNextProject">
|
||||
<ExitIcon aria-hidden="true" />
|
||||
<template v-if="futureProjects.length > 0">Skip</template>
|
||||
<template v-else>Exit</template>
|
||||
</button>
|
||||
<button v-if="currentStepIndex > 0" class="btn" @click="previousPage() && !done">
|
||||
<LeftArrowIcon aria-hidden="true" /> Previous
|
||||
</button>
|
||||
<button
|
||||
v-if="currentStepIndex < steps.length - 1 && !done"
|
||||
class="btn btn-primary"
|
||||
@click="nextPage()"
|
||||
>
|
||||
<RightArrowIcon aria-hidden="true" /> Next
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!generatedMessage"
|
||||
class="btn btn-primary"
|
||||
:disabled="loadingMessage"
|
||||
@click="generateMessage"
|
||||
>
|
||||
<UpdatedIcon aria-hidden="true" /> Generate message
|
||||
</button>
|
||||
<template v-if="generatedMessage && !done">
|
||||
<button class="btn btn-green" @click="sendMessage(project.requested_status ?? 'approved')">
|
||||
<CheckIcon aria-hidden="true" /> Approve
|
||||
</button>
|
||||
<div class="joined-buttons">
|
||||
<button class="btn btn-danger" @click="sendMessage('rejected')">
|
||||
<CrossIcon aria-hidden="true" /> Reject
|
||||
</button>
|
||||
<OverflowMenu
|
||||
class="btn btn-danger btn-dropdown-animation icon-only"
|
||||
:options="[
|
||||
{
|
||||
id: 'withhold',
|
||||
color: 'danger',
|
||||
action: () => sendMessage('withheld'),
|
||||
hoverFilled: true,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<DropdownIcon style="rotate: 180deg" />
|
||||
<template #withhold> <EyeOffIcon aria-hidden="true" /> Withhold </template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</template>
|
||||
<button v-if="done" class="btn btn-primary next-project" @click="goToNextProject">
|
||||
Next project
|
||||
</button>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -337,8 +374,9 @@ import {
|
||||
XIcon as CrossIcon,
|
||||
EyeOffIcon,
|
||||
ExitIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { MarkdownEditor, OverflowMenu } from "@modrinth/ui";
|
||||
import { ButtonStyled, MarkdownEditor, OverflowMenu, Collapsible } from "@modrinth/ui";
|
||||
import Categories from "~/components/ui/search/Categories.vue";
|
||||
|
||||
const props = defineProps({
|
||||
@@ -355,8 +393,14 @@ const props = defineProps({
|
||||
required: true,
|
||||
default: () => {},
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["exit", "toggleCollapsed"]);
|
||||
|
||||
const steps = computed(() =>
|
||||
[
|
||||
{
|
||||
@@ -1008,6 +1052,20 @@ async function sendMessage(status) {
|
||||
|
||||
const router = useNativeRouter();
|
||||
|
||||
async function exitModeration() {
|
||||
await router.push({
|
||||
name: "type-id",
|
||||
params: {
|
||||
type: "project",
|
||||
id: props.project.id,
|
||||
},
|
||||
state: {
|
||||
showChecklist: false,
|
||||
},
|
||||
});
|
||||
emit("exit");
|
||||
}
|
||||
|
||||
async function goToNextProject() {
|
||||
const project = props.futureProjects[0];
|
||||
|
||||
@@ -1031,23 +1089,8 @@ async function goToNextProject() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
.moderation-checklist {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
left: 100vw;
|
||||
z-index: 100;
|
||||
border: 1px solid var(--color-bg-inverted);
|
||||
width: 600px;
|
||||
|
||||
.skip-btn {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.next-project {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.modpack-buttons {
|
||||
margin-top: 1rem;
|
||||
@media (prefers-reduced-motion) {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
.option-selected {
|
||||
|
||||
@@ -76,7 +76,12 @@ function pickLink() {
|
||||
subpageSelected.value = false;
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; 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;
|
||||
break;
|
||||
} else if (
|
||||
@@ -150,7 +155,7 @@ onMounted(() => {
|
||||
});
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => [route.path, route.query],
|
||||
() => pickLink(),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -19,13 +19,21 @@
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<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" />
|
||||
<div class="stacked">
|
||||
<span class="title">{{ report.user.username }}</span>
|
||||
<span>User</span>
|
||||
</div>
|
||||
</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 v-else-if="report.item_type === 'version'" class="item-info">
|
||||
<nuxt-link
|
||||
@@ -50,7 +58,7 @@
|
||||
</div>
|
||||
<div v-else class="item-info">
|
||||
<div class="backed-svg" :class="{ raised: raised }"><UnknownIcon /></div>
|
||||
<span>Unknown report type</span>
|
||||
<span>Unknown report type: {{ report.item_type }}</span>
|
||||
</div>
|
||||
<div class="report-type">
|
||||
<Badge v-if="report.closed" type="closed" />
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<template>
|
||||
<Chips v-if="false" v-model="viewMode" :items="['open', 'archived']" />
|
||||
<ReportInfo
|
||||
v-for="report in reports.filter(
|
||||
(x) =>
|
||||
@@ -17,7 +16,6 @@
|
||||
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
||||
</template>
|
||||
<script setup>
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
||||
import { addReportMessage } from "~/helpers/threads.js";
|
||||
|
||||
|
||||
@@ -106,6 +106,7 @@ const fetchSettings = async () => {
|
||||
initialSettings.value = settings as { interval: number; enabled: boolean };
|
||||
autoBackupEnabled.value = settings?.enabled ?? false;
|
||||
autoBackupInterval.value = settings?.interval || 6;
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error fetching backup settings:", error);
|
||||
addNotification({
|
||||
@@ -114,6 +115,7 @@ const fetchSettings = async () => {
|
||||
text: "Failed to load backup settings",
|
||||
type: "error",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
isLoadingSettings.value = false;
|
||||
}
|
||||
@@ -155,8 +157,10 @@ const saveSettings = async () => {
|
||||
|
||||
defineExpose({
|
||||
show: async () => {
|
||||
await fetchSettings();
|
||||
modal.value?.show();
|
||||
const success = await fetchSettings();
|
||||
if (success) {
|
||||
modal.value?.show();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -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,
|
||||
} from "@modrinth/assets";
|
||||
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 {
|
||||
UiServersIconsCogFolderIcon,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
|
||||
<header
|
||||
: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',
|
||||
]"
|
||||
data-pyro-files-state="browsing"
|
||||
@@ -76,25 +76,23 @@
|
||||
<UiServersTeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Sort files"
|
||||
aria-label="Filter view"
|
||||
:options="[
|
||||
{ id: 'normal', action: () => $emit('sort', 'default') },
|
||||
{ id: 'modified', action: () => $emit('sort', 'modified') },
|
||||
{ id: 'created', action: () => $emit('sort', 'created') },
|
||||
{ id: 'filesOnly', action: () => $emit('sort', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('sort', 'foldersOnly') },
|
||||
{ id: 'all', action: () => $emit('filter', 'all') },
|
||||
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
|
||||
]"
|
||||
>
|
||||
<span class="hidden whitespace-pre text-sm font-medium sm:block">
|
||||
{{ sortMethodLabel }}
|
||||
</span>
|
||||
<SortAscendingIcon aria-hidden="true" />
|
||||
<div class="flex items-center gap-1">
|
||||
<FilterIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<span class="hidden text-sm font-medium sm:block">
|
||||
{{ filterLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #normal> Alphabetical </template>
|
||||
<template #modified> Date modified </template>
|
||||
<template #created> Date created </template>
|
||||
<template #filesOnly> Files only </template>
|
||||
<template #foldersOnly> Folders only </template>
|
||||
<template #all>Show all</template>
|
||||
<template #filesOnly>Files only</template>
|
||||
<template #foldersOnly>Folders only</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 w-full text-sm sm:w-48">
|
||||
@@ -148,9 +146,9 @@ import {
|
||||
DropdownIcon,
|
||||
FolderOpenIcon,
|
||||
SearchIcon,
|
||||
SortAscendingIcon,
|
||||
HomeIcon,
|
||||
ChevronRightIcon,
|
||||
FilterIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed } from "vue";
|
||||
@@ -159,15 +157,15 @@ import { useIntersectionObserver } from "@vueuse/core";
|
||||
const props = defineProps<{
|
||||
breadcrumbSegments: string[];
|
||||
searchQuery: string;
|
||||
sortMethod: string;
|
||||
currentFilter: string;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: "navigate", index: number): void;
|
||||
(e: "sort", method: string): void;
|
||||
(e: "create", type: "file" | "directory"): void;
|
||||
(e: "upload"): void;
|
||||
(e: "update:searchQuery", value: string): void;
|
||||
(e: "filter", type: string): void;
|
||||
}>();
|
||||
|
||||
const pyroFilesSentinel = ref<HTMLElement | null>(null);
|
||||
@@ -181,18 +179,14 @@ useIntersectionObserver(
|
||||
{ threshold: [0, 1] },
|
||||
);
|
||||
|
||||
const sortMethodLabel = computed(() => {
|
||||
switch (props.sortMethod) {
|
||||
case "modified":
|
||||
return "Date modified";
|
||||
case "created":
|
||||
return "Date created";
|
||||
const filterLabel = computed(() => {
|
||||
switch (props.currentFilter) {
|
||||
case "filesOnly":
|
||||
return "Files only";
|
||||
case "foldersOnly":
|
||||
return "Folders only";
|
||||
default:
|
||||
return "Alphabetical";
|
||||
return "Show all";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<UiServersPyroLoading v-if="state.isLoading" />
|
||||
<div v-if="state.isLoading" />
|
||||
<div
|
||||
v-if="state.hasError"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||
|
||||
@@ -1,14 +1,65 @@
|
||||
<template>
|
||||
<div
|
||||
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>
|
||||
<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">
|
||||
<span class="hidden min-w-[160px] md:block">Created</span>
|
||||
<span class="mr-4 min-w-[160px]">Modified</span>
|
||||
<div class="min-w-[36px]"></div>
|
||||
<button
|
||||
class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
|
||||
@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>
|
||||
</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)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
@@ -28,6 +29,7 @@
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
@@ -47,6 +49,7 @@
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
@@ -60,6 +63,7 @@ const props = defineProps<{
|
||||
loader: string | null;
|
||||
loader_version: string | null;
|
||||
};
|
||||
isInstalling?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button @click="onSelect">
|
||||
<button :disabled="isInstalling" @click="onSelect">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader ? "Reinstall" : "Install" }}
|
||||
</button>
|
||||
@@ -52,6 +52,7 @@ interface Props {
|
||||
loader: LoaderInfo;
|
||||
currentLoader: string | null;
|
||||
loaderVersion: string | null;
|
||||
isInstalling?: boolean;
|
||||
}
|
||||
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ 0 Bytes</h3>
|
||||
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">0 B</h2>
|
||||
</div>
|
||||
<h3 class="relative z-10 text-base font-normal text-secondary">Storage usage</h3>
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
@@ -50,8 +49,12 @@
|
||||
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative w-full">
|
||||
<input type="text" placeholder="Search logs" class="h-12 !w-full !pl-10 !pr-48" />
|
||||
<SearchIcon class="absolute left-4 top-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
<div
|
||||
class="console relative h-full min-h-[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>
|
||||
@@ -59,7 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CPUIcon, DBIcon, FolderOpenIcon } from "@modrinth/assets";
|
||||
import { CPUIcon, DBIcon, FolderOpenIcon, SearchIcon } from "@modrinth/assets";
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<div class="contents">
|
||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="closePowerModal">
|
||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
|
||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||
<p class="m-0">Are you sure you want to {{ currentPendingAction }} the server?</p>
|
||||
|
||||
<p class="m-0">
|
||||
Are you sure you want to <span class="lowercase">{{ confirmActionText }}</span> the
|
||||
server?
|
||||
</p>
|
||||
<UiCheckbox
|
||||
v-model="powerDontAskAgainCheckbox"
|
||||
v-model="dontAskAgain"
|
||||
label="Don't ask me again"
|
||||
class="text-sm"
|
||||
:disabled="!currentPendingAction"
|
||||
:disabled="!powerAction"
|
||||
/>
|
||||
<div class="flex flex-row gap-4">
|
||||
<ButtonStyled type="standard" color="brand" @click="confirmAction">
|
||||
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
|
||||
<button>
|
||||
<CheckIcon class="h-5 w-5" />
|
||||
{{ currentPendingActionFriendly }} server
|
||||
{{ confirmActionText }} server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled @click="closePowerModal">
|
||||
<ButtonStyled @click="resetPowerAction">
|
||||
<button>
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
@@ -29,7 +31,7 @@
|
||||
|
||||
<NewModal
|
||||
ref="detailsModal"
|
||||
:header="`All of ${props.serverName ? props.serverName : 'Server'} info`"
|
||||
:header="`All of ${serverName || 'Server'} info`"
|
||||
@close="closeDetailsModal"
|
||||
>
|
||||
<UiServersServerInfoLabels
|
||||
@@ -51,75 +53,74 @@
|
||||
<UiServersPanelSpinner class="size-5" /> Installing...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div v-else class="contents">
|
||||
|
||||
<template v-else>
|
||||
<ButtonStyled v-if="showStopButton" type="transparent">
|
||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="stopServer">
|
||||
<button :disabled="!canTakeAction" @click="initiateAction('stop')">
|
||||
<div class="flex gap-1">
|
||||
<StopCircleIcon class="h-5 w-5" />
|
||||
<span>{{ stopButtonText }}</span>
|
||||
<span>{{ isStoppingState ? "Stopping..." : "Stop" }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button :disabled="!canTakeAction || disabled || isStopping" @click="handleAction">
|
||||
<div v-if="isStartingOrRestarting" class="grid place-content-center">
|
||||
<button :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||
<div v-if="isTransitionState" class="grid place-content-center">
|
||||
<UiServersIconsLoadingIcon />
|
||||
</div>
|
||||
<div v-else class="contents">
|
||||
<component :is="showRestartIcon ? UpdatedIcon : PlayIcon" />
|
||||
</div>
|
||||
<span>
|
||||
{{ actionButtonText }}
|
||||
</span>
|
||||
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
|
||||
<span>{{ primaryActionText }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<!-- Dropdown options -->
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu
|
||||
:options="[
|
||||
...(props.isInstalling ? [] : [{ id: 'kill', action: () => killServer() }]),
|
||||
{ id: 'allServers', action: () => router.push('/servers/manage') },
|
||||
{ id: 'details', action: () => showDetailsModal() },
|
||||
]"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #kill>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
<span>Kill server</span>
|
||||
</template>
|
||||
<template #allServers>
|
||||
<ServerIcon class="h-5 w-5" />
|
||||
<span>All servers</span>
|
||||
</template>
|
||||
<template #details>
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<UiServersTeleportOverflowMenu :options="[...menuOptions]">
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #kill>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
<span>Kill server</span>
|
||||
</template>
|
||||
<template #allServers>
|
||||
<ServerIcon class="h-5 w-5" />
|
||||
<span>All servers</span>
|
||||
</template>
|
||||
<template #details>
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
</UiServersTeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from "vue";
|
||||
import { ref, computed } from "vue";
|
||||
import {
|
||||
PlayIcon,
|
||||
UpdatedIcon,
|
||||
StopCircleIcon,
|
||||
SlashIcon,
|
||||
MoreVerticalIcon,
|
||||
XIcon,
|
||||
CheckIcon,
|
||||
ServerIcon,
|
||||
InfoIcon,
|
||||
MoreVerticalIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
type ServerAction = "start" | "stop" | "restart" | "kill";
|
||||
type ServerState = "stopped" | "starting" | "running" | "stopping" | "restarting";
|
||||
|
||||
interface PowerAction {
|
||||
action: ServerAction;
|
||||
nextState: ServerState;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isOnline: boolean;
|
||||
isActioning: boolean;
|
||||
@@ -130,183 +131,142 @@ const props = defineProps<{
|
||||
uptimeSeconds: number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: ServerAction): void;
|
||||
}>();
|
||||
|
||||
const router = useRouter();
|
||||
const serverId = router.currentRoute.value.params.id;
|
||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
powerDontAskAgain: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "action", action: "start" | "restart" | "stop" | "kill"): void;
|
||||
}>();
|
||||
const serverState = ref<ServerState>(props.isOnline ? "running" : "stopped");
|
||||
const powerAction = ref<PowerAction | null>(null);
|
||||
const dontAskAgain = ref(false);
|
||||
const startingDelay = ref(false);
|
||||
|
||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null);
|
||||
|
||||
const ServerState = {
|
||||
Stopped: "Stopped",
|
||||
Starting: "Starting",
|
||||
Running: "Running",
|
||||
Stopping: "Stopping",
|
||||
Restarting: "Restarting",
|
||||
} as const;
|
||||
|
||||
type ServerStateType = (typeof ServerState)[keyof typeof ServerState];
|
||||
|
||||
const currentPendingAction = ref<string | null>(null);
|
||||
const currentPendingState = ref<ServerStateType | null>(null);
|
||||
const powerDontAskAgainCheckbox = ref(false);
|
||||
|
||||
const currentState = ref<ServerStateType>(
|
||||
props.isOnline ? ServerState.Running : ServerState.Stopped,
|
||||
);
|
||||
|
||||
const isStartingDelay = ref(false);
|
||||
const showStopButton = computed(
|
||||
() => currentState.value === ServerState.Running || currentState.value === ServerState.Stopping,
|
||||
);
|
||||
const showRestartIcon = computed(() => currentState.value === ServerState.Running);
|
||||
const canTakeAction = computed(
|
||||
() =>
|
||||
!props.isActioning &&
|
||||
!isStartingDelay.value &&
|
||||
currentState.value !== ServerState.Starting &&
|
||||
currentState.value !== ServerState.Stopping,
|
||||
() => !props.isActioning && !startingDelay.value && !isTransitionState.value,
|
||||
);
|
||||
|
||||
const isStartingOrRestarting = computed(
|
||||
() =>
|
||||
currentState.value === ServerState.Starting || currentState.value === ServerState.Restarting,
|
||||
const isRunning = computed(() => serverState.value === "running");
|
||||
const isTransitionState = computed(() =>
|
||||
["starting", "stopping", "restarting"].includes(serverState.value),
|
||||
);
|
||||
const isStoppingState = computed(() => serverState.value === "stopping");
|
||||
const showStopButton = computed(() => isRunning.value || isStoppingState.value);
|
||||
|
||||
const isStopping = computed(() => currentState.value === ServerState.Stopping);
|
||||
|
||||
const actionButtonText = computed(() => {
|
||||
switch (currentState.value) {
|
||||
case ServerState.Starting:
|
||||
return "Starting...";
|
||||
case ServerState.Restarting:
|
||||
return "Restarting...";
|
||||
case ServerState.Running:
|
||||
return "Restart";
|
||||
case ServerState.Stopping:
|
||||
return "Stopping...";
|
||||
default:
|
||||
return "Start";
|
||||
}
|
||||
const primaryActionText = computed(() => {
|
||||
const states: Record<ServerState, string> = {
|
||||
starting: "Starting...",
|
||||
restarting: "Restarting...",
|
||||
running: "Restart",
|
||||
stopping: "Stopping...",
|
||||
stopped: "Start",
|
||||
};
|
||||
return states[serverState.value];
|
||||
});
|
||||
|
||||
const currentPendingActionFriendly = computed(() => {
|
||||
switch (currentPendingAction.value) {
|
||||
case "start":
|
||||
return "Start";
|
||||
case "restart":
|
||||
return "Restart";
|
||||
case "stop":
|
||||
return "Stop";
|
||||
case "kill":
|
||||
return "Kill";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
const confirmActionText = computed(() => {
|
||||
if (!powerAction.value) return "";
|
||||
return powerAction.value.action.charAt(0).toUpperCase() + powerAction.value.action.slice(1);
|
||||
});
|
||||
|
||||
const stopButtonText = computed(() =>
|
||||
currentState.value === ServerState.Stopping ? "Stopping..." : "Stop",
|
||||
);
|
||||
const menuOptions = computed(() => [
|
||||
...(props.isInstalling
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "kill",
|
||||
label: "Kill server",
|
||||
icon: SlashIcon,
|
||||
action: () => initiateAction("kill"),
|
||||
},
|
||||
]),
|
||||
{
|
||||
id: "allServers",
|
||||
label: "All servers",
|
||||
icon: ServerIcon,
|
||||
action: () => router.push("/servers/manage"),
|
||||
},
|
||||
{
|
||||
id: "details",
|
||||
label: "Details",
|
||||
icon: InfoIcon,
|
||||
action: () => detailsModal.value?.show(),
|
||||
},
|
||||
]);
|
||||
|
||||
const createPendingAction = () => {
|
||||
function initiateAction(action: ServerAction) {
|
||||
if (!canTakeAction.value) return;
|
||||
if (currentState.value === ServerState.Running) {
|
||||
currentPendingAction.value = "restart";
|
||||
currentPendingState.value = ServerState.Restarting;
|
||||
showPowerModal();
|
||||
} else {
|
||||
runAction("start", ServerState.Starting);
|
||||
|
||||
const stateMap: Record<ServerAction, ServerState> = {
|
||||
start: "starting",
|
||||
stop: "stopping",
|
||||
restart: "restarting",
|
||||
kill: "stopping",
|
||||
};
|
||||
|
||||
if (action === "start") {
|
||||
emit("action", action);
|
||||
serverState.value = stateMap[action];
|
||||
startingDelay.value = true;
|
||||
setTimeout(() => (startingDelay.value = false), 5000);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = () => {
|
||||
createPendingAction();
|
||||
};
|
||||
powerAction.value = { action, nextState: stateMap[action] };
|
||||
|
||||
const showPowerModal = () => {
|
||||
if (userPreferences.value.powerDontAskAgain) {
|
||||
runAction(
|
||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
||||
currentPendingState.value!,
|
||||
);
|
||||
executePowerAction();
|
||||
} else {
|
||||
confirmActionModal.value?.show();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const confirmAction = () => {
|
||||
if (powerDontAskAgainCheckbox.value) {
|
||||
function handlePrimaryAction() {
|
||||
initiateAction(isRunning.value ? "restart" : "start");
|
||||
}
|
||||
|
||||
function executePowerAction() {
|
||||
if (!powerAction.value) return;
|
||||
|
||||
const { action, nextState } = powerAction.value;
|
||||
emit("action", action);
|
||||
serverState.value = nextState;
|
||||
|
||||
if (dontAskAgain.value) {
|
||||
userPreferences.value.powerDontAskAgain = true;
|
||||
}
|
||||
runAction(
|
||||
currentPendingAction.value as "start" | "restart" | "stop" | "kill",
|
||||
currentPendingState.value!,
|
||||
);
|
||||
closePowerModal();
|
||||
};
|
||||
|
||||
const runAction = (action: "start" | "restart" | "stop" | "kill", serverState: ServerStateType) => {
|
||||
emit("action", action);
|
||||
currentState.value = serverState;
|
||||
|
||||
if (action === "start") {
|
||||
isStartingDelay.value = true;
|
||||
setTimeout(() => {
|
||||
isStartingDelay.value = false;
|
||||
}, 5000);
|
||||
startingDelay.value = true;
|
||||
setTimeout(() => (startingDelay.value = false), 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const stopServer = () => {
|
||||
if (!canTakeAction.value) return;
|
||||
currentPendingAction.value = "stop";
|
||||
currentPendingState.value = ServerState.Stopping;
|
||||
showPowerModal();
|
||||
};
|
||||
resetPowerAction();
|
||||
}
|
||||
|
||||
const killServer = () => {
|
||||
currentPendingAction.value = "kill";
|
||||
currentPendingState.value = ServerState.Stopping;
|
||||
showPowerModal();
|
||||
};
|
||||
|
||||
const closePowerModal = () => {
|
||||
function resetPowerAction() {
|
||||
confirmActionModal.value?.hide();
|
||||
currentPendingAction.value = null;
|
||||
powerDontAskAgainCheckbox.value = false;
|
||||
};
|
||||
powerAction.value = null;
|
||||
dontAskAgain.value = false;
|
||||
}
|
||||
|
||||
const closeDetailsModal = () => {
|
||||
function closeDetailsModal() {
|
||||
detailsModal.value?.hide();
|
||||
};
|
||||
|
||||
const showDetailsModal = () => {
|
||||
detailsModal.value?.show();
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.isOnline,
|
||||
(newValue) => {
|
||||
if (newValue) {
|
||||
currentState.value = ServerState.Running;
|
||||
} else {
|
||||
currentState.value = ServerState.Stopped;
|
||||
}
|
||||
},
|
||||
(online) => (serverState.value = online ? "running" : "stopped"),
|
||||
);
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value.fullPath,
|
||||
() => {
|
||||
closeDetailsModal();
|
||||
},
|
||||
() => closeDetailsModal(),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,66 +1,72 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-label="`Server is ${getStatusText}`"
|
||||
:aria-label="`Server is ${getStatusText(state)}`"
|
||||
class="relative inline-flex select-none items-center"
|
||||
@mouseenter="isExpanded = true"
|
||||
@mouseleave="isExpanded = false"
|
||||
>
|
||||
<div
|
||||
:class="`h-4 w-4 rounded-full transition-all duration-300 ease-in-out ${getStatusClass.main}`"
|
||||
:class="[
|
||||
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
|
||||
getStatusClass(state).main,
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="`absolute inline-flex h-4 w-4 animate-ping rounded-full ${getStatusClass.bg}`"
|
||||
:class="[
|
||||
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
|
||||
getStatusClass(state).bg,
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
:class="`absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out ${getStatusClass.bg} ${
|
||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0'
|
||||
}`"
|
||||
:class="[
|
||||
'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
|
||||
getStatusClass(state).bg,
|
||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
|
||||
]"
|
||||
>
|
||||
<div class="h-3 w-3 rounded-full"></div>
|
||||
<span
|
||||
class="origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out"
|
||||
:class="`${isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75'}`"
|
||||
:class="[
|
||||
'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
|
||||
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
|
||||
]"
|
||||
>
|
||||
{{ getStatusText }}
|
||||
{{ getStatusText(state) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { ref } from "vue";
|
||||
import type { ServerState } from "~/types/servers";
|
||||
|
||||
const props = defineProps<{
|
||||
const STATUS_CLASSES = {
|
||||
running: { main: "bg-brand", bg: "bg-bg-green" },
|
||||
stopped: { main: "", bg: "" },
|
||||
crashed: { main: "bg-brand-red", bg: "bg-bg-red" },
|
||||
unknown: { main: "", bg: "" },
|
||||
} as const;
|
||||
|
||||
const STATUS_TEXTS = {
|
||||
running: "Running",
|
||||
stopped: "",
|
||||
crashed: "Crashed",
|
||||
unknown: "Unknown",
|
||||
} as const;
|
||||
|
||||
defineProps<{
|
||||
state: ServerState;
|
||||
}>();
|
||||
|
||||
const isExpanded = ref(false);
|
||||
|
||||
const getStatusClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case "running":
|
||||
return { main: "bg-brand", bg: "bg-bg-green" };
|
||||
case "stopped":
|
||||
return { main: "", bg: "" };
|
||||
case "crashed":
|
||||
return { main: "bg-brand-red", bg: "bg-bg-red" };
|
||||
default:
|
||||
return { main: "", bg: "" };
|
||||
}
|
||||
});
|
||||
function getStatusClass(state: ServerState) {
|
||||
return STATUS_CLASSES[state] ?? STATUS_CLASSES.unknown;
|
||||
}
|
||||
|
||||
const getStatusText = computed(() => {
|
||||
switch (props.state) {
|
||||
case "running":
|
||||
return "Running";
|
||||
case "stopped":
|
||||
return "";
|
||||
case "crashed":
|
||||
return "Crashed";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
});
|
||||
function getStatusText(state: ServerState) {
|
||||
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown;
|
||||
}
|
||||
</script>
|
||||
|
||||
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;
|
||||
reset: () => void;
|
||||
isVisible: boolean;
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const saveAndRestart = async () => {
|
||||
|
||||
@@ -8,13 +8,19 @@
|
||||
<NuxtLink
|
||||
v-if="isLink"
|
||||
: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' : ''"
|
||||
>
|
||||
{{ 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>
|
||||
<div v-else class="min-w-0 truncate text-sm font-semibold">
|
||||
{{ game[0].toUpperCase() + game.slice(1) }} {{ mcVersion }}
|
||||
<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) }}
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -2,19 +2,18 @@
|
||||
<div>
|
||||
<UiServersServerGameLabel
|
||||
v-if="showGameLabel"
|
||||
:game="serverData.game!"
|
||||
:game="serverData.game"
|
||||
:mc-version="serverData.mc_version ?? ''"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<UiServersServerLoaderLabel
|
||||
v-if="showLoaderLabel"
|
||||
:loader="serverData.loader!"
|
||||
:loader="serverData.loader"
|
||||
:loader-version="serverData.loader_version ?? ''"
|
||||
:no-separator="column"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<UiServersServerSubdomainLabel
|
||||
v-if="serverData.net.domain"
|
||||
v-if="serverData.net?.domain"
|
||||
:subdomain="serverData.net.domain"
|
||||
:no-separator="column"
|
||||
:is-link="linked"
|
||||
|
||||
@@ -47,7 +47,6 @@
|
||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:show-subdomain-label="showSubdomainLabel"
|
||||
: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"
|
||||
/>
|
||||
@@ -70,11 +69,13 @@
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
class="relative -mt-4 flex w-full flex-col gap-2 rounded-b-3xl bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" />
|
||||
Your server has been suspended due to a billing issue. Please visit your billing settings or
|
||||
contact Modrinth Support for more information.
|
||||
<div class="flex flex-row gap-2">
|
||||
<UiServersIconsPanelErrorIcon class="!size-5" /> Your server has been suspended. Please
|
||||
update your billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<UiCopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
@@ -85,9 +86,12 @@ import type { Project, Server } from "~/types/servers";
|
||||
|
||||
const props = defineProps<Partial<Server>>();
|
||||
|
||||
if (props.server_id) {
|
||||
await usePyroServer(props.server_id, ["general"]);
|
||||
}
|
||||
|
||||
const showGameLabel = computed(() => !!props.game);
|
||||
const showLoaderLabel = computed(() => !!props.loader);
|
||||
const showSubdomainLabel = computed(() => !!props.net?.domain);
|
||||
|
||||
let projectData: Ref<Project | null>;
|
||||
if (props.upstream) {
|
||||
@@ -103,39 +107,11 @@ if (props.upstream) {
|
||||
projectData = ref(null);
|
||||
}
|
||||
|
||||
const image = ref<string | undefined>();
|
||||
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined);
|
||||
|
||||
onMounted(async () => {
|
||||
const auth = (await usePyroFetch(`servers/${props.server_id}/fs`)) as any;
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
if (import.meta.server && projectData.value?.icon_url) {
|
||||
await usePyroServer(props.server_id!, ["general"]);
|
||||
}
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined);
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="loader"
|
||||
v-tooltip="'Change server loader'"
|
||||
class="flex min-w-0 flex-row items-center gap-4 truncate"
|
||||
>
|
||||
<div 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 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
|
||||
v-if="isLink"
|
||||
: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' : ''"
|
||||
>
|
||||
{{ 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>
|
||||
<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>
|
||||
@@ -25,8 +36,8 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
noSeparator?: boolean;
|
||||
loader: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||
loaderVersion: string;
|
||||
loader?: "Fabric" | "Quilt" | "Forge" | "NeoForge" | "Paper" | "Spigot" | "Bukkit" | "Vanilla";
|
||||
loaderVersion?: string;
|
||||
isLink?: boolean;
|
||||
}>();
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ const emit = defineEmits(["reinstall"]);
|
||||
const props = defineProps<{
|
||||
navLinks: { label: string; href: string; icon: Component; external?: boolean }[];
|
||||
route: RouteLocationNormalized;
|
||||
server: Server<["general", "mods", "backups", "network", "startup", "ws", "fs"]>;
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
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"
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div
|
||||
class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1"
|
||||
:style="{
|
||||
backdropFilter: 'blur(6px)',
|
||||
}"
|
||||
>
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">
|
||||
{{ metric.value }}
|
||||
</h2>
|
||||
<h3 class="relative z-10 text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
|
||||
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
||||
<div class="relative z-10">
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">{{ metric.value }}</h2>
|
||||
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
|
||||
</div>
|
||||
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
|
||||
{{ metric.title }}
|
||||
<WarningIcon
|
||||
v-if="metric.warning"
|
||||
v-tooltip="metric.warning"
|
||||
class="size-5"
|
||||
:style="{ color: 'var(--color-orange)' }"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
<h3 class="relative z-10 flex items-center gap-2 text-base font-normal text-secondary">
|
||||
{{ 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 class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
|
||||
</div>
|
||||
|
||||
<component :is="metric.icon" class="absolute right-10 top-10 z-10" />
|
||||
<ClientOnly>
|
||||
<VueApexCharts
|
||||
v-if="
|
||||
metric.data.length && !(metric.title === 'Memory usage' && userPreferences.ramAsNumber)
|
||||
"
|
||||
ref="chart"
|
||||
v-if="metric.showGraph"
|
||||
type="area"
|
||||
height="142"
|
||||
:options="generateOptions(metric)"
|
||||
:series="[{ name: 'Chart', data: metric.data }]"
|
||||
class="chart chart-animation absolute bottom-0 left-0 right-0 w-full"
|
||||
:options="getChartOptions(metric.warning)"
|
||||
:series="[{ name: metric.title, data: metric.data }]"
|
||||
class="chart absolute bottom-0 left-0 right-0 w-full opacity-0"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
@@ -57,21 +47,17 @@
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
|
||||
{{ formatBytes(animatedStorageUsage) }}
|
||||
{{ formatBytes(stats.storage_usage_bytes) }}
|
||||
</h2>
|
||||
<!-- <h3 class="relative z-10 text-sm font-normal text-secondary">
|
||||
/ {{ formatBytes(props.data.current.storage_total_bytes) }}
|
||||
</h3> -->
|
||||
</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" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from "vue";
|
||||
import { ref, computed, shallowRef } from "vue";
|
||||
import { FolderOpenIcon, CPUIcon, DBIcon } from "@modrinth/assets";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import type { Stats } from "~/types/servers";
|
||||
@@ -79,252 +65,132 @@ import WarningIcon from "~/assets/images/utils/issues.svg?component";
|
||||
|
||||
const route = useNativeRoute();
|
||||
const serverId = route.params.id;
|
||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
ramAsNumber: false,
|
||||
autoRestart: false,
|
||||
backupWhileRunning: false,
|
||||
});
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import("vue3-apexcharts"));
|
||||
const props = defineProps<{ data: Stats }>();
|
||||
|
||||
const props = defineProps({
|
||||
data: {
|
||||
type: Object as PropType<Stats>,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
const stats = shallowRef(props.data.current);
|
||||
|
||||
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 units = ["Bytes", "KB", "MB", "GB", "TB"];
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 2) {
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
unit++;
|
||||
}
|
||||
|
||||
return `${Math.round(value * 100) / 100} ${units[unitIndex]}`;
|
||||
return `${Math.round(value * 10) / 10} ${units[unit]}`;
|
||||
};
|
||||
|
||||
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 => {
|
||||
let startTimestamp: number | null = null;
|
||||
const step = (timestamp: number) => {
|
||||
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);
|
||||
const updateGraphData = (arr: number[], newValue: number) => {
|
||||
arr.push(newValue);
|
||||
arr.shift();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
animateValue(0, props.data.current.storage_usage_bytes, 250);
|
||||
const metrics = computed(() => {
|
||||
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(
|
||||
() => props.data.current.storage_usage_bytes,
|
||||
(newValue, oldValue) => {
|
||||
animateValue(oldValue, newValue, 250);
|
||||
() => props.data.current,
|
||||
(newStats) => {
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
@keyframes chart-enter-animation {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
.chart {
|
||||
animation: fadeIn 0.2s ease-out 0.2s forwards;
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
width: calc(100% + 48px) !important;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chart-animation {
|
||||
opacity: 0;
|
||||
animation: chart-enter-animation 0.5s ease-out forwards;
|
||||
animation-delay: 1s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="subdomain"
|
||||
v-if="subdomain && !isHidden"
|
||||
v-tooltip="'Copy custom URL'"
|
||||
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
|
||||
>
|
||||
@@ -20,6 +20,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LinkIcon } from "@modrinth/assets";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
subdomain: string;
|
||||
noSeparator?: boolean;
|
||||
@@ -29,12 +31,18 @@ const copySubdomain = () => {
|
||||
navigator.clipboard.writeText(props.subdomain + ".modrinth.gg");
|
||||
addNotification({
|
||||
group: "servers",
|
||||
title: "Subdomain copied",
|
||||
text: "Your subdomain has been copied to your clipboard.",
|
||||
title: "Custom URL copied",
|
||||
text: "Your server's URL has been copied to your clipboard.",
|
||||
type: "success",
|
||||
});
|
||||
};
|
||||
|
||||
const route = useNativeRoute();
|
||||
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>
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
<div
|
||||
v-if="uptimeSeconds || uptimeSeconds !== 0"
|
||||
v-tooltip="`Online for ${verboseUptime}`"
|
||||
class="flex min-w-0 flex-row items-center gap-4"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-4"
|
||||
data-pyro-uptime
|
||||
>
|
||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||
|
||||
<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">
|
||||
{{ formattedUptime }}
|
||||
</time>
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
<template>
|
||||
<div
|
||||
ref="dropdown"
|
||||
data-pyro-dropdown
|
||||
tabindex="0"
|
||||
role="combobox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
class="relative inline-block h-9 w-full max-w-80"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@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"
|
||||
<div class="relative inline-block h-9 w-full max-w-80">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="dropdownVisible"
|
||||
:aria-controls="listboxId"
|
||||
:aria-labelledby="listboxId"
|
||||
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"
|
||||
:class="triggerClasses"
|
||||
@click="toggleDropdown"
|
||||
@keydown="handleTriggerKeyDown"
|
||||
>
|
||||
<span>{{ selectedOption }}</span>
|
||||
<DropdownIcon
|
||||
class="transition-transform duration-200 ease-in-out"
|
||||
:class="{ 'rotate-180': dropdownVisible }"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<Teleport to="#teleports">
|
||||
<transition
|
||||
@@ -35,27 +30,28 @@
|
||||
>
|
||||
<div
|
||||
v-if="dropdownVisible"
|
||||
:id="listboxId"
|
||||
ref="optionsContainer"
|
||||
data-pyro-dropdown-options
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
:aria-activedescendant="activeDescendant"
|
||||
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg outline-none"
|
||||
:class="{
|
||||
'rounded-b-xl': !isRenderingUp,
|
||||
'rounded-t-xl': isRenderingUp,
|
||||
}"
|
||||
:style="positionStyle"
|
||||
@keydown.stop="handleDropdownKeyDown"
|
||||
@keydown="handleListboxKeyDown"
|
||||
>
|
||||
<div
|
||||
class="overflow-y-auto"
|
||||
:style="{ height: `${virtualListHeight}px` }"
|
||||
data-pyro-dropdown-options-virtual-scroller
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
|
||||
<div
|
||||
v-for="item in visibleOptions"
|
||||
:key="item.index"
|
||||
data-pyro-dropdown-option
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -65,32 +61,20 @@
|
||||
}"
|
||||
>
|
||||
<div
|
||||
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
|
||||
:id="`${listboxId}-option-${item.index}`"
|
||||
role="option"
|
||||
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
|
||||
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"
|
||||
: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"
|
||||
:class="{
|
||||
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
|
||||
'bg-bg-raised': focusedOptionIndex === item.index,
|
||||
'rounded-b-xl': item.index === props.options.length - 1 && !isRenderingUp,
|
||||
'rounded-t-xl': item.index === 0 && isRenderingUp,
|
||||
}"
|
||||
:aria-selected="selectedValue === item.option"
|
||||
@click="selectOption(item.option, item.index)"
|
||||
@mouseover="focusedOptionIndex = item.index"
|
||||
@focus="focusedOptionIndex = item.index"
|
||||
@mousemove="focusedOptionIndex = item.index"
|
||||
>
|
||||
<input
|
||||
: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>
|
||||
{{ displayName(item.option) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,13 +124,14 @@ const emit = defineEmits<{
|
||||
const dropdownVisible = ref(false);
|
||||
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue);
|
||||
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 scrollTop = ref(0);
|
||||
const isRenderingUp = ref(false);
|
||||
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>({
|
||||
position: "fixed",
|
||||
@@ -156,41 +141,6 @@ const positionStyle = ref<CSSProperties>({
|
||||
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 visibleOptions = computed(() => {
|
||||
@@ -227,16 +177,16 @@ const radioValue = computed<OptionValue>({
|
||||
});
|
||||
|
||||
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-t-none": dropdownVisible.value && isRenderingUp.value && !props.disabled,
|
||||
}));
|
||||
|
||||
const updatePosition = async () => {
|
||||
if (!dropdown.value) return;
|
||||
if (!triggerRef.value) return;
|
||||
|
||||
await nextTick();
|
||||
const triggerRect = dropdown.value.getBoundingClientRect();
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
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 = () => {
|
||||
if (!props.disabled) {
|
||||
if (dropdownVisible.value) {
|
||||
@@ -300,61 +236,6 @@ const handleScroll = (event: Event) => {
|
||||
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 event = new CustomEvent("close-all-dropdowns");
|
||||
window.dispatchEvent(event);
|
||||
@@ -373,9 +254,6 @@ const focusNextOption = () => {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
|
||||
}
|
||||
scrollToFocused();
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
@@ -386,9 +264,6 @@ const focusPreviousOption = () => {
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
|
||||
}
|
||||
scrollToFocused();
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
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(() => {
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("scroll", handleResize, true);
|
||||
@@ -416,6 +404,10 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
window.addEventListener("close-all-dropdowns", closeDropdown);
|
||||
|
||||
if (selectedValue.value) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -427,7 +419,13 @@ onUnmounted(() => {
|
||||
}
|
||||
});
|
||||
window.removeEventListener("close-all-dropdowns", closeDropdown);
|
||||
lastFocusedElement.value = null;
|
||||
|
||||
if (isOpen.value) {
|
||||
openDropdownCount.value--;
|
||||
if (openDropdownCount.value === 0) {
|
||||
document.body.style.overflow = "";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -443,4 +441,19 @@ watch(dropdownVisible, async (newValue) => {
|
||||
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>
|
||||
|
||||
@@ -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,
|
||||
showVersionFilesInTable: false,
|
||||
showAdsWithPlus: false,
|
||||
alwaysShowChecklistAsPopup: true,
|
||||
|
||||
// Feature toggles
|
||||
projectTypesPrimaryNav: false,
|
||||
|
||||
@@ -10,19 +10,111 @@ interface PyroFetchOptions {
|
||||
url?: string;
|
||||
token?: string;
|
||||
};
|
||||
retry?: boolean;
|
||||
retry?: number | boolean;
|
||||
}
|
||||
|
||||
async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promise<T> {
|
||||
class PyroServerError extends Error {
|
||||
public readonly errors: Map<string, Error> = new Map();
|
||||
public readonly timestamp: number = Date.now();
|
||||
|
||||
constructor(message?: string) {
|
||||
super(message || "Multiple errors occurred");
|
||||
this.name = "PyroServerError";
|
||||
}
|
||||
|
||||
addError(module: string, error: Error) {
|
||||
this.errors.set(module, error);
|
||||
this.message = this.buildErrorMessage();
|
||||
}
|
||||
|
||||
hasErrors() {
|
||||
return this.errors.size > 0;
|
||||
}
|
||||
|
||||
private buildErrorMessage(): string {
|
||||
return Array.from(this.errors.entries())
|
||||
.map(([_module, error]) => error.message)
|
||||
.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
export class PyroServersFetchError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number,
|
||||
public readonly originalError?: Error,
|
||||
public readonly module?: string,
|
||||
) {
|
||||
let errorMessage = message;
|
||||
let method = "GET";
|
||||
let path = "";
|
||||
|
||||
if (originalError instanceof FetchError) {
|
||||
const matches = message.match(/\[([A-Z]+)\]\s+"([^"]+)":/);
|
||||
if (matches) {
|
||||
method = matches[1];
|
||||
path = matches[2].replace(/https?:\/\/[^/]+\/[^/]+\/v\d+\//, "");
|
||||
}
|
||||
|
||||
const statusMessage = (() => {
|
||||
if (!statusCode) return "Unknown Error";
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return "Bad Request";
|
||||
case 401:
|
||||
return "Unauthorized";
|
||||
case 403:
|
||||
return "Forbidden";
|
||||
case 404:
|
||||
return "Not Found";
|
||||
case 408:
|
||||
return "Request Timeout";
|
||||
case 429:
|
||||
return "Too Many Requests";
|
||||
case 500:
|
||||
return "Internal Server Error";
|
||||
case 502:
|
||||
return "Bad Gateway";
|
||||
case 503:
|
||||
return "Service Unavailable";
|
||||
case 504:
|
||||
return "Gateway Timeout";
|
||||
default:
|
||||
return `HTTP ${statusCode}`;
|
||||
}
|
||||
})();
|
||||
|
||||
errorMessage = `[${method}] ${statusMessage} (${statusCode}) while fetching ${path}${module ? ` in ${module}` : ""}`;
|
||||
} else {
|
||||
errorMessage = `${message}${statusCode ? ` (${statusCode})` : ""}${module ? ` in ${module}` : ""}`;
|
||||
}
|
||||
|
||||
super(errorMessage);
|
||||
this.name = "PyroServersFetchError";
|
||||
}
|
||||
}
|
||||
|
||||
async function PyroFetch<T>(
|
||||
path: string,
|
||||
options: PyroFetchOptions = {},
|
||||
module?: string,
|
||||
): Promise<T> {
|
||||
const config = useRuntimeConfig();
|
||||
const auth = await useAuth();
|
||||
const authToken = auth.value?.token;
|
||||
|
||||
if (!authToken) {
|
||||
throw new PyroFetchError("Cannot pyrofetch without auth", 10000);
|
||||
throw new PyroServersFetchError("Missing auth token", 401, undefined, module);
|
||||
}
|
||||
|
||||
const { method = "GET", contentType = "application/json", body, version = 0, override } = options;
|
||||
const {
|
||||
method = "GET",
|
||||
contentType = "application/json",
|
||||
body,
|
||||
version = 0,
|
||||
override,
|
||||
retry = method === "GET" ? 3 : 0,
|
||||
} = options;
|
||||
|
||||
const base = (import.meta.server ? config.pyroBaseUrl : config.public.pyroBaseUrl)?.replace(
|
||||
/\/$/,
|
||||
@@ -30,9 +122,11 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
||||
);
|
||||
|
||||
if (!base) {
|
||||
throw new PyroFetchError(
|
||||
"Cannot pyrofetch without base url. Make sure to set a PYRO_BASE_URL in environment variables",
|
||||
10001,
|
||||
throw new PyroServersFetchError(
|
||||
"Configuration error: Missing PYRO_BASE_URL",
|
||||
500,
|
||||
undefined,
|
||||
module,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,9 +134,7 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
||||
? `https://${override.url}/${path.replace(/^\//, "")}`
|
||||
: `${base}/modrinth/v${version}/${path.replace(/^\//, "")}`;
|
||||
|
||||
type HeadersRecord = Record<string, string>;
|
||||
|
||||
const headers: HeadersRecord = {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${override?.token ?? authToken}`,
|
||||
"Access-Control-Allow-Headers": "Authorization",
|
||||
"User-Agent": "Pyro/1.0 (https://pyro.host)",
|
||||
@@ -57,43 +149,47 @@ async function PyroFetch<T>(path: string, options: PyroFetchOptions = {}): Promi
|
||||
headers.Origin = window.location.origin;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||
timeout: 10000,
|
||||
retry: options.retry !== false ? (method === "GET" ? 3 : 0) : 0,
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error("[PyroServers/PyroFetch]:", error);
|
||||
if (error instanceof FetchError) {
|
||||
const statusCode = error.response?.status;
|
||||
const statusText = error.response?.statusText || "[no status text available]";
|
||||
const errorMessages: { [key: number]: string } = {
|
||||
400: "Bad Request",
|
||||
401: "Unauthorized",
|
||||
403: "Forbidden",
|
||||
404: "Not Found",
|
||||
405: "Method Not Allowed",
|
||||
429: "Too Many Requests",
|
||||
500: "Internal Server Error",
|
||||
502: "Bad Gateway",
|
||||
503: "Service Unavailable",
|
||||
};
|
||||
const message =
|
||||
statusCode && statusCode in errorMessages
|
||||
? errorMessages[statusCode]
|
||||
: `HTTP Error: ${statusCode || "[unhandled status code]"} ${statusText}`;
|
||||
throw new PyroFetchError(`[PyroServers/PyroFetch] ${message}`, statusCode, error);
|
||||
let attempts = 0;
|
||||
const maxAttempts = (typeof retry === "boolean" ? (retry ? 1 : 0) : retry) + 1;
|
||||
let lastError: Error | null = null;
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
attempts++;
|
||||
|
||||
if (error instanceof FetchError) {
|
||||
const statusCode = error.response?.status;
|
||||
const isRetryable = statusCode ? [408, 429, 500, 502, 503, 504].includes(statusCode) : true;
|
||||
|
||||
if (!isRetryable || attempts >= maxAttempts) {
|
||||
throw new PyroServersFetchError(error.message, statusCode, error, module);
|
||||
}
|
||||
|
||||
const delay = Math.min(1000 * Math.pow(2, attempts - 1) + Math.random() * 1000, 10000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new PyroServersFetchError(
|
||||
"Unexpected error during fetch operation",
|
||||
undefined,
|
||||
error as Error,
|
||||
module,
|
||||
);
|
||||
}
|
||||
throw new PyroFetchError(
|
||||
"[PyroServers/PyroFetch] An unexpected error occurred during the fetch operation.",
|
||||
undefined,
|
||||
error as Error,
|
||||
);
|
||||
}
|
||||
|
||||
throw lastError || new Error("Maximum retry attempts reached");
|
||||
}
|
||||
|
||||
const internalServerRefrence = ref<any>(null);
|
||||
@@ -252,7 +348,7 @@ export interface DirectoryResponse {
|
||||
current?: number;
|
||||
}
|
||||
|
||||
type ContentType = "Mod" | "Plugin";
|
||||
type ContentType = "mod" | "plugin";
|
||||
|
||||
const constructServerProperties = (properties: any): string => {
|
||||
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 image = ref<string | null>(null);
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
||||
try {
|
||||
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
retry: false,
|
||||
});
|
||||
const sharedImage = useState<string | undefined>(
|
||||
`server-icon-${internalServerRefrence.value.serverId}`,
|
||||
);
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
if (import.meta.client) {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(fileData);
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => {
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
internalServerRefrence.value.general.image = dataURL;
|
||||
image.value = dataURL;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
||||
console.log("[PYROSERVERS] No server icon found");
|
||||
} else {
|
||||
console.error(error);
|
||||
}
|
||||
if (sharedImage.value) {
|
||||
return sharedImage.value;
|
||||
}
|
||||
|
||||
if (image.value === null && iconUrl) {
|
||||
console.log("iconUrl", iconUrl);
|
||||
try {
|
||||
const auth = await PyroFetch<JWTAuth>(`servers/${internalServerRefrence.value.serverId}/fs`);
|
||||
try {
|
||||
const response = await fetch(iconUrl);
|
||||
const file = await response.blob();
|
||||
const originalfile = new File([file], "server-icon-original.png", {
|
||||
type: "image/png",
|
||||
const fileData = await PyroFetch(`/download?path=/server-icon-original.png`, {
|
||||
override: auth,
|
||||
retry: false,
|
||||
});
|
||||
if (import.meta.client) {
|
||||
const scaledFile = await new Promise<File>((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
ctx?.drawImage(img, 0, 0, 64, 64);
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const data = new File([blob], "server-icon.png", { type: "image/png" });
|
||||
resolve(data);
|
||||
} else {
|
||||
reject(new Error("Canvas toBlob failed"));
|
||||
}
|
||||
}, "image/png");
|
||||
};
|
||||
img.onerror = reject;
|
||||
});
|
||||
if (scaledFile) {
|
||||
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: scaledFile,
|
||||
override: auth,
|
||||
});
|
||||
|
||||
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: originalfile,
|
||||
override: auth,
|
||||
if (fileData instanceof Blob) {
|
||||
if (import.meta.client) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
sharedImage.value = dataURL;
|
||||
resolve(dataURL);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(fileData);
|
||||
});
|
||||
return dataURL;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 404) {
|
||||
console.log("[PYROSERVERS] No server icon found");
|
||||
} else {
|
||||
console.error(error);
|
||||
if (error instanceof PyroServersFetchError && error.statusCode === 404 && iconUrl) {
|
||||
try {
|
||||
const response = await fetch(iconUrl);
|
||||
if (!response.ok) throw new Error("Failed to fetch icon");
|
||||
const file = await response.blob();
|
||||
const originalFile = new File([file], "server-icon-original.png", { type: "image/png" });
|
||||
|
||||
if (import.meta.client) {
|
||||
const dataURL = await new Promise<string>((resolve) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
ctx?.drawImage(img, 0, 0, 64, 64);
|
||||
canvas.toBlob(async (blob) => {
|
||||
if (blob) {
|
||||
const scaledFile = new File([blob], "server-icon.png", { type: "image/png" });
|
||||
await PyroFetch(`/create?path=/server-icon.png&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: scaledFile,
|
||||
override: auth,
|
||||
});
|
||||
await PyroFetch(`/create?path=/server-icon-original.png&type=file`, {
|
||||
method: "POST",
|
||||
contentType: "application/octet-stream",
|
||||
body: originalFile,
|
||||
override: auth,
|
||||
});
|
||||
}
|
||||
}, "image/png");
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
sharedImage.value = dataURL;
|
||||
resolve(dataURL);
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
return dataURL;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process external icon:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to process server icon:", error);
|
||||
}
|
||||
return image.value;
|
||||
|
||||
sharedImage.value = undefined;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// ------------------ GENERAL ------------------ //
|
||||
@@ -519,8 +616,8 @@ const installContent = async (contentType: ContentType, projectId: string, versi
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
install_as: contentType,
|
||||
rinth_ids: { project_id: projectId, version_id: versionId },
|
||||
install_as: contentType,
|
||||
},
|
||||
});
|
||||
} 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 {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/deleteMod`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
install_as: contentType,
|
||||
path: contentId,
|
||||
path,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -544,15 +640,11 @@ const removeContent = async (contentType: ContentType, contentId: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const reinstallContent = async (
|
||||
contentType: ContentType,
|
||||
contentId: string,
|
||||
newContentId: string,
|
||||
) => {
|
||||
const reinstallContent = async (replace: string, projectId: string, versionId: string) => {
|
||||
try {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/${contentId}`, {
|
||||
method: "PUT",
|
||||
body: { install_as: contentType, version_id: newContentId },
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/mods/update`, {
|
||||
method: "POST",
|
||||
body: { replace, project_id: projectId, version_id: versionId },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error reinstalling mod:", error);
|
||||
@@ -564,10 +656,14 @@ const reinstallContent = async (
|
||||
|
||||
const createBackup = async (backupName: string) => {
|
||||
try {
|
||||
const response = (await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups`, {
|
||||
method: "POST",
|
||||
body: { name: backupName },
|
||||
})) as { id: string };
|
||||
const response = await PyroFetch<{ id: string }>(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups`,
|
||||
{
|
||||
method: "POST",
|
||||
body: { name: backupName },
|
||||
},
|
||||
);
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
return response.id;
|
||||
} catch (error) {
|
||||
console.error("Error creating backup:", error);
|
||||
@@ -581,6 +677,7 @@ const renameBackup = async (backupId: string, newName: string) => {
|
||||
method: "POST",
|
||||
body: { name: newName },
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error renaming backup:", error);
|
||||
throw error;
|
||||
@@ -592,6 +689,7 @@ const deleteBackup = async (backupId: string) => {
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error deleting backup:", error);
|
||||
throw error;
|
||||
@@ -606,6 +704,7 @@ const restoreBackup = async (backupId: string) => {
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error restoring backup:", error);
|
||||
throw error;
|
||||
@@ -644,12 +743,10 @@ const getAutoBackup = async () => {
|
||||
|
||||
const lockBackup = async (backupId: string) => {
|
||||
try {
|
||||
return await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/lock`, {
|
||||
method: "POST",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error locking backup:", error);
|
||||
throw error;
|
||||
@@ -658,14 +755,12 @@ const lockBackup = async (backupId: string) => {
|
||||
|
||||
const unlockBackup = async (backupId: string) => {
|
||||
try {
|
||||
return await PyroFetch(
|
||||
`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
await PyroFetch(`servers/${internalServerRefrence.value.serverId}/backups/${backupId}/unlock`, {
|
||||
method: "POST",
|
||||
});
|
||||
await internalServerRefrence.value.refresh(["backups"]);
|
||||
} catch (error) {
|
||||
console.error("Error locking backup:", error);
|
||||
console.error("Error unlocking backup:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -760,7 +855,7 @@ const retryWithAuth = async (requestFn: () => Promise<any>) => {
|
||||
try {
|
||||
return await requestFn();
|
||||
} catch (error) {
|
||||
if (error instanceof PyroFetchError && error.statusCode === 401) {
|
||||
if (error instanceof PyroServersFetchError && error.statusCode === 401) {
|
||||
await internalServerRefrence.value.refresh(["fs"]);
|
||||
return await requestFn();
|
||||
}
|
||||
@@ -947,17 +1042,18 @@ const modules: any = {
|
||||
general: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
const data = await PyroFetch<General>(`servers/${serverId}`);
|
||||
// TODO: temp hack to fix hydration error
|
||||
const data = await PyroFetch<General>(`servers/${serverId}`, {}, "general");
|
||||
if (data.upstream?.project_id) {
|
||||
const res = await $fetch(
|
||||
`https://api.modrinth.com/v2/project/${data.upstream.project_id}`,
|
||||
);
|
||||
data.project = res as Project;
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
data.image = (await processImage(data.project?.icon_url)) ?? undefined;
|
||||
}
|
||||
|
||||
const motd = await getMotd();
|
||||
if (motd === "A Minecraft Server") {
|
||||
await setMotd(
|
||||
@@ -967,8 +1063,19 @@ const modules: any = {
|
||||
data.motd = motd;
|
||||
return data;
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
status: "error",
|
||||
server_id: serverId,
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
updateName,
|
||||
@@ -978,21 +1085,27 @@ const modules: any = {
|
||||
suspend: suspendServer,
|
||||
getMotd,
|
||||
setMotd,
|
||||
fetchConfigFile,
|
||||
},
|
||||
content: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`);
|
||||
const mods = await PyroFetch<Mod[]>(`servers/${serverId}/mods`, {}, "content");
|
||||
return {
|
||||
data:
|
||||
internalServerRefrence.value.error === undefined
|
||||
? mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? ""))
|
||||
: [],
|
||||
data: mods.sort((a, b) => (a?.name ?? "").localeCompare(b?.name ?? "")),
|
||||
};
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
data: [],
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
install: installContent,
|
||||
@@ -1002,10 +1115,22 @@ const modules: any = {
|
||||
backups: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return { data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`) };
|
||||
return {
|
||||
data: await PyroFetch<Backup[]>(`servers/${serverId}/backups`, {}, "backups"),
|
||||
};
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
data: [],
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
create: createBackup,
|
||||
@@ -1021,10 +1146,26 @@ const modules: any = {
|
||||
network: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return { allocations: await PyroFetch<Allocation[]>(`servers/${serverId}/allocations`) };
|
||||
return {
|
||||
allocations: await PyroFetch<Allocation[]>(
|
||||
`servers/${serverId}/allocations`,
|
||||
{},
|
||||
"network",
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
allocations: [],
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
reserveAllocation,
|
||||
@@ -1036,10 +1177,19 @@ const modules: any = {
|
||||
startup: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return await PyroFetch<Startup>(`servers/${serverId}/startup`);
|
||||
return await PyroFetch<Startup>(`servers/${serverId}/startup`, {}, "startup");
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
update: updateStartupSettings,
|
||||
@@ -1047,20 +1197,39 @@ const modules: any = {
|
||||
ws: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`);
|
||||
return await PyroFetch<JWTAuth>(`servers/${serverId}/ws`, {}, "ws");
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
},
|
||||
fs: {
|
||||
get: async (serverId: string) => {
|
||||
try {
|
||||
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`) };
|
||||
return { auth: await PyroFetch<JWTAuth>(`servers/${serverId}/fs`, {}, "fs") };
|
||||
} catch (error) {
|
||||
internalServerRefrence.value.setError(error);
|
||||
return undefined;
|
||||
const fetchError =
|
||||
error instanceof PyroServersFetchError
|
||||
? error
|
||||
: new PyroServersFetchError("Unknown error occurred", undefined, error as Error);
|
||||
|
||||
return {
|
||||
auth: undefined,
|
||||
error: {
|
||||
error: fetchError,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
listDirContents,
|
||||
@@ -1136,8 +1305,7 @@ type GeneralFunctions = {
|
||||
setMotd: (motd: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* INTERNAL: Gets the config file of a server.
|
||||
* @param fileName - The name of the file.
|
||||
* @deprecated Use fs.downloadFile instead
|
||||
*/
|
||||
fetchConfigFile: (fileName: string) => Promise<any>;
|
||||
};
|
||||
@@ -1160,18 +1328,17 @@ type ContentFunctions = {
|
||||
|
||||
/**
|
||||
* Removes a mod from a server.
|
||||
* @param contentType - The type of content to remove.
|
||||
* @param contentId - The ID of the content.
|
||||
* @param path - The path of the mod file.
|
||||
*/
|
||||
remove: (contentType: ContentType, contentId: string) => Promise<void>;
|
||||
remove: (path: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Reinstalls a mod to a server.
|
||||
* @param contentType - The type of content to reinstall.
|
||||
* @param contentId - The ID of the content.
|
||||
* @param newContentId - The ID of the new version.
|
||||
* @param replace - The path of the mod to replace.
|
||||
* @param projectId - The ID of the content.
|
||||
* @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 = {
|
||||
@@ -1370,12 +1537,44 @@ type FSFunctions = {
|
||||
downloadFile: (path: string, raw?: boolean) => Promise<any>;
|
||||
};
|
||||
|
||||
type GeneralModule = General & GeneralFunctions;
|
||||
type ContentModule = { data: Mod[] } & ContentFunctions;
|
||||
type BackupsModule = { data: Backup[] } & BackupFunctions;
|
||||
type NetworkModule = { allocations: Allocation[] } & NetworkFunctions;
|
||||
type StartupModule = Startup & StartupFunctions;
|
||||
export type FSModule = { auth: JWTAuth } & FSFunctions;
|
||||
type ModuleError = {
|
||||
error: PyroServersFetchError;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
type GeneralModule = General &
|
||||
GeneralFunctions & {
|
||||
error?: ModuleError;
|
||||
};
|
||||
|
||||
type ContentModule = {
|
||||
data: Mod[];
|
||||
error?: ModuleError;
|
||||
} & ContentFunctions;
|
||||
|
||||
type BackupsModule = {
|
||||
data: Backup[];
|
||||
error?: ModuleError;
|
||||
} & BackupFunctions;
|
||||
|
||||
type NetworkModule = {
|
||||
allocations: Allocation[];
|
||||
error?: ModuleError;
|
||||
} & NetworkFunctions;
|
||||
|
||||
type StartupModule = Startup &
|
||||
StartupFunctions & {
|
||||
error?: ModuleError;
|
||||
};
|
||||
|
||||
type WSModule = JWTAuth & {
|
||||
error?: ModuleError;
|
||||
};
|
||||
|
||||
type FSModule = {
|
||||
auth: JWTAuth;
|
||||
error?: ModuleError;
|
||||
} & FSFunctions;
|
||||
|
||||
type ModulesMap = {
|
||||
general: GeneralModule;
|
||||
@@ -1383,7 +1582,7 @@ type ModulesMap = {
|
||||
backups: BackupsModule;
|
||||
network: NetworkModule;
|
||||
startup: StartupModule;
|
||||
ws: JWTAuth;
|
||||
ws: WSModule;
|
||||
fs: FSModule;
|
||||
};
|
||||
|
||||
@@ -1395,8 +1594,16 @@ export type Server<T extends avaliableModules> = {
|
||||
/**
|
||||
* Refreshes the included modules of the server
|
||||
* @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;
|
||||
error?: Error;
|
||||
serverId: string;
|
||||
@@ -1404,49 +1611,103 @@ export type Server<T extends avaliableModules> = {
|
||||
|
||||
export const usePyroServer = async (serverId: string, includedModules: avaliableModules) => {
|
||||
const server: Server<typeof includedModules> = reactive({
|
||||
refresh: async (refreshModules?: avaliableModules) => {
|
||||
const promises: Promise<void>[] = [];
|
||||
if (refreshModules) {
|
||||
for (const module of refreshModules) {
|
||||
promises.push(
|
||||
(async () => {
|
||||
const mods = modules[module];
|
||||
if (mods.get) {
|
||||
const data = await mods.get(serverId);
|
||||
server[module] = { ...server[module], ...data };
|
||||
}
|
||||
})(),
|
||||
);
|
||||
refresh: async (
|
||||
refreshModules?: avaliableModules,
|
||||
options?: {
|
||||
preserveConnection?: boolean;
|
||||
preserveInstallState?: boolean;
|
||||
},
|
||||
) => {
|
||||
if (server.general?.status === "installing" && !refreshModules) {
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
(async () => {
|
||||
const mods = modules[module];
|
||||
if (mods.get) {
|
||||
const data = await mods.get(serverId);
|
||||
server[module] = { ...server[module], ...data };
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.allSettled(modulePromises);
|
||||
|
||||
if (serverError.hasErrors()) {
|
||||
if (server.error && server.error instanceof PyroServerError) {
|
||||
serverError.errors.forEach((error, module) => {
|
||||
(server.error as PyroServerError).addError(module, error);
|
||||
});
|
||||
} else {
|
||||
server.setError(serverError);
|
||||
}
|
||||
}
|
||||
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) => {
|
||||
server.error = error;
|
||||
if (!server.error) {
|
||||
server.error = error;
|
||||
} else if (error instanceof PyroServerError) {
|
||||
if (!(server.error instanceof PyroServerError)) {
|
||||
const newError = new PyroServerError();
|
||||
newError.addError("previous", server.error);
|
||||
server.error = newError;
|
||||
}
|
||||
error.errors.forEach((err, module) => {
|
||||
(server.error as PyroServerError).addError(module, err);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
serverId,
|
||||
});
|
||||
|
||||
for (const module of includedModules) {
|
||||
const mods = modules[module];
|
||||
server[module] = mods;
|
||||
}
|
||||
const initialModules = includedModules.filter((module) => ["general", "ws"].includes(module));
|
||||
const deferredModules = includedModules.filter((module) => !["general", "ws"].includes(module));
|
||||
|
||||
initialModules.forEach((module) => {
|
||||
server[module] = modules[module];
|
||||
});
|
||||
|
||||
internalServerRefrence.value = server;
|
||||
await server.refresh(initialModules);
|
||||
|
||||
await server.refresh();
|
||||
if (deferredModules.length > 0) {
|
||||
await server.loadModules(deferredModules);
|
||||
}
|
||||
|
||||
return server as Server<typeof includedModules>;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,27 @@
|
||||
<div class="pointer-events-none absolute inset-0 z-[-1]">
|
||||
<div id="absolute-background-teleport" class="relative"></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
|
||||
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>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
:highlighted="
|
||||
@@ -231,14 +251,52 @@
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</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">
|
||||
<OverflowMenu
|
||||
v-if="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="createPopoutId"
|
||||
:dropdown-id="`${basePopoutId}-create`"
|
||||
aria-label="Create new..."
|
||||
:options="[
|
||||
{
|
||||
@@ -270,7 +328,7 @@
|
||||
</ButtonStyled>
|
||||
<OverflowMenu
|
||||
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"
|
||||
:options="userMenuOptions"
|
||||
>
|
||||
@@ -291,15 +349,22 @@
|
||||
</template>
|
||||
<template #revenue> <CurrencyIcon aria-hidden="true" /> Revenue </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>
|
||||
</OverflowMenu>
|
||||
<ButtonStyled v-else color="brand">
|
||||
<nuxt-link to="/auth/sign-in">
|
||||
<LogInIcon aria-hidden="true" />
|
||||
Sign in
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<template v-else>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link to="/auth/sign-in">
|
||||
<LogInIcon aria-hidden="true" />
|
||||
Sign in
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<nuxt-link v-tooltip="'Settings'" to="/settings">
|
||||
<SettingsIcon aria-label="Settings" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</header>
|
||||
<header class="mobile-navigation mobile-only">
|
||||
@@ -371,7 +436,7 @@
|
||||
class="iconified-button"
|
||||
to="/moderation"
|
||||
>
|
||||
<ModerationIcon aria-hidden="true" />
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.moderationLabel) }}
|
||||
</NuxtLink>
|
||||
<NuxtLink v-if="flags.developerMode" class="iconified-button" to="/flags">
|
||||
@@ -432,7 +497,7 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
<NotificationIcon aria-hidden="true" />
|
||||
<BellIcon aria-hidden="true" />
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
to="/dashboard"
|
||||
@@ -451,7 +516,7 @@
|
||||
>
|
||||
<template v-if="!auth.user">
|
||||
<HamburgerIcon v-if="!isMobileMenuOpen" aria-hidden="true" />
|
||||
<CrossIcon v-else aria-hidden="true" />
|
||||
<XIcon v-else aria-hidden="true" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<Avatar
|
||||
@@ -466,108 +531,102 @@
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main>
|
||||
<main class="min-h-[calc(100vh-4.5rem-310.59px)]">
|
||||
<ModalCreation v-if="auth.user" ref="modal_creation" />
|
||||
<CollectionCreateModal ref="modal_collection_creation" />
|
||||
<OrganizationCreateModal ref="modal_organization_creation" />
|
||||
<slot id="main" />
|
||||
</main>
|
||||
<footer>
|
||||
<div class="logo-info" role="region" aria-label="Modrinth information">
|
||||
<BrandTextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base mx-auto mb-4 lg:mx-0"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<p class="mb-4">
|
||||
<IntlFormatted :message-id="footerMessages.openSource">
|
||||
<template #github-link="{ children }">
|
||||
<a
|
||||
:target="$external()"
|
||||
href="https://github.com/modrinth"
|
||||
class="text-link"
|
||||
rel="noopener"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<p class="mb-4">
|
||||
{{ config.public.branch }}@<a
|
||||
:target="$external()"
|
||||
:href="
|
||||
'https://github.com/' +
|
||||
config.public.owner +
|
||||
'/' +
|
||||
config.public.slug +
|
||||
'/tree/' +
|
||||
config.public.hash
|
||||
"
|
||||
class="text-link"
|
||||
rel="noopener"
|
||||
>{{ config.public.hash.substring(0, 7) }}</a
|
||||
<footer
|
||||
class="footer-brand-background experimental-styles-within mt-6 border-0 border-t-[1px] border-solid"
|
||||
>
|
||||
<div class="mx-auto flex max-w-screen-xl flex-col gap-6 p-6 pb-12 sm:px-12 md:py-12">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 text-primary md:grid-cols-[1fr_2fr] lg:grid-cols-[auto_auto_auto_auto_auto]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 md:items-start"
|
||||
role="region"
|
||||
aria-label="Modrinth information"
|
||||
>
|
||||
</p>
|
||||
<p>© Rinth, Inc.</p>
|
||||
</div>
|
||||
<div class="links links-1" role="region" aria-label="Legal">
|
||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.companyTitle) }}</h4>
|
||||
<nuxt-link to="/legal/terms"> {{ formatMessage(footerMessages.terms) }}</nuxt-link>
|
||||
<nuxt-link to="/legal/privacy"> {{ formatMessage(footerMessages.privacy) }}</nuxt-link>
|
||||
<nuxt-link to="/legal/rules"> {{ formatMessage(footerMessages.rules) }}</nuxt-link>
|
||||
<a :target="$external()" href="https://careers.modrinth.com">
|
||||
{{ formatMessage(footerMessages.careers) }}
|
||||
<span v-if="false" class="count-bubble">0</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="links links-2" role="region" aria-label="Resources">
|
||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.resourcesTitle) }}</h4>
|
||||
<a :target="$external()" href="https://support.modrinth.com">
|
||||
{{ formatMessage(footerMessages.support) }}
|
||||
</a>
|
||||
<a :target="$external()" href="https://blog.modrinth.com">
|
||||
{{ formatMessage(footerMessages.blog) }}
|
||||
</a>
|
||||
<a :target="$external()" href="https://docs.modrinth.com">
|
||||
{{ formatMessage(footerMessages.docs) }}
|
||||
</a>
|
||||
<a :target="$external()" href="https://status.modrinth.com">
|
||||
{{ formatMessage(footerMessages.status) }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="links links-3" role="region" aria-label="Interact">
|
||||
<h4 aria-hidden="true">{{ formatMessage(footerMessages.interactTitle) }}</h4>
|
||||
<a rel="noopener" :target="$external()" href="https://discord.modrinth.com"> Discord </a>
|
||||
<a rel="noopener" :target="$external()" href="https://x.com/modrinth"> X (Twitter) </a>
|
||||
<a rel="noopener" :target="$external()" href="https://floss.social/@modrinth"> Mastodon </a>
|
||||
<a rel="noopener" :target="$external()" href="https://crowdin.com/project/modrinth">
|
||||
Crowdin
|
||||
</a>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<nuxt-link class="btn btn-outline btn-primary" to="/app">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.getModrinthApp) }}
|
||||
</nuxt-link>
|
||||
<button class="iconified-button raised-button" @click="changeTheme">
|
||||
<MoonIcon v-if="$theme.active === 'light'" aria-hidden="true" />
|
||||
<SunIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(messages.changeTheme) }}
|
||||
</button>
|
||||
<nuxt-link class="iconified-button raised-button" to="/settings">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="not-affiliated-notice">
|
||||
{{ formatMessage(footerMessages.legalDisclaimer) }}
|
||||
<BrandTextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
|
||||
<ButtonStyled
|
||||
v-for="(social, index) in socialLinks"
|
||||
:key="`footer-social-${index}`"
|
||||
circular
|
||||
type="transparent"
|
||||
>
|
||||
<a
|
||||
v-tooltip="social.label"
|
||||
:href="social.href"
|
||||
target="_blank"
|
||||
:rel="`noopener${social.rel ? ` ${social.rel}` : ''}`"
|
||||
>
|
||||
<component :is="social.icon" class="h-5 w-5" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="mt-auto flex flex-wrap justify-center gap-3 md:flex-col">
|
||||
<p class="m-0">
|
||||
<IntlFormatted :message-id="footerMessages.openSource">
|
||||
<template #github-link="{ children }">
|
||||
<a
|
||||
href="https://github.com/modrinth/code"
|
||||
class="text-brand hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<p class="m-0">© 2025 Rinth, Inc.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:contents">
|
||||
<div
|
||||
v-for="group in footerLinks"
|
||||
:key="group.label"
|
||||
class="flex flex-col items-center gap-3 sm:items-start"
|
||||
>
|
||||
<h3 class="m-0 text-base text-contrast">{{ group.label }}</h3>
|
||||
<template v-for="item in group.links" :key="item.label">
|
||||
<nuxt-link
|
||||
v-if="item.href.startsWith('/')"
|
||||
:to="item.href"
|
||||
class="w-fit hover:underline"
|
||||
>
|
||||
{{ item.label }}
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else
|
||||
:href="item.href"
|
||||
class="w-fit hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ item.label }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center text-center text-xs font-medium text-secondary opacity-50">
|
||||
{{ formatMessage(footerMessages.legalDisclaimer) }}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import {
|
||||
ModrinthIcon,
|
||||
ArrowBigUpDashIcon,
|
||||
BookmarkIcon,
|
||||
ServerIcon,
|
||||
@@ -599,12 +658,17 @@ import {
|
||||
GlassesIcon,
|
||||
PaintBrushIcon,
|
||||
PackageOpenIcon,
|
||||
XIcon as CrossIcon,
|
||||
ScaleIcon as ModerationIcon,
|
||||
BellIcon as NotificationIcon,
|
||||
DiscordIcon,
|
||||
BlueskyIcon,
|
||||
TumblrIcon,
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
GitHubIcon,
|
||||
ScaleIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { Button, ButtonStyled, OverflowMenu, Avatar, commonMessages } from "@modrinth/ui";
|
||||
|
||||
import { isAdmin, isStaff } from "@modrinth/utils";
|
||||
import ModalCreation from "~/components/ui/ModalCreation.vue";
|
||||
import { getProjectTypeMessage } from "~/utils/i18n-project-type.ts";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
@@ -622,10 +686,10 @@ const flags = useFeatureFlags();
|
||||
|
||||
const config = useRuntimeConfig();
|
||||
const route = useNativeRoute();
|
||||
const router = useNativeRouter();
|
||||
const link = config.public.siteUrl + route.path.replace(/\/+$/, "");
|
||||
|
||||
const createPopoutId = useId();
|
||||
const userPopoutId = useId();
|
||||
const basePopoutId = useId();
|
||||
|
||||
const verifyEmailBannerMessages = defineMessages({
|
||||
title: {
|
||||
@@ -708,50 +772,6 @@ const footerMessages = defineMessages({
|
||||
id: "layout.footer.open-source",
|
||||
defaultMessage: "Modrinth is <github-link>open source</github-link>.",
|
||||
},
|
||||
companyTitle: {
|
||||
id: "layout.footer.company.title",
|
||||
defaultMessage: "Company",
|
||||
},
|
||||
terms: {
|
||||
id: "layout.footer.company.terms",
|
||||
defaultMessage: "Terms",
|
||||
},
|
||||
privacy: {
|
||||
id: "layout.footer.company.privacy",
|
||||
defaultMessage: "Privacy",
|
||||
},
|
||||
rules: {
|
||||
id: "layout.footer.company.rules",
|
||||
defaultMessage: "Rules",
|
||||
},
|
||||
careers: {
|
||||
id: "layout.footer.company.careers",
|
||||
defaultMessage: "Careers",
|
||||
},
|
||||
resourcesTitle: {
|
||||
id: "layout.footer.resources.title",
|
||||
defaultMessage: "Resources",
|
||||
},
|
||||
support: {
|
||||
id: "layout.footer.resources.support",
|
||||
defaultMessage: "Support",
|
||||
},
|
||||
blog: {
|
||||
id: "layout.footer.resources.blog",
|
||||
defaultMessage: "Blog",
|
||||
},
|
||||
docs: {
|
||||
id: "layout.footer.resources.docs",
|
||||
defaultMessage: "Docs",
|
||||
},
|
||||
status: {
|
||||
id: "layout.footer.resources.status",
|
||||
defaultMessage: "Status",
|
||||
},
|
||||
interactTitle: {
|
||||
id: "layout.footer.interact.title",
|
||||
defaultMessage: "Interact",
|
||||
},
|
||||
legalDisclaimer: {
|
||||
id: "layout.footer.legal-disclaimer",
|
||||
defaultMessage:
|
||||
@@ -928,12 +948,57 @@ const isDiscoveringSubpage = computed(
|
||||
() => 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(() => {
|
||||
if (window && import.meta.client) {
|
||||
window.history.scrollRestoration = "auto";
|
||||
}
|
||||
|
||||
runAnalytics();
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("keyup", onKeyUp);
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -1023,6 +1088,194 @@ const { cycle: changeTheme } = useTheme();
|
||||
function hideStagingBanner() {
|
||||
cosmetics.value.hideStagingBanner = true;
|
||||
}
|
||||
|
||||
const socialLinks = [
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.discord", defaultMessage: "Discord" }),
|
||||
),
|
||||
href: "https://discord.modrinth.com",
|
||||
icon: DiscordIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.bluesky", defaultMessage: "Bluesky" }),
|
||||
),
|
||||
href: "https://bsky.app/profile/modrinth.com",
|
||||
icon: BlueskyIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.mastodon", defaultMessage: "Mastodon" }),
|
||||
),
|
||||
href: "https://floss.social/@modrinth",
|
||||
icon: MastodonIcon,
|
||||
rel: "me",
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
|
||||
),
|
||||
href: "https://tumblr.com/modrinth",
|
||||
icon: TumblrIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
||||
href: "https://x.com/modrinth",
|
||||
icon: TwitterIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.github", defaultMessage: "GitHub" }),
|
||||
),
|
||||
href: "https://github.com/modrinth",
|
||||
icon: GitHubIcon,
|
||||
},
|
||||
];
|
||||
|
||||
const footerLinks = [
|
||||
{
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.about", defaultMessage: "About" })),
|
||||
links: [
|
||||
{
|
||||
href: "https://blog.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.blog", defaultMessage: "Blog" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/news/changelog",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.changelog", defaultMessage: "Changelog" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://status.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.status", defaultMessage: "Status" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://careers.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.about.careers", defaultMessage: "Careers" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/cmp-info",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.about.rewards-program",
|
||||
defaultMessage: "Rewards Program",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.products", defaultMessage: "Products" }),
|
||||
),
|
||||
links: [
|
||||
{
|
||||
href: "/plus",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.products.plus", defaultMessage: "Modrinth+" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/app",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.products.app", defaultMessage: "Modrinth App" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/servers",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.products.servers",
|
||||
defaultMessage: "Modrinth Servers",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.resources", defaultMessage: "Resources" }),
|
||||
),
|
||||
links: [
|
||||
{
|
||||
href: "https://support.modrinth.com",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.resources.help-center",
|
||||
defaultMessage: "Help Center",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://crowdin.com/project/modrinth",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.resources.translate", defaultMessage: "Translate" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://github.com/modrinth/code/issues",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.resources.report-issues",
|
||||
defaultMessage: "Report issues",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://docs.modrinth.com/api/",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.resources.api-docs",
|
||||
defaultMessage: "API documentation",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.legal", defaultMessage: "Legal" })),
|
||||
links: [
|
||||
{
|
||||
href: "/legal/rules",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.legal.rules", defaultMessage: "Content Rules" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/terms",
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.legal.terms-of-use", defaultMessage: "Terms of Use" }),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/privacy",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.legal.privacy-policy",
|
||||
defaultMessage: "Privacy Policy",
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/security",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.legal.security-notice",
|
||||
defaultMessage: "Security Notice",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
@@ -1037,127 +1290,9 @@ function hideStagingBanner() {
|
||||
min-height: calc(100vh - var(--spacing-card-bg));
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
margin-bottom: calc(var(--size-mobile-navbar-height) + 2rem);
|
||||
}
|
||||
|
||||
main {
|
||||
grid-area: main;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin: 6rem 0 2rem 0;
|
||||
text-align: center;
|
||||
display: grid;
|
||||
grid-template:
|
||||
"logo-info logo-info logo-info" auto
|
||||
"links-1 links-2 links-3" auto
|
||||
"buttons buttons buttons" auto
|
||||
"notice notice notice" auto
|
||||
/ 1fr 1fr 1fr;
|
||||
max-width: 1280px;
|
||||
|
||||
.logo-info {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 15rem;
|
||||
margin-bottom: 1rem;
|
||||
grid-area: logo-info;
|
||||
|
||||
.text-logo {
|
||||
width: 10rem;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 1rem;
|
||||
|
||||
h4 {
|
||||
color: var(--color-text-dark);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
a {
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
&.links-1 {
|
||||
grid-area: links-1;
|
||||
}
|
||||
|
||||
&.links-2 {
|
||||
grid-area: links-2;
|
||||
}
|
||||
|
||||
&.links-3 {
|
||||
grid-area: links-3;
|
||||
}
|
||||
|
||||
.count-bubble {
|
||||
font-size: 1rem;
|
||||
border-radius: 5rem;
|
||||
background: var(--color-brand);
|
||||
color: var(--color-text-inverted);
|
||||
padding: 0 0.35rem;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
grid-area: buttons;
|
||||
|
||||
button,
|
||||
a {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.not-affiliated-notice {
|
||||
grid-area: notice;
|
||||
font-size: var(--font-size-xs);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
margin-top: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1024px) {
|
||||
display: grid;
|
||||
margin-inline: auto;
|
||||
grid-template:
|
||||
"logo-info links-1 links-2 links-3 buttons" auto
|
||||
"notice notice notice notice notice" auto;
|
||||
text-align: unset;
|
||||
|
||||
.logo-info {
|
||||
margin-right: 4rem;
|
||||
}
|
||||
|
||||
.links {
|
||||
margin-right: 4rem;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
width: unset;
|
||||
margin-left: 0;
|
||||
|
||||
button,
|
||||
a {
|
||||
margin-right: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.not-affiliated-notice {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -1444,9 +1579,120 @@ function hideStagingBanner() {
|
||||
.mobile-navigation {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
main {
|
||||
padding-top: 1.5rem;
|
||||
.footer-brand-background {
|
||||
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>
|
||||
|
||||
@@ -287,45 +287,90 @@
|
||||
"layout.banner.verify-email.title": {
|
||||
"message": "For security purposes, please verify your email address on Modrinth."
|
||||
},
|
||||
"layout.footer.company.careers": {
|
||||
"layout.footer.about": {
|
||||
"message": "About"
|
||||
},
|
||||
"layout.footer.about.blog": {
|
||||
"message": "Blog"
|
||||
},
|
||||
"layout.footer.about.careers": {
|
||||
"message": "Careers"
|
||||
},
|
||||
"layout.footer.company.privacy": {
|
||||
"message": "Privacy"
|
||||
"layout.footer.about.changelog": {
|
||||
"message": "Changelog"
|
||||
},
|
||||
"layout.footer.company.rules": {
|
||||
"message": "Rules"
|
||||
"layout.footer.about.rewards-program": {
|
||||
"message": "Rewards Program"
|
||||
},
|
||||
"layout.footer.company.terms": {
|
||||
"message": "Terms"
|
||||
"layout.footer.about.status": {
|
||||
"message": "Status"
|
||||
},
|
||||
"layout.footer.company.title": {
|
||||
"message": "Company"
|
||||
},
|
||||
"layout.footer.interact.title": {
|
||||
"message": "Interact"
|
||||
"layout.footer.legal": {
|
||||
"message": "Legal"
|
||||
},
|
||||
"layout.footer.legal-disclaimer": {
|
||||
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
||||
},
|
||||
"layout.footer.legal.privacy-policy": {
|
||||
"message": "Privacy Policy"
|
||||
},
|
||||
"layout.footer.legal.rules": {
|
||||
"message": "Content Rules"
|
||||
},
|
||||
"layout.footer.legal.security-notice": {
|
||||
"message": "Security Notice"
|
||||
},
|
||||
"layout.footer.legal.terms-of-use": {
|
||||
"message": "Terms of Use"
|
||||
},
|
||||
"layout.footer.open-source": {
|
||||
"message": "Modrinth is <github-link>open source</github-link>."
|
||||
},
|
||||
"layout.footer.resources.blog": {
|
||||
"message": "Blog"
|
||||
"layout.footer.products": {
|
||||
"message": "Products"
|
||||
},
|
||||
"layout.footer.resources.docs": {
|
||||
"message": "Docs"
|
||||
"layout.footer.products.app": {
|
||||
"message": "Modrinth App"
|
||||
},
|
||||
"layout.footer.resources.status": {
|
||||
"message": "Status"
|
||||
"layout.footer.products.plus": {
|
||||
"message": "Modrinth+"
|
||||
},
|
||||
"layout.footer.resources.support": {
|
||||
"message": "Support"
|
||||
"layout.footer.products.servers": {
|
||||
"message": "Modrinth Servers"
|
||||
},
|
||||
"layout.footer.resources.title": {
|
||||
"layout.footer.resources": {
|
||||
"message": "Resources"
|
||||
},
|
||||
"layout.footer.resources.api-docs": {
|
||||
"message": "API documentation"
|
||||
},
|
||||
"layout.footer.resources.help-center": {
|
||||
"message": "Help Center"
|
||||
},
|
||||
"layout.footer.resources.report-issues": {
|
||||
"message": "Report issues"
|
||||
},
|
||||
"layout.footer.resources.translate": {
|
||||
"message": "Translate"
|
||||
},
|
||||
"layout.footer.social.bluesky": {
|
||||
"message": "Bluesky"
|
||||
},
|
||||
"layout.footer.social.discord": {
|
||||
"message": "Discord"
|
||||
},
|
||||
"layout.footer.social.github": {
|
||||
"message": "GitHub"
|
||||
},
|
||||
"layout.footer.social.mastodon": {
|
||||
"message": "Mastodon"
|
||||
},
|
||||
"layout.footer.social.tumblr": {
|
||||
"message": "Tumblr"
|
||||
},
|
||||
"layout.footer.social.x": {
|
||||
"message": "X"
|
||||
},
|
||||
"layout.menu-toggle.action": {
|
||||
"message": "Toggle menu"
|
||||
},
|
||||
@@ -521,6 +566,9 @@
|
||||
"report.not-for.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": {
|
||||
"message": "DMCA takedowns"
|
||||
},
|
||||
|
||||
@@ -460,6 +460,10 @@
|
||||
class="new-page sidebar"
|
||||
:class="{
|
||||
'alt-layout': cosmetics.leftContentLayout,
|
||||
'ultimate-sidebar':
|
||||
showModerationChecklist &&
|
||||
!collapsedModerationChecklist &&
|
||||
!flags.alwaysShowChecklistAsPopup,
|
||||
}"
|
||||
>
|
||||
<div class="normal-page__header relative my-4">
|
||||
@@ -674,7 +678,7 @@
|
||||
:auth="auth"
|
||||
:tags="tags"
|
||||
/>
|
||||
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="mb-4">
|
||||
<MessageBanner v-if="project.status === 'archived'" message-type="warning" class="my-4">
|
||||
{{ project.title }} has been archived. {{ project.title }} will not receive any further
|
||||
updates unless the author decides to unarchive the project.
|
||||
</MessageBanner>
|
||||
@@ -805,13 +809,18 @@
|
||||
@delete-version="deleteVersion"
|
||||
/>
|
||||
</div>
|
||||
<div class="normal-page__ultimate-sidebar">
|
||||
<ModerationChecklist
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
:project="project"
|
||||
:future-projects="futureProjects"
|
||||
:reset-project="resetProject"
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ModerationChecklist
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
:project="project"
|
||||
:future-projects="futureProjects"
|
||||
:reset-project="resetProject"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
@@ -1431,6 +1440,7 @@ async function copyId() {
|
||||
const collapsedChecklist = ref(false);
|
||||
|
||||
const showModerationChecklist = ref(false);
|
||||
const collapsedModerationChecklist = ref(false);
|
||||
const futureProjects = ref([]);
|
||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||
showModerationChecklist.value = true;
|
||||
|
||||
@@ -8,21 +8,25 @@
|
||||
<span class="label__subdescription">
|
||||
The description must clearly and honestly describe the purpose and function of the
|
||||
project. See section 2.1 of the
|
||||
<nuxt-link to="/legal/rules" class="text-link" target="_blank">Content Rules</nuxt-link>
|
||||
<nuxt-link class="text-link" target="_blank" to="/legal/rules">Content Rules</nuxt-link>
|
||||
for the full requirements.
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<MarkdownEditor
|
||||
v-model="description"
|
||||
:disabled="
|
||||
!currentMember ||
|
||||
(currentMember.permissions & TeamMemberPermission.EDIT_BODY) !==
|
||||
TeamMemberPermission.EDIT_BODY
|
||||
"
|
||||
:on-image-upload="onUploadHandler"
|
||||
:disabled="(currentMember.permissions & EDIT_BODY) !== EDIT_BODY"
|
||||
/>
|
||||
<div class="input-group markdown-disclaimer">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges"
|
||||
class="iconified-button brand-button"
|
||||
type="button"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
@@ -33,91 +37,50 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts" setup>
|
||||
import { SaveIcon } from "@modrinth/assets";
|
||||
import { MarkdownEditor } from "@modrinth/ui";
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import SaveIcon from "~/assets/images/utils/save.svg?component";
|
||||
import { renderHighlightedString } from "~/helpers/highlight.js";
|
||||
import { type Project, type TeamMember, TeamMemberPermission } from "@modrinth/utils";
|
||||
import { computed, ref } from "vue";
|
||||
import { useImageUpload } from "~/composables/image-upload.ts";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Chips,
|
||||
SaveIcon,
|
||||
MarkdownEditor,
|
||||
},
|
||||
props: {
|
||||
project: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
allMembers: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
currentMember: {
|
||||
type: Object,
|
||||
default() {
|
||||
return null;
|
||||
},
|
||||
},
|
||||
patchProject: {
|
||||
type: Function,
|
||||
default() {
|
||||
return () => {
|
||||
this.$notify({
|
||||
group: "main",
|
||||
title: "An error occurred",
|
||||
text: "Patch project function not found",
|
||||
type: "error",
|
||||
});
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
description: this.project.body,
|
||||
bodyViewMode: "source",
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
patchData() {
|
||||
const data = {};
|
||||
const props = defineProps<{
|
||||
project: Project;
|
||||
allMembers: TeamMember[];
|
||||
currentMember: TeamMember | undefined;
|
||||
patchProject: (payload: object, quiet?: boolean) => object;
|
||||
}>();
|
||||
|
||||
if (this.description !== this.project.body) {
|
||||
data.body = this.description;
|
||||
}
|
||||
const description = ref(props.project.body);
|
||||
|
||||
return data;
|
||||
},
|
||||
hasChanges() {
|
||||
return Object.keys(this.patchData).length > 0;
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.EDIT_BODY = 1 << 3;
|
||||
},
|
||||
methods: {
|
||||
renderHighlightedString,
|
||||
saveChanges() {
|
||||
if (this.hasChanges) {
|
||||
this.patchProject(this.patchData);
|
||||
}
|
||||
},
|
||||
async onUploadHandler(file) {
|
||||
const response = await useImageUpload(file, {
|
||||
context: "project",
|
||||
projectID: this.project.id,
|
||||
});
|
||||
return response.url;
|
||||
},
|
||||
},
|
||||
const patchRequestPayload = computed(() => {
|
||||
const payload: {
|
||||
body?: string;
|
||||
} = {};
|
||||
|
||||
if (description.value !== props.project.body) {
|
||||
payload.body = description.value;
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
return Object.keys(patchRequestPayload.value).length > 0;
|
||||
});
|
||||
|
||||
function saveChanges() {
|
||||
props.patchProject(patchRequestPayload.value);
|
||||
}
|
||||
|
||||
async function onUploadHandler(file: File) {
|
||||
const response = await useImageUpload(file, {
|
||||
context: "project",
|
||||
projectID: props.project.id,
|
||||
});
|
||||
|
||||
return response.url;
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,61 +1,128 @@
|
||||
<template>
|
||||
<div>
|
||||
<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">
|
||||
<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">
|
||||
It is very 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.
|
||||
<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>
|
||||
How users are and aren't allowed to use your project.
|
||||
</span>
|
||||
</label>
|
||||
<div class="input-stack">
|
||||
<Multiselect
|
||||
id="license-multiselect"
|
||||
|
||||
<div class="w-1/2">
|
||||
<DropdownSelect
|
||||
v-model="license"
|
||||
name="License selector"
|
||||
:options="builtinLicenses"
|
||||
:display-name="(chosen: BuiltinLicense) => chosen.friendly"
|
||||
placeholder="Select license..."
|
||||
track-by="short"
|
||||
label="friendly"
|
||||
:options="defaultLicenses"
|
||||
:searchable="true"
|
||||
:close-on-select="true"
|
||||
:show-labels="false"
|
||||
:class="{
|
||||
'known-error': license?.short === '' && showKnownErrors,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="adjacent-input" v-if="license.requiresOnlyOrLater">
|
||||
<label for="or-later-checkbox">
|
||||
<span class="label__title">Later editions</span>
|
||||
<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"
|
||||
/>
|
||||
<Checkbox
|
||||
v-if="license?.requiresOnlyOrLater"
|
||||
v-model="allowOrLater"
|
||||
<input
|
||||
v-else
|
||||
v-model="license.short"
|
||||
id="license-name"
|
||||
class="w-full"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
placeholder="License name"
|
||||
:disabled="!hasPermission"
|
||||
description="Allow later editions of this license"
|
||||
>
|
||||
Allow later editions of this license
|
||||
</Checkbox>
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
v-if="license?.friendly === 'Custom'"
|
||||
v-model="nonSpdxLicense"
|
||||
@@ -64,31 +131,18 @@
|
||||
>
|
||||
License does not have a SPDX identifier
|
||||
</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 class="input-stack">
|
||||
<button
|
||||
type="button"
|
||||
class="iconified-button brand-button"
|
||||
:disabled="!hasChanges || license === null"
|
||||
:disabled="
|
||||
!hasChanges ||
|
||||
!hasPermission ||
|
||||
(license.friendly === 'Custom' && (license.short === '' || licenseUrl === ''))
|
||||
"
|
||||
@click="saveChanges()"
|
||||
>
|
||||
<SaveIcon />
|
||||
@@ -99,199 +153,109 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Multiselect from "vue-multiselect";
|
||||
import Checkbox from "~/components/ui/Checkbox";
|
||||
<script setup lang="ts">
|
||||
import { Checkbox, DropdownSelect } from "@modrinth/ui";
|
||||
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";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
Multiselect,
|
||||
Checkbox,
|
||||
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 props = defineProps<{
|
||||
project: Project;
|
||||
currentMember: TeamMember | undefined;
|
||||
patchProject: (payload: Object, quiet?: boolean) => Object;
|
||||
}>();
|
||||
|
||||
const licenseUrl = ref(props.project.license.url);
|
||||
|
||||
const licenseId = props.project.license.id;
|
||||
const trimmedLicenseId = licenseId
|
||||
.replaceAll("-only", "")
|
||||
.replaceAll("-or-later", "")
|
||||
.replaceAll("LicenseRef-", "");
|
||||
|
||||
const license = ref(
|
||||
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 licenseUrl = ref(props.project.license.url);
|
||||
const license: Ref<{
|
||||
friendly: string;
|
||||
short: string;
|
||||
requiresOnlyOrLater?: boolean;
|
||||
}> = ref({
|
||||
friendly: "",
|
||||
short: "",
|
||||
requiresOnlyOrLater: false,
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -640,7 +640,6 @@ import Badge from "~/components/ui/Badge.vue";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import CopyCode from "~/components/ui/CopyCode.vue";
|
||||
import Categories from "~/components/ui/search/Categories.vue";
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import Checkbox from "~/components/ui/Checkbox.vue";
|
||||
import FileInput from "~/components/ui/FileInput.vue";
|
||||
|
||||
@@ -663,6 +662,7 @@ import Modal from "~/components/ui/Modal.vue";
|
||||
import ChevronRightIcon from "~/assets/images/utils/chevron-right.svg?component";
|
||||
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
|
||||
export default defineNuxtComponent({
|
||||
components: {
|
||||
MarkdownEditor,
|
||||
@@ -670,7 +670,6 @@ export default defineNuxtComponent({
|
||||
FileInput,
|
||||
Checkbox,
|
||||
ChevronRightIcon,
|
||||
Chips,
|
||||
Categories,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
|
||||
@@ -98,6 +98,14 @@
|
||||
action: () => (auth.user ? reportVersion(version.id) : navigateTo('/auth/sign-in')),
|
||||
shown: !currentMember,
|
||||
},
|
||||
{ divider: true, shown: currentMember || flags.developerMode },
|
||||
{
|
||||
id: 'copy-id',
|
||||
action: () => {
|
||||
copyToClipboard(version.id);
|
||||
},
|
||||
shown: currentMember || flags.developerMode,
|
||||
},
|
||||
{ divider: true, shown: currentMember },
|
||||
{
|
||||
id: 'edit',
|
||||
@@ -148,6 +156,10 @@
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Delete
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy ID
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -174,6 +186,7 @@ import {
|
||||
ReportIcon,
|
||||
UploadIcon,
|
||||
InfoIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import DropArea from "~/components/ui/DropArea.vue";
|
||||
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||
|
||||
@@ -40,12 +40,7 @@
|
||||
</span>
|
||||
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
||||
</label>
|
||||
<Toggle
|
||||
id="unprovision"
|
||||
:model-value="unprovision"
|
||||
:checked="unprovision"
|
||||
@update:model-value="() => (unprovision = !unprovision)"
|
||||
/>
|
||||
<Toggle id="unprovision" v-model="unprovision" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
@@ -63,50 +58,137 @@
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<div class="normal-page no-sidebar">
|
||||
<h1>{{ user.username }}'s subscriptions</h1>
|
||||
<div class="normal-page__content">
|
||||
<div class="page experimental-styles-within">
|
||||
<div
|
||||
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">
|
||||
<span class="font-extrabold text-contrast">
|
||||
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template>
|
||||
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||
Modrinth Servers
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
<template v-if="subscription.interval">
|
||||
{{ subscription.interval }}
|
||||
</template>
|
||||
</span>
|
||||
<div class="mb-4 mt-2 flex items-center gap-1">
|
||||
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }}
|
||||
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template>
|
||||
</div>
|
||||
<div
|
||||
v-for="charge in subscription.charges"
|
||||
:key="charge.id"
|
||||
class="universal-card recessed flex items-center justify-between gap-4"
|
||||
>
|
||||
<div class="flex w-full items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<Badge
|
||||
:color="charge.status === 'succeeded' ? 'green' : 'red'"
|
||||
:type="charge.status"
|
||||
/>
|
||||
⋅
|
||||
{{ charge.type }}
|
||||
⋅
|
||||
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
|
||||
⋅
|
||||
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
|
||||
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template>
|
||||
<div class="mb-4 grid grid-cols-[1fr_auto]">
|
||||
<div>
|
||||
<span class="flex items-center gap-2 font-semibold text-contrast">
|
||||
<template v-if="subscription.product.metadata.type === 'midas'">
|
||||
<ModrinthPlusIcon class="h-7 w-min" />
|
||||
</template>
|
||||
<template v-else-if="subscription.product.metadata.type === 'pyro'">
|
||||
<ModrinthServersIcon class="h-7 w-min" />
|
||||
</template>
|
||||
<template v-else> Unknown product </template>
|
||||
</span>
|
||||
<div class="mb-4 mt-2 flex w-full items-center gap-1 text-sm text-secondary">
|
||||
{{ formatCategory(subscription.interval) }} ⋅ {{ subscription.status }} ⋅
|
||||
{{ dayjs(subscription.created).format("MMMM D, YYYY [at] h:mma") }} ({{
|
||||
dayjs(subscription.created).fromNow()
|
||||
}})
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="subscription.metadata?.id" class="flex flex-col items-end gap-2">
|
||||
<ButtonStyled v-if="subscription.product.metadata.type === 'pyro'">
|
||||
<nuxt-link
|
||||
:to="`/servers/manage/${subscription.metadata.id}`"
|
||||
target="_blank"
|
||||
class="w-fit"
|
||||
>
|
||||
<ServerIcon /> Server panel <ExternalIcon class="h-4 w-4" />
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<CopyCode :text="subscription.metadata.id" />
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<button
|
||||
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
|
||||
class="btn"
|
||||
@click="showRefundModal(charge)"
|
||||
>
|
||||
Refund charge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,11 +196,22 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
|
||||
import { formatPrice } from "@modrinth/utils";
|
||||
import { CheckIcon, XIcon } from "@modrinth/assets";
|
||||
import { Avatar, ButtonStyled, CopyCode, DropdownSelect, NewModal, Toggle } from "@modrinth/ui";
|
||||
import { formatCategory, formatPrice } from "@modrinth/utils";
|
||||
import {
|
||||
CheckIcon,
|
||||
XIcon,
|
||||
UserIcon,
|
||||
ModrinthPlusIcon,
|
||||
ServerIcon,
|
||||
ExternalIcon,
|
||||
CurrencyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import dayjs from "dayjs";
|
||||
import { products } from "~/generated/state.json";
|
||||
import ModrinthServersIcon from "~/components/ui/servers/ModrinthServersIcon.vue";
|
||||
|
||||
const flags = useFeatureFlags();
|
||||
const route = useRoute();
|
||||
const data = useNuxtApp();
|
||||
const vintl = useVIntl();
|
||||
@@ -169,7 +262,10 @@ const subscriptionCharges = computed(() => {
|
||||
return subscriptions.value.map((subscription) => {
|
||||
return {
|
||||
...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.prices.some((price) => price.id === subscription.price_id),
|
||||
),
|
||||
@@ -217,4 +313,30 @@ async function refundCharge() {
|
||||
}
|
||||
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>
|
||||
<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>
|
||||
import {
|
||||
BoxIcon,
|
||||
CalendarIcon,
|
||||
EditIcon,
|
||||
XIcon,
|
||||
SaveIcon,
|
||||
UploadIcon,
|
||||
TrashIcon,
|
||||
LinkIcon,
|
||||
LockIcon,
|
||||
GridIcon,
|
||||
ImageIcon,
|
||||
ListIcon,
|
||||
UpdatedIcon,
|
||||
LibraryIcon,
|
||||
BoxIcon,
|
||||
LinkIcon,
|
||||
ListIcon,
|
||||
LockIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
UpdatedIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
PopoutMenu,
|
||||
FileInput,
|
||||
DropdownSelect,
|
||||
Avatar,
|
||||
Button,
|
||||
commonMessages,
|
||||
ConfirmModal,
|
||||
DropdownSelect,
|
||||
FileInput,
|
||||
PopoutMenu,
|
||||
} from "@modrinth/ui";
|
||||
|
||||
import { isAdmin } from "@modrinth/utils";
|
||||
@@ -651,7 +651,7 @@ async function saveChanges() {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
name: name.value,
|
||||
description: summary.value,
|
||||
description: summary.value || null,
|
||||
status: visibility.value,
|
||||
new_projects: newProjectIds,
|
||||
},
|
||||
|
||||
@@ -49,7 +49,9 @@
|
||||
</div>
|
||||
</nuxt-link>
|
||||
<nuxt-link
|
||||
v-for="collection in orderedCollections"
|
||||
v-for="collection in orderedCollections.sort(
|
||||
(a, b) => new Date(b.created) - new Date(a.created),
|
||||
)"
|
||||
:key="collection.id"
|
||||
:to="`/collection/${collection.id}`"
|
||||
class="universal-card recessed collection"
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button } from "@modrinth/ui";
|
||||
import { Button, Chips } from "@modrinth/ui";
|
||||
import { HistoryIcon } from "@modrinth/assets";
|
||||
import {
|
||||
fetchExtraNotificationData,
|
||||
@@ -58,7 +58,6 @@ import {
|
||||
markAsRead,
|
||||
} from "~/helpers/notifications.js";
|
||||
import NotificationItem from "~/components/ui/NotificationItem.vue";
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import CheckCheckIcon from "~/assets/images/utils/check-check.svg?component";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import Pagination from "~/components/ui/Pagination.vue";
|
||||
|
||||
@@ -1,39 +1,95 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="experimental-styles-within">
|
||||
<section class="universal-card">
|
||||
<h2 class="text-2xl">Revenue</h2>
|
||||
<div v-if="userBalance.available >= minWithdraw">
|
||||
<p>
|
||||
You have
|
||||
<strong>{{ $formatMoney(userBalance.available) }}</strong>
|
||||
available to withdraw. <strong>{{ $formatMoney(userBalance.pending) }}</strong> of your
|
||||
balance is <nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
|
||||
</p>
|
||||
<div class="grid-display">
|
||||
<div class="grid-display__item">
|
||||
<div class="label">Available now</div>
|
||||
<div class="value">
|
||||
{{ $formatMoney(userBalance.available) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<div class="label">
|
||||
Total pending
|
||||
<nuxt-link
|
||||
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
|
||||
class="align-middle text-link"
|
||||
to="/legal/cmp-info#pending"
|
||||
>
|
||||
<UnknownIcon />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
<div class="value">
|
||||
{{ $formatMoney(userBalance.pending) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-display__item">
|
||||
<h3 class="label m-0">
|
||||
Available soon
|
||||
<nuxt-link
|
||||
v-tooltip="`Click to read about how Modrinth handles your revenue.`"
|
||||
class="align-middle text-link"
|
||||
to="/legal/cmp-info#pending"
|
||||
>
|
||||
<UnknownIcon />
|
||||
</nuxt-link>
|
||||
</h3>
|
||||
<ul class="m-0 list-none p-0">
|
||||
<li
|
||||
v-for="date in availableSoonDateKeys"
|
||||
:key="date"
|
||||
class="flex items-center justify-between border-0 border-solid border-b-divider p-0 [&:not(:last-child)]:mb-1 [&:not(:last-child)]:border-b-[1px] [&:not(:last-child)]:pb-1"
|
||||
>
|
||||
<span
|
||||
v-tooltip="
|
||||
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1
|
||||
? `Revenue period is ongoing. \nThis amount is not yet finalized.`
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'cursor-help':
|
||||
availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1,
|
||||
}"
|
||||
class="inline-flex items-center gap-1 font-bold"
|
||||
>
|
||||
{{ $formatMoney(availableSoonDates[date]) }}
|
||||
<template
|
||||
v-if="availableSoonDateKeys.indexOf(date) === availableSoonDateKeys.length - 1"
|
||||
>
|
||||
<InProgressIcon />
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
{{ formatDate(dayjs(date)) }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else>
|
||||
You have made
|
||||
<strong>{{ $formatMoney(userBalance.available) }}</strong
|
||||
>, which is under the minimum of ${{ minWithdraw }} to withdraw.
|
||||
<strong>{{ $formatMoney(userBalance.pending) }}</strong> of your balance is
|
||||
<nuxt-link class="text-link" to="/legal/cmp-info#pending">pending</nuxt-link>.
|
||||
</p>
|
||||
<div class="input-group mt-4">
|
||||
<nuxt-link
|
||||
v-if="userBalance.available >= minWithdraw"
|
||||
class="iconified-button brand-button"
|
||||
to="/dashboard/revenue/withdraw"
|
||||
>
|
||||
<TransferIcon /> Withdraw
|
||||
</nuxt-link>
|
||||
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
|
||||
<nuxt-link
|
||||
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
|
||||
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
|
||||
class="iconified-button brand-button"
|
||||
to="/dashboard/revenue/withdraw"
|
||||
>
|
||||
<TransferIcon /> Withdraw
|
||||
</nuxt-link>
|
||||
</span>
|
||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon /> View transfer history
|
||||
<HistoryIcon />
|
||||
View transfer history
|
||||
</NuxtLink>
|
||||
</div>
|
||||
<p>
|
||||
<p class="text-sm text-secondary">
|
||||
By uploading projects to Modrinth and withdrawing money from your account, you agree to the
|
||||
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>. For more
|
||||
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
|
||||
information on how the rewards system works, see our information page
|
||||
<nuxt-link to="/legal/cmp-info" class="text-link">here</nuxt-link>.
|
||||
<nuxt-link class="text-link" to="/legal/cmp-info">here</nuxt-link>.
|
||||
</p>
|
||||
</section>
|
||||
<section class="universal-card">
|
||||
@@ -46,12 +102,13 @@
|
||||
{{ auth.user.payout_data.paypal_address }}
|
||||
</p>
|
||||
<button class="btn mt-4" @click="removeAuthProvider('paypal')">
|
||||
<XIcon /> Disconnect account
|
||||
<XIcon />
|
||||
Disconnect account
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
|
||||
<a class="btn mt-4" :href="`${getAuthUrl('paypal')}&token=${auth.token}`">
|
||||
<a :href="`${getAuthUrl('paypal')}&token=${auth.token}`" class="btn mt-4">
|
||||
<PayPalIcon />
|
||||
Sign in with PayPal
|
||||
</a>
|
||||
@@ -60,7 +117,8 @@
|
||||
<p>
|
||||
Tremendous payments are sent to your Modrinth email. To change/set your Modrinth email,
|
||||
visit
|
||||
<nuxt-link to="/settings/account" class="text-link">here</nuxt-link>.
|
||||
<nuxt-link class="text-link" to="/settings/account">here</nuxt-link>
|
||||
.
|
||||
</p>
|
||||
<h3>Venmo</h3>
|
||||
<p>Enter your Venmo username below to enable withdrawing to your Venmo balance.</p>
|
||||
@@ -68,18 +126,32 @@
|
||||
<input
|
||||
id="venmo"
|
||||
v-model="auth.user.payout_data.venmo_handle"
|
||||
autocomplete="off"
|
||||
class="mt-4"
|
||||
type="search"
|
||||
name="search"
|
||||
placeholder="@example"
|
||||
autocomplete="off"
|
||||
type="search"
|
||||
/>
|
||||
<button class="btn btn-secondary" @click="updateVenmo"><SaveIcon /> Save information</button>
|
||||
<button class="btn btn-secondary" @click="updateVenmo">
|
||||
<SaveIcon />
|
||||
Save information
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { TransferIcon, HistoryIcon, PayPalIcon, SaveIcon, XIcon } from "@modrinth/assets";
|
||||
import {
|
||||
HistoryIcon,
|
||||
InProgressIcon,
|
||||
PayPalIcon,
|
||||
SaveIcon,
|
||||
TransferIcon,
|
||||
UnknownIcon,
|
||||
XIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { formatDate } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import { computed } from "vue";
|
||||
|
||||
const auth = await useAuth();
|
||||
const minWithdraw = ref(0.01);
|
||||
@@ -88,6 +160,33 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
|
||||
useBaseFetch(`payout/balance`, { apiVersion: 3 }),
|
||||
);
|
||||
|
||||
const deadlineEnding = computed(() => {
|
||||
let deadline = dayjs().subtract(2, "month").endOf("month").add(60, "days");
|
||||
if (deadline.isBefore(dayjs().startOf("day"))) {
|
||||
deadline = dayjs().subtract(1, "month").endOf("month").add(60, "days");
|
||||
}
|
||||
return deadline;
|
||||
});
|
||||
|
||||
const availableSoonDates = computed(() => {
|
||||
// Get the next 3 dates from userBalance.dates that are from now to the deadline + 4 months to make sure we get all the pending ones.
|
||||
const dates = Object.keys(userBalance.value.dates)
|
||||
.filter((date) => {
|
||||
const dateObj = dayjs(date);
|
||||
return (
|
||||
dateObj.isAfter(dayjs()) && dateObj.isBefore(dayjs(deadlineEnding.value).add(4, "month"))
|
||||
);
|
||||
})
|
||||
.sort((a, b) => dayjs(a).diff(dayjs(b)));
|
||||
|
||||
return dates.reduce((acc, date) => {
|
||||
acc[date] = userBalance.value.dates[date];
|
||||
return acc;
|
||||
}, {});
|
||||
});
|
||||
|
||||
const availableSoonDateKeys = computed(() => Object.keys(availableSoonDates.value));
|
||||
|
||||
async function updateVenmo() {
|
||||
startLoading();
|
||||
try {
|
||||
@@ -118,4 +217,16 @@ strong {
|
||||
color: var(--color-text-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.disabled-cursor-wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.disabled-link {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.grid-display {
|
||||
grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="markdown-body">
|
||||
<h1>Rewards Program Information</h1>
|
||||
<p><em>Last modified: Sep 12, 2024</em></p>
|
||||
<p><em>Last modified: Feb 20, 2025</em></p>
|
||||
<p>
|
||||
This page was created for transparency for how the rewards program works on Modrinth. Feel
|
||||
free to join our Discord or email
|
||||
@@ -82,42 +82,41 @@
|
||||
<p>
|
||||
Modrinth receives ad revenue from our ad providers on a NET 60 day basis. Due to this, not all
|
||||
revenue is immediately available to withdraw. We pay creators as soon as we receive the money
|
||||
from our ad providers, which is 60 days after the last day of each month. This table outlines
|
||||
some example dates of how NET 60 payments are made:
|
||||
from our ad providers, which is 60 days after the last day of each month.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
To understand when revenue becomes available, you can use this calculator to estimate when
|
||||
revenue earned on a specific date will be available for withdrawal. Please be advised that all
|
||||
dates within this calculator are represented at 00:00 UTC.
|
||||
</p>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Payment available date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>January 1st</td>
|
||||
<td>March 31st</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>January 15th</td>
|
||||
<td>March 31st</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>March 3rd</td>
|
||||
<td>May 30th</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>June 30th</td>
|
||||
<td>August 29th</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>July 14th</td>
|
||||
<td>September 29th</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>October 12th</td>
|
||||
<td>December 30th</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tr>
|
||||
<th>Timeline</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Revenue earned on</td>
|
||||
<td>
|
||||
<input id="revenue-date-picker" v-model="rawSelectedDate" type="date" />
|
||||
<noscript
|
||||
>(JavaScript must be enabled for the date picker to function, example date: 2024-07-15)
|
||||
</noscript>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>End of the month</td>
|
||||
<td>{{ formatDate(endOfMonthDate) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>NET 60 policy applied</td>
|
||||
<td>+ 60 days</td>
|
||||
</tr>
|
||||
<tr class="final-result">
|
||||
<td>Available for withdrawal</td>
|
||||
<td>{{ formatDate(withdrawalDate) }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3>How do I know Modrinth is being transparent about revenue?</h3>
|
||||
<p>
|
||||
@@ -127,12 +126,40 @@
|
||||
revenue distribution system</a
|
||||
>. We also have an
|
||||
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a> that allows users
|
||||
to query exact daily revenue for the site.
|
||||
to query exact daily revenue for the site - so far, Modrinth has generated
|
||||
<strong>{{ formatMoney(platformRevenue) }}</strong> in revenue.
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Revenue</th>
|
||||
<th>Creator Revenue (75%)</th>
|
||||
<th>Modrinth's Cut (25%)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in platformRevenueData" :key="item.time">
|
||||
<td>{{ formatDate(dayjs.unix(item.time)) }}</td>
|
||||
<td>{{ formatMoney(item.revenue) }}</td>
|
||||
<td>{{ formatMoney(item.creator_revenue) }}</td>
|
||||
<td>{{ formatMoney(item.revenue - item.creator_revenue) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<small
|
||||
>Modrinth's total revenue in the previous 5 days, for the entire dataset, use the
|
||||
aforementioned
|
||||
<a href="https://api.modrinth.com/v3/payout/platform_revenue">API route</a>.</small
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script lang="ts" setup>
|
||||
import dayjs from "dayjs";
|
||||
import { computed, ref } from "vue";
|
||||
import { formatDate, formatMoney } from "@modrinth/utils";
|
||||
|
||||
const description =
|
||||
"Information about the Rewards Program of Modrinth, an open source modding platform focused on Minecraft.";
|
||||
|
||||
@@ -142,4 +169,18 @@ useSeoMeta({
|
||||
ogTitle: "Rewards Program Information",
|
||||
ogDescription: description,
|
||||
});
|
||||
|
||||
const rawSelectedDate = ref(dayjs().format("YYYY-MM-DD"));
|
||||
const selectedDate = computed(() => dayjs(rawSelectedDate.value));
|
||||
const endOfMonthDate = computed(() => selectedDate.value.endOf("month"));
|
||||
const withdrawalDate = computed(() => endOfMonthDate.value.add(60, "days"));
|
||||
|
||||
const { data: transparencyInformation } = await useAsyncData("payout/platform_revenue", () =>
|
||||
useBaseFetch("payout/platform_revenue", {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
const platformRevenue = transparencyInformation.value.all_time;
|
||||
const platformRevenueData = transparencyInformation.value.data.slice(0, 5);
|
||||
</script>
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
</section>
|
||||
</template>
|
||||
<script setup>
|
||||
import Chips from "~/components/ui/Chips.vue";
|
||||
import { Chips } from "@modrinth/ui";
|
||||
import Avatar from "~/components/ui/Avatar.vue";
|
||||
import UnknownIcon from "~/assets/images/utils/unknown.svg?component";
|
||||
import EyeIcon from "~/assets/images/utils/eye.svg?component";
|
||||
@@ -164,17 +164,45 @@ const projectTypes = computed(() => {
|
||||
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) {
|
||||
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 orgUrl = `organizations?ids=${encodeURIComponent(JSON.stringify(organizationIds))}`;
|
||||
const { data: result } = await useAsyncData(url, () => useBaseFetch(url));
|
||||
const { data: orgs } = await useAsyncData(orgUrl, () => useBaseFetch(orgUrl, { apiVersion: 3 }));
|
||||
const [{ data: teams }, { data: orgs }] = await Promise.all([
|
||||
useAsyncData(`teams?ids=${asEncodedJsonArray(teamIds)}`, () =>
|
||||
fetchSegmented(teamIds, (ids) => `teams?ids=${asEncodedJsonArray(ids)}`),
|
||||
),
|
||||
useAsyncData(`organizations?ids=${asEncodedJsonArray(orgIds)}`, () =>
|
||||
fetchSegmented(orgIds, (ids) => `organizations?ids=${asEncodedJsonArray(ids)}`, {
|
||||
apiVersion: 3,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
|
||||
if (result.value) {
|
||||
members.value = result.value;
|
||||
if (teams.value) {
|
||||
members.value = teams.value;
|
||||
|
||||
projects.value = projects.value.map((project) => {
|
||||
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>
|
||||
<div class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||
<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 class="text-md flex items-center gap-2 font-semibold text-contrast">
|
||||
<XCircleIcon class="h-8 w-8 shrink-0 text-brand-red" />
|
||||
@@ -238,6 +251,7 @@ import {
|
||||
AutoLink,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
ExternalIcon,
|
||||
LeftArrowIcon,
|
||||
RightArrowIcon,
|
||||
CheckIcon,
|
||||
@@ -289,6 +303,7 @@ const itemIcon = ref<string | Component | undefined>();
|
||||
const itemName = ref<string | undefined>();
|
||||
const itemLink = ref<string | undefined>();
|
||||
const itemId = ref<string | undefined>();
|
||||
const itemIssueTracker = ref<string | undefined>();
|
||||
|
||||
const reports = ref<Report[]>([]);
|
||||
const existingReport = computed(() =>
|
||||
@@ -319,6 +334,7 @@ async function fetchItem() {
|
||||
itemName.value = undefined;
|
||||
itemLink.value = undefined;
|
||||
itemId.value = undefined;
|
||||
itemIssueTracker.value = undefined;
|
||||
try {
|
||||
if (reportItem.value === "project") {
|
||||
const project = (await useBaseFetch(`project/${reportItemID.value}`)) as Project;
|
||||
@@ -328,6 +344,7 @@ async function fetchItem() {
|
||||
itemName.value = project.title;
|
||||
itemLink.value = `/project/${project.id}`;
|
||||
itemId.value = project.id;
|
||||
itemIssueTracker.value = project.issues_url;
|
||||
} else if (reportItem.value === "version") {
|
||||
const version = (await useBaseFetch(`version/${reportItemID.value}`)) as Version;
|
||||
currentVersion.value = version;
|
||||
@@ -540,6 +557,10 @@ const messages = defineMessages({
|
||||
id: "report.not-for.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: {
|
||||
id: "report.not-for.dmca",
|
||||
defaultMessage: "DMCA takedowns",
|
||||
@@ -586,7 +607,7 @@ const messages = defineMessages({
|
||||
|
||||
<style scoped lang="scss">
|
||||
.page {
|
||||
padding: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 56rem;
|
||||
|
||||
@@ -258,7 +258,8 @@
|
||||
<button
|
||||
v-if="
|
||||
result.installed ||
|
||||
server.content.data.find((x) => x.project_id === result.project_id) ||
|
||||
(server?.content?.data &&
|
||||
server.content.data.find((x) => x.project_id === result.project_id)) ||
|
||||
server.general?.project?.id === result.project_id
|
||||
"
|
||||
disabled
|
||||
@@ -376,7 +377,9 @@ async function updateServerContext() {
|
||||
if (!auth.value.user) {
|
||||
router.push("/auth/sign-in?redirect=" + encodeURIComponent(route.fullPath));
|
||||
} else if (route.query.sid !== null) {
|
||||
server.value = await usePyroServer(route.query.sid, ["general", "content"]);
|
||||
server.value = await usePyroServer(route.query.sid, ["general", "content"], {
|
||||
waitForModules: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,8 +498,8 @@ async function serverInstall(project) {
|
||||
) ?? versions[0];
|
||||
|
||||
if (projectType.value.id === "modpack") {
|
||||
await server.value.general?.reinstall(
|
||||
route.query.sid,
|
||||
await server.value.general.reinstall(
|
||||
server.value.serverId,
|
||||
false,
|
||||
project.project_id,
|
||||
version.id,
|
||||
@@ -504,7 +507,7 @@ async function serverInstall(project) {
|
||||
eraseDataOnInstall.value,
|
||||
);
|
||||
project.installed = true;
|
||||
navigateTo(`/servers/manage/${route.query.sid}/options/loader`);
|
||||
navigateTo(`/servers/manage/${server.value.serverId}/options/loader`);
|
||||
} else if (projectType.value.id === "mod") {
|
||||
await server.value.content.install("mod", version.project_id, version.id);
|
||||
await server.value.refresh(["content"]);
|
||||
|
||||
@@ -456,9 +456,10 @@
|
||||
Where are Modrinth Servers located? Can I choose a region?
|
||||
</summary>
|
||||
<p class="m-0 !leading-[190%]">
|
||||
Currently, Modrinth Servers are located in New York, Los Angeles, Seattle, and
|
||||
Miami. More regions are coming soon! Your server's location is currently chosen
|
||||
algorithmically, but you will be able to choose a region in the future.
|
||||
Currently, Modrinth Servers are located throughout the United States in New York,
|
||||
Los Angelas, Dallas, Miami, and Spokane. More regions are coming soon! Your server's
|
||||
location is currently chosen algorithmically, but you will be able to choose a
|
||||
region in the future.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -494,6 +495,97 @@
|
||||
</div>
|
||||
</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
|
||||
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"
|
||||
@@ -511,147 +603,180 @@
|
||||
? "We are currently at capacity. Please try again later."
|
||||
: "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>
|
||||
|
||||
<ul class="m-0 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">
|
||||
<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
|
||||
<ul class="m-0 mt-8 flex w-full flex-col gap-8 p-0 lg:flex-row">
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isSmallLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.small?.available }} left in stock!
|
||||
</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>
|
||||
<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>
|
||||
<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
|
||||
style="
|
||||
background: radial-gradient(
|
||||
86.12% 101.64% at 95.97% 94.07%,
|
||||
rgba(27, 217, 106, 0.23) 0%,
|
||||
rgba(14, 115, 56, 0.2) 100%
|
||||
);
|
||||
border: 1px solid rgba(12, 107, 52, 0.55);
|
||||
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
|
||||
"
|
||||
class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3"
|
||||
>
|
||||
<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
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isMediumLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.medium?.available }} left in stock!
|
||||
</div>
|
||||
<div
|
||||
style="
|
||||
background: radial-gradient(
|
||||
86.12% 101.64% at 95.97% 94.07%,
|
||||
rgba(27, 217, 106, 0.23) 0%,
|
||||
rgba(14, 115, 56, 0.2) 100%
|
||||
);
|
||||
border: 1px solid rgba(12, 107, 52, 0.55);
|
||||
box-shadow: 0px 12px 38.1px rgba(27, 217, 106, 0.13);
|
||||
"
|
||||
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>
|
||||
<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>
|
||||
<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 class="flex w-full flex-col gap-4 rounded-2xl bg-bg p-8 text-left lg:w-1/3">
|
||||
<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
|
||||
<li class="relative flex w-full flex-col justify-between pt-12 lg:w-1/3">
|
||||
<div
|
||||
v-if="isLargeLowStock"
|
||||
class="absolute left-0 right-0 top-[-2px] rounded-t-2xl bg-yellow-500/20 p-4 text-center font-bold"
|
||||
>
|
||||
Only {{ capacityStatuses?.large?.available }} left in stock!
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
</ul>
|
||||
|
||||
@@ -697,6 +822,7 @@ import {
|
||||
} from "@modrinth/assets";
|
||||
import { products } from "~/generated/state.json";
|
||||
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 pyroPlanProducts = pyroProducts.filter(
|
||||
@@ -746,7 +872,7 @@ const deletingSpeed = 25;
|
||||
const pauseTime = 2000;
|
||||
|
||||
const loggedOut = computed(() => !auth.value.user);
|
||||
const outOfStockUrl = "https://support.modrinth.com";
|
||||
const outOfStockUrl = "https://discord.modrinth.com";
|
||||
|
||||
const { data: hasServers } = await useAsyncData("ServerListCountCheck", async () => {
|
||||
try {
|
||||
@@ -760,9 +886,16 @@ const { data: hasServers } = await useAsyncData("ServerListCountCheck", async ()
|
||||
|
||||
async function fetchCapacityStatuses(customProduct = null) {
|
||||
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) =>
|
||||
usePyroFetch("capacity", {
|
||||
usePyroFetch("stock", {
|
||||
method: "POST",
|
||||
body: {
|
||||
cpu: product.metadata.cpu,
|
||||
@@ -774,6 +907,7 @@ async function fetchCapacityStatuses(customProduct = null) {
|
||||
);
|
||||
|
||||
const results = await Promise.all(capacityChecks);
|
||||
|
||||
if (customProduct?.metadata) {
|
||||
return {
|
||||
custom: results[0],
|
||||
@@ -783,6 +917,7 @@ async function fetchCapacityStatuses(customProduct = null) {
|
||||
small: results[0],
|
||||
medium: results[1],
|
||||
large: results[2],
|
||||
custom: results[3],
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -804,6 +939,22 @@ const { data: capacityStatuses, refresh: refreshCapacity } = await useAsyncData(
|
||||
const isSmallAtCapacity = computed(() => capacityStatuses.value?.small?.available === 0);
|
||||
const isMediumAtCapacity = computed(() => capacityStatuses.value?.medium?.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 currentWord = words[currentWordIndex.value];
|
||||
@@ -907,7 +1058,9 @@ const selectProduct = async (product) => {
|
||||
}
|
||||
|
||||
await refreshCapacity();
|
||||
if (isAtCapacity.value) {
|
||||
console.log(capacityStatuses.value);
|
||||
|
||||
if ((product === "custom" && isCustomAtCapacity.value) || isAtCapacity.value) {
|
||||
addNotification({
|
||||
group: "main",
|
||||
title: "Server Capacity Full",
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<TransferIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Upgrading</h1>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server upgrading</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
Your server's hardware is currently being upgraded and will be back online shortly!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -47,17 +47,18 @@
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<LockIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server Suspended</h1>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Server suspended</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
{{
|
||||
serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
serverData.suspension_reason === "cancelled"
|
||||
? "Your subscription has been cancelled."
|
||||
: serverData.suspension_reason
|
||||
? `Your server has been suspended: ${serverData.suspension_reason}`
|
||||
: "Your server has been suspended."
|
||||
}}
|
||||
<br />
|
||||
This is most likely due to a billing issue. Please check your billing information and
|
||||
contact Modrinth support if you believe this is an error.
|
||||
Contact Modrinth support if you believe this is an error.
|
||||
</p>
|
||||
</div>
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/settings/billing')">
|
||||
@@ -66,7 +67,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error && server.error.message.includes('Forbidden')"
|
||||
v-else-if="
|
||||
server.general?.error?.error.statusCode === 403 ||
|
||||
server.general?.error?.error.statusCode === 404
|
||||
"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
@@ -82,21 +86,22 @@
|
||||
this is an error, please contact Modrinth support.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
|
||||
<ButtonStyled size="large" color="brand" @click="() => router.push('/servers/manage')">
|
||||
<button class="mt-6 !w-full">Go back to all servers</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error && server.error.message.includes('Service Unavailable')"
|
||||
v-else-if="server.general?.error?.error.statusCode === 503"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<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-red p-4">
|
||||
<PanelErrorIcon class="size-12 text-red" />
|
||||
<UiServersIconsPanelErrorIcon class="size-12 text-red" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-4 w-fit text-4xl font-bold">Server Node Unavailable</h1>
|
||||
</div>
|
||||
@@ -141,7 +146,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="server.error"
|
||||
v-else-if="server.general?.error"
|
||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
@@ -164,7 +169,7 @@
|
||||
temporary network issue. You'll be reconnected automatically.
|
||||
</p>
|
||||
</div>
|
||||
<UiCopyCode :text="server.error ? String(server.error) : 'Unknown error'" />
|
||||
<UiCopyCode :text="JSON.stringify(server.general?.error)" />
|
||||
<ButtonStyled
|
||||
:disabled="formattedTime !== '00'"
|
||||
size="large"
|
||||
@@ -228,7 +233,7 @@
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:linked="true"
|
||||
class="flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
class="server-action-buttons-anim flex min-w-0 flex-col flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -343,7 +348,7 @@
|
||||
<div
|
||||
v-if="isReconnecting"
|
||||
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 />
|
||||
Hang on, we're reconnecting to your server.
|
||||
@@ -352,12 +357,17 @@
|
||||
<div
|
||||
v-if="serverData.status === '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 />
|
||||
We're preparing your server, this may take a few minutes.
|
||||
</div>
|
||||
<UiServersServerIcon :image="serverData.image" class="!h-10 !w-10" />
|
||||
|
||||
<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
|
||||
:route="route"
|
||||
:is-connected="isConnected"
|
||||
@@ -392,10 +402,9 @@ import {
|
||||
import DOMPurify from "dompurify";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
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 { usePyroConsole } from "~/store/console.ts";
|
||||
import PanelErrorIcon from "~/components/ui/servers/icons/PanelErrorIcon.vue";
|
||||
|
||||
const socket = ref<WebSocket | null>(null);
|
||||
const isReconnecting = ref(false);
|
||||
@@ -420,21 +429,25 @@ const createdAt = ref(
|
||||
const route = useNativeRoute();
|
||||
const router = useRouter();
|
||||
const serverId = route.params.id as string;
|
||||
const server = await usePyroServer(serverId, [
|
||||
"general",
|
||||
"content",
|
||||
"backups",
|
||||
"network",
|
||||
"startup",
|
||||
"ws",
|
||||
"fs",
|
||||
]);
|
||||
|
||||
const server = await usePyroServer(serverId, ["general", "ws"]);
|
||||
|
||||
const loadModulesPromise = Promise.resolve().then(() => {
|
||||
if (server.general?.status === "suspended") {
|
||||
return;
|
||||
}
|
||||
return server.loadModules(["content", "backups", "network", "startup", "fs"]);
|
||||
});
|
||||
|
||||
provide("modulesLoaded", loadModulesPromise);
|
||||
|
||||
watch(
|
||||
() => server.error,
|
||||
(newError) => {
|
||||
() => [server.general?.error, server.ws?.error],
|
||||
([generalError, wsError]) => {
|
||||
if (server.general?.status === "suspended") return;
|
||||
if (newError && !newError.message.includes("Forbidden")) {
|
||||
|
||||
const error = generalError?.error || wsError?.error;
|
||||
if (error && error.statusCode !== 403) {
|
||||
startPolling();
|
||||
}
|
||||
},
|
||||
@@ -445,11 +458,9 @@ const errorMessage = ref("An unexpected error occurred.");
|
||||
const errorLog = ref("");
|
||||
const errorLogFile = ref("");
|
||||
const serverData = computed(() => server.general);
|
||||
const error = ref<Error | null>(null);
|
||||
const isConnected = ref(false);
|
||||
const isWSAuthIncorrect = ref(false);
|
||||
const pyroConsole = usePyroConsole();
|
||||
console.log("||||||||||||||||||||||| console", pyroConsole.output);
|
||||
const cpuData = ref<number[]>([]);
|
||||
const ramData = ref<number[]>([]);
|
||||
const isActioning = ref(false);
|
||||
@@ -460,6 +471,7 @@ const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>();
|
||||
const uptimeSeconds = ref(0);
|
||||
const firstConnect = ref(true);
|
||||
const copied = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
const initialConsoleMessage = [
|
||||
" __________________________________________________",
|
||||
@@ -660,24 +672,71 @@ const newLoader = ref<string | null>(null);
|
||||
const newLoaderVersion = ref<string | null>(null);
|
||||
const newMCVersion = ref<string | null>(null);
|
||||
|
||||
const onReinstall = (potentialArgs: any) => {
|
||||
if (!serverData.value) return;
|
||||
|
||||
serverData.value.status = "installing";
|
||||
|
||||
if (potentialArgs?.loader) {
|
||||
newLoader.value = potentialArgs.loader;
|
||||
}
|
||||
if (potentialArgs?.lVersion) {
|
||||
newLoaderVersion.value = potentialArgs.lVersion;
|
||||
}
|
||||
if (potentialArgs?.mVersion) {
|
||||
newMCVersion.value = potentialArgs.mVersion;
|
||||
}
|
||||
|
||||
error.value = null;
|
||||
errorTitle.value = "Error";
|
||||
errorMessage.value = "An unexpected error occurred.";
|
||||
};
|
||||
|
||||
const handleInstallationResult = async (data: WSInstallationResultEvent) => {
|
||||
switch (data.result) {
|
||||
case "ok":
|
||||
case "ok": {
|
||||
if (!serverData.value) break;
|
||||
serverData.value.status = "available";
|
||||
|
||||
if (!isFirstMount.value) {
|
||||
await server.refresh();
|
||||
}
|
||||
|
||||
if (server.general) {
|
||||
if (newLoader.value) server.general.loader = newLoader.value;
|
||||
if (newLoaderVersion.value) server.general.loader_version = newLoaderVersion.value;
|
||||
if (newMCVersion.value) server.general.mc_version = newMCVersion.value;
|
||||
stopPolling();
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
let attempts = 0;
|
||||
const maxAttempts = 3;
|
||||
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;
|
||||
break;
|
||||
}
|
||||
case "err": {
|
||||
console.log("failed to install");
|
||||
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"]) => {
|
||||
isConnected.value = true;
|
||||
stats.value = {
|
||||
@@ -762,7 +784,6 @@ const updatePowerState = (
|
||||
state: ServerState,
|
||||
details?: { oom_killed?: boolean; exit_code?: number },
|
||||
) => {
|
||||
console.log("Power state:", state, details);
|
||||
serverPowerState.value = state;
|
||||
|
||||
if (state === "crashed") {
|
||||
@@ -910,6 +931,10 @@ const cleanup = () => {
|
||||
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
if (server.general?.status === "suspended") {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
if (server.error) {
|
||||
if (!server.error.message.includes("Forbidden")) {
|
||||
startPolling();
|
||||
@@ -959,17 +984,15 @@ onUnmounted(() => {
|
||||
|
||||
watch(
|
||||
() => serverData.value?.status,
|
||||
(newStatus) => {
|
||||
(newStatus, oldStatus) => {
|
||||
if (isFirstMount.value) {
|
||||
isFirstMount.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStatus === "installing") {
|
||||
if (newStatus === "installing" && oldStatus !== "installing") {
|
||||
countdown.value = 15;
|
||||
startPolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
server.refresh();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -979,7 +1002,7 @@ definePageMeta({
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
<style>
|
||||
@keyframes server-action-buttons-anim {
|
||||
0% {
|
||||
opacity: 0;
|
||||
@@ -996,7 +1019,16 @@ definePageMeta({
|
||||
}
|
||||
|
||||
.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: "";
|
||||
background-image: linear-gradient(
|
||||
to bottom,
|
||||
@@ -1005,4 +1037,10 @@ definePageMeta({
|
||||
),
|
||||
var(--server-bg-image);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.mobile-blurred-servericon::before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,30 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div v-if="data" class="contents">
|
||||
<div
|
||||
v-if="server.backups?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load backups</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's backups. Here's what went wrong:
|
||||
</p>
|
||||
<p>
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.backups.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['backups'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="contents">
|
||||
<LazyUiServersBackupCreateModal
|
||||
ref="createBackupModal"
|
||||
:server="server"
|
||||
@@ -53,7 +77,10 @@
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2 sm:w-fit sm:flex-row">
|
||||
<ButtonStyled type="standard">
|
||||
<button @click="showbackupSettingsModal">
|
||||
<button
|
||||
:disabled="server.general?.status === 'installing'"
|
||||
@click="showbackupSettingsModal"
|
||||
>
|
||||
<SettingsIcon class="h-5 w-5" />
|
||||
Auto backups
|
||||
</button>
|
||||
@@ -63,13 +90,16 @@
|
||||
v-tooltip="
|
||||
isServerRunning && !userPreferences.backupWhileRunning
|
||||
? '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"
|
||||
:disabled="
|
||||
(isServerRunning && !userPreferences.backupWhileRunning) ||
|
||||
data.used_backup_quota >= data.backup_quota ||
|
||||
backups.some((backup) => backup.ongoing)
|
||||
backups.some((backup) => backup.ongoing) ||
|
||||
server.general?.status === 'installing'
|
||||
"
|
||||
@click="showCreateModel"
|
||||
>
|
||||
@@ -90,108 +120,111 @@
|
||||
automatically refresh when the backup is complete.
|
||||
</div>
|
||||
|
||||
<li
|
||||
v-for="(backup, index) in backups"
|
||||
:key="backup.id"
|
||||
class="relative m-0 w-full list-none rounded-2xl bg-bg-raised p-4 shadow-md"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex min-w-0 flex-row items-center gap-4">
|
||||
<div
|
||||
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"
|
||||
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" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="max-w-full truncate text-xl font-bold text-contrast">
|
||||
{{ backup.name }}
|
||||
</div>
|
||||
<div class="flex w-full flex-col gap-2">
|
||||
<li
|
||||
v-for="(backup, index) in backups"
|
||||
: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 min-w-0 flex-row items-center gap-4">
|
||||
<div
|
||||
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"
|
||||
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" />
|
||||
</div>
|
||||
<div class="flex min-w-0 flex-col gap-2">
|
||||
<div class="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div class="max-w-full truncate font-bold text-contrast">
|
||||
{{ backup.name }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<CheckIcon class="size-4" /> Latest
|
||||
<div
|
||||
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"
|
||||
>
|
||||
<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 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>
|
||||
<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>
|
||||
<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>
|
||||
</li>
|
||||
</li>
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
@@ -232,6 +265,7 @@ import {
|
||||
BoxIcon,
|
||||
LockIcon,
|
||||
LockOpenIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
@@ -288,33 +322,37 @@ const showbackupSettingsModal = () => {
|
||||
backupSettingsModal.value?.show();
|
||||
};
|
||||
|
||||
const handleBackupCreated = (payload: { success: boolean; message: string }) => {
|
||||
const handleBackupCreated = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupRenamed = (payload: { success: boolean; message: string }) => {
|
||||
const handleBackupRenamed = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupRestored = (payload: { success: boolean; message: string }) => {
|
||||
const handleBackupRestored = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackupDeleted = (payload: { success: boolean; message: string }) => {
|
||||
const handleBackupDeleted = async (payload: { success: boolean; message: string }) => {
|
||||
if (payload.success) {
|
||||
addNotification({ type: "success", text: payload.message });
|
||||
await props.server.refresh(["backups"]);
|
||||
} else {
|
||||
addNotification({ type: "error", text: payload.message });
|
||||
}
|
||||
@@ -378,8 +416,8 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
if (hasOngoingBackups) {
|
||||
refreshInterval.value = setInterval(() => {
|
||||
props.server.refresh(["backups"]);
|
||||
refreshInterval.value = setInterval(async () => {
|
||||
await props.server.refresh(["backups"]);
|
||||
}, 10000);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,59 +1,39 @@
|
||||
<template>
|
||||
<NewModal ref="modModal" header="Editing mod version">
|
||||
<div>
|
||||
<div class="mb-4 flex flex-col gap-4">
|
||||
<div class="inline-flex flex-wrap items-center">
|
||||
You're changing the version of
|
||||
<div class="inline-flex flex-wrap items-center gap-1 text-nowrap pl-2">
|
||||
<UiAvatar
|
||||
:src="currentMod?.icon_url"
|
||||
size="24px"
|
||||
class="inline-block"
|
||||
alt="Server Icon"
|
||||
/>
|
||||
<strong>{{ currentMod?.name + "." }}</strong>
|
||||
<UiServersContentVersionEditModal
|
||||
v-if="!invalidModal"
|
||||
ref="versionEditModal"
|
||||
:type="type"
|
||||
:mod-pack="Boolean(props.server.general?.upstream)"
|
||||
:game-version="props.server.general?.mc_version ?? ''"
|
||||
:loader="props.server.general?.loader?.toLowerCase() ?? ''"
|
||||
:server-id="props.server.serverId"
|
||||
@change-version="changeModVersion($event)"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="server.content?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load content</h1>
|
||||
</div>
|
||||
<div>
|
||||
<div v-if="props.server.general?.upstream" class="flex gap-2">
|
||||
<InfoIcon class="hidden sm:block" />
|
||||
<span class="text-sm text-secondary">
|
||||
Changing the mod version may cause unexpected issues. Because your server was created
|
||||
from a modpack, it is recommended to use the modpack's version of the mod.
|
||||
</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>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's {{ type.toLowerCase() }}s. Here's what we know:
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.content.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['content'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</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 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">
|
||||
@@ -123,7 +103,7 @@
|
||||
class="rounded-xl bg-bg-raised"
|
||||
:margin-bottom="16"
|
||||
:file-type="type"
|
||||
:current-path="'/mods'"
|
||||
:current-path="`/${type.toLocaleLowerCase()}s`"
|
||||
:fs="props.server.fs"
|
||||
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
||||
@upload-complete="() => props.server.refresh(['content'])"
|
||||
@@ -149,7 +129,7 @@
|
||||
:to="
|
||||
mod.project_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"
|
||||
draggable="false"
|
||||
@@ -162,9 +142,7 @@
|
||||
/>
|
||||
<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="truncate text-contrast">{{
|
||||
mod.name || mod.filename.replace(".disabled", "")
|
||||
}}</span>
|
||||
<span class="truncate text-contrast">{{ friendlyModName(mod) }}</span>
|
||||
<span
|
||||
v-if="mod.disabled"
|
||||
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">
|
||||
<span v-if="mod.owner" class="hidden sm:block"> by {{ mod.owner }} </span>
|
||||
<span class="block font-semibold sm:hidden">
|
||||
{{ mod.version_number || "External mod" }}
|
||||
{{ mod.version_number || `External ${type.toLocaleLowerCase()}` }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div class="ml-2 hidden min-w-0 flex-1 flex-col text-sm sm:flex">
|
||||
<div class="truncate font-semibold text-contrast">
|
||||
<span v-tooltip="'Mod version'">{{
|
||||
mod.version_number || "External mod"
|
||||
<span v-tooltip="`${type} version`">{{
|
||||
mod.version_number || `External ${type.toLocaleLowerCase()}`
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="truncate">
|
||||
<span v-tooltip="'Mod file name'">{{ mod.filename }}</span>
|
||||
<span v-tooltip="`${type} file name`">
|
||||
{{ mod.filename }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -194,7 +174,7 @@
|
||||
>
|
||||
<ButtonStyled color="red" type="transparent">
|
||||
<button
|
||||
v-tooltip="'Delete mod'"
|
||||
v-tooltip="`Delete ${type.toLocaleLowerCase()}`"
|
||||
:disabled="mod.changing"
|
||||
class="!hidden sm:!block"
|
||||
@click="removeMod(mod)"
|
||||
@@ -205,14 +185,16 @@
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
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"
|
||||
class="!hidden sm:!block"
|
||||
@click="beginChangeModVersion(mod)"
|
||||
@click="showVersionModal(mod)"
|
||||
>
|
||||
<template v-if="mod.changing">
|
||||
<UiServersIconsLoadingIcon />
|
||||
<UiServersIconsLoadingIcon class="animate-spin" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<EditIcon />
|
||||
@@ -232,7 +214,7 @@
|
||||
:options="[
|
||||
{
|
||||
id: 'edit',
|
||||
action: () => beginChangeModVersion(mod),
|
||||
action: () => showVersionModal(mod),
|
||||
shown: !!(mod.project_id && !mod.changing),
|
||||
},
|
||||
{
|
||||
@@ -357,16 +339,15 @@ import {
|
||||
PackageClosedIcon,
|
||||
FilterIcon,
|
||||
DropdownIcon,
|
||||
InfoIcon,
|
||||
XIcon,
|
||||
PlusIcon,
|
||||
MoreVerticalIcon,
|
||||
CompassIcon,
|
||||
WrenchIcon,
|
||||
ListIcon,
|
||||
FileIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted, onUnmounted } from "vue";
|
||||
import FilesUploadDragAndDrop from "~/components/ui/servers/FilesUploadDragAndDrop.vue";
|
||||
import FilesUploadDropdown from "~/components/ui/servers/FilesUploadDropdown.vue";
|
||||
@@ -401,6 +382,64 @@ const filterMethod = ref("all");
|
||||
|
||||
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[]) => {
|
||||
files.forEach((file) => {
|
||||
uploadDropdownRef.value?.uploadFile(file);
|
||||
@@ -529,17 +568,30 @@ const debouncedSearch = debounce(() => {
|
||||
}
|
||||
}, 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) {
|
||||
mod.changing = true;
|
||||
|
||||
const originalFilename = mod.filename;
|
||||
try {
|
||||
const newFilename = mod.filename.endsWith(".disabled")
|
||||
? mod.filename.replace(".disabled", "")
|
||||
? mod.filename.slice(0, -9)
|
||||
: `${mod.filename}.disabled`;
|
||||
|
||||
const sourcePath = `/mods/${mod.filename}`;
|
||||
const destinationPath = `/mods/${newFilename}`;
|
||||
const folder = `${type.value.toLocaleLowerCase()}s`;
|
||||
const sourcePath = `/${folder}/${mod.filename}`;
|
||||
const destinationPath = `/${folder}/${newFilename}`;
|
||||
|
||||
mod.disabled = newFilename.endsWith(".disabled");
|
||||
mod.filename = newFilename;
|
||||
@@ -553,7 +605,7 @@ async function toggleMod(mod: ContentItem) {
|
||||
|
||||
console.error("Error toggling mod:", error);
|
||||
addNotification({
|
||||
text: `Something went wrong toggling ${mod.name || mod.filename.replace(".disabled", "")}`,
|
||||
text: `Something went wrong toggling ${friendlyModName(mod)}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
@@ -565,10 +617,7 @@ async function removeMod(mod: ContentItem) {
|
||||
mod.changing = true;
|
||||
|
||||
try {
|
||||
await props.server.content?.remove(
|
||||
type.value as "Mod" | "Plugin",
|
||||
`/${type.value.toLowerCase()}s/${mod.filename}`,
|
||||
);
|
||||
await props.server.content?.remove(`/${type.value.toLowerCase()}s/${mod.filename}`);
|
||||
await props.server.refresh(["general", "content"]);
|
||||
} catch (error) {
|
||||
console.error("Error removing mod:", error);
|
||||
@@ -582,41 +631,6 @@ async function removeMod(mod: ContentItem) {
|
||||
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(() => {
|
||||
return localMods.value?.length > 0;
|
||||
});
|
||||
@@ -646,9 +660,7 @@ const filteredMods = computed(() => {
|
||||
})();
|
||||
|
||||
return statusFilteredMods.sort((a, b) => {
|
||||
const aName = a.name || a.filename.replace(".disabled", "");
|
||||
const bName = b.name || b.filename.replace(".disabled", "");
|
||||
return aName.localeCompare(bName);
|
||||
return friendlyModName(a).localeCompare(friendlyModName(b));
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -34,13 +34,18 @@
|
||||
<UiServersFilesBrowseNavbar
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
:search-query="searchQuery"
|
||||
:sort-method="sortMethod"
|
||||
:current-filter="viewFilter"
|
||||
@navigate="navigateToSegment"
|
||||
@sort="sortFiles"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@filter="handleFilter"
|
||||
@update:search-query="searchQuery = $event"
|
||||
/>
|
||||
<UiServersFilesLabelBar
|
||||
:sort-field="sortMethod"
|
||||
:sort-desc="sortDesc"
|
||||
@sort="handleSort"
|
||||
/>
|
||||
<FilesUploadDropdown
|
||||
v-if="props.server.fs"
|
||||
ref="uploadDropdownRef"
|
||||
@@ -94,7 +99,6 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="items.length > 0" class="h-full w-full overflow-hidden rounded-b-2xl">
|
||||
<UiServersFilesLabelBar />
|
||||
<UiServersFileVirtualList
|
||||
:items="filteredItems"
|
||||
@delete="showDeleteModal"
|
||||
@@ -185,6 +189,8 @@ const props = defineProps<{
|
||||
server: Server<["general", "content", "backups", "network", "startup", "ws", "fs"]>;
|
||||
}>();
|
||||
|
||||
const modulesLoaded = inject<Promise<void>>("modulesLoaded");
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -196,7 +202,8 @@ const operationHistory = ref<Operation[]>([]);
|
||||
const redoStack = ref<Operation[]>([]);
|
||||
|
||||
const searchQuery = ref("");
|
||||
const sortMethod = ref("default");
|
||||
const sortMethod = ref("name");
|
||||
const sortDesc = ref(false);
|
||||
|
||||
const maxResults = 100;
|
||||
const currentPage = ref(1);
|
||||
@@ -227,11 +234,21 @@ const uploadDropdownRef = ref();
|
||||
|
||||
const data = computed(() => props.server.general);
|
||||
|
||||
const viewFilter = ref("all");
|
||||
|
||||
const handleFilter = (type: string) => {
|
||||
viewFilter.value = type;
|
||||
sortMethod.value = "name";
|
||||
sortDesc.value = false;
|
||||
};
|
||||
|
||||
useHead({
|
||||
title: computed(() => `Files - ${data.value?.name ?? "Server"} - Modrinth`),
|
||||
});
|
||||
|
||||
const fetchDirectoryContents = async (): Promise<DirectoryResponse> => {
|
||||
await modulesLoaded;
|
||||
|
||||
const path = Array.isArray(currentPath.value) ? currentPath.value.join("") : currentPath.value;
|
||||
try {
|
||||
const data = await props.server.fs?.listDirContents(path, currentPage.value, maxResults);
|
||||
@@ -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(() => {
|
||||
let result = [...items.value];
|
||||
|
||||
@@ -575,24 +637,7 @@ const filteredItems = computed(() => {
|
||||
result = result.filter((item) => item.name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
switch (sortMethod.value) {
|
||||
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;
|
||||
return applySort(result);
|
||||
});
|
||||
|
||||
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 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 () => {
|
||||
await modulesLoaded;
|
||||
|
||||
await initializeFileEdit();
|
||||
|
||||
await import("ace-builds");
|
||||
await import("ace-builds/src-noconflict/mode-json");
|
||||
await import("ace-builds/src-noconflict/mode-yaml");
|
||||
@@ -717,7 +773,9 @@ watch(
|
||||
async (newQuery) => {
|
||||
currentPage.value = 1;
|
||||
searchQuery.value = "";
|
||||
sortMethod.value = "default";
|
||||
viewFilter.value = "all";
|
||||
sortMethod.value = "name";
|
||||
sortDesc.value = false;
|
||||
|
||||
currentPath.value = Array.isArray(newQuery.path)
|
||||
? newQuery.path.join("")
|
||||
|
||||
@@ -80,14 +80,19 @@
|
||||
<div class="flex flex-col-reverse gap-6 md:flex-col">
|
||||
<UiServersServerStats :data="stats" />
|
||||
<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 gap-4">
|
||||
<h2 class="m-0 text-3xl font-extrabold text-contrast">Console</h2>
|
||||
|
||||
<UiServersPanelServerStatus :state="serverPowerState" />
|
||||
</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">
|
||||
<div class="relative w-full px-4 pt-4">
|
||||
<ul
|
||||
@@ -164,7 +169,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiServersPanelOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
|
||||
<UiServersOverviewLoading v-else-if="!isConnected && !isWsAuthIncorrect" />
|
||||
<div v-else-if="isWsAuthIncorrect" class="flex flex-col">
|
||||
<h2>Could not connect to the server.</h2>
|
||||
<p>
|
||||
@@ -239,19 +244,31 @@ interface ErrorData {
|
||||
const inspectingError = ref<ErrorData | null>(null);
|
||||
|
||||
const inspectError = async () => {
|
||||
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
||||
// @ts-ignore
|
||||
const analysis = (await $fetch(`https://api.mclo.gs/1/analyse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
content: log,
|
||||
}),
|
||||
})) as ErrorData;
|
||||
try {
|
||||
const log = await props.server.fs?.downloadFile("logs/latest.log");
|
||||
if (!log) return;
|
||||
|
||||
inspectingError.value = analysis;
|
||||
// @ts-ignore
|
||||
const response = await $fetch(`https://api.mclo.gs/1/analyse`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
content: log,
|
||||
}),
|
||||
});
|
||||
|
||||
// @ts-ignore
|
||||
if (response && response.analysis && Array.isArray(response.analysis.problems)) {
|
||||
inspectingError.value = response as ErrorData;
|
||||
} else {
|
||||
inspectingError.value = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to analyze logs:", error);
|
||||
inspectingError.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
@@ -261,7 +278,7 @@ const clearError = () => {
|
||||
watch(
|
||||
() => props.serverPowerState,
|
||||
(newVal) => {
|
||||
if (newVal === "crashed") {
|
||||
if (newVal === "crashed" && !props.powerStateDetails?.oom_killed) {
|
||||
inspectError();
|
||||
} else {
|
||||
clearError();
|
||||
@@ -269,7 +286,7 @@ watch(
|
||||
},
|
||||
);
|
||||
|
||||
if (props.serverPowerState === "crashed") {
|
||||
if (props.serverPowerState === "crashed" && !props.powerStateDetails?.oom_killed) {
|
||||
inspectError();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="card flex flex-col gap-4">
|
||||
<label for="server-name-field" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-bold text-contrast">Server name</span>
|
||||
<span> Change your server's name. This name is only visible on Modrinth.</span>
|
||||
<span> This name is only visible on Modrinth.</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
@@ -64,10 +64,7 @@
|
||||
<div class="card flex flex-col gap-4">
|
||||
<label for="server-icon-field" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-bold text-contrast">Server icon</span>
|
||||
<span>
|
||||
Change your server's icon. Changes will be visible on the Minecraft server list and on
|
||||
Modrinth.
|
||||
</span>
|
||||
<span> This icon will be visible on the Minecraft server list and on Modrinth. </span>
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<div
|
||||
@@ -91,20 +88,7 @@
|
||||
>
|
||||
<EditIcon class="h-8 w-8 text-contrast" />
|
||||
</div>
|
||||
<img
|
||||
v-if="icon"
|
||||
no-shadow
|
||||
alt="Server Icon"
|
||||
class="h-[6rem] w-[6rem] rounded-xl"
|
||||
:src="icon"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
no-shadow
|
||||
alt="Server Icon"
|
||||
class="h-[6rem] w-[6rem] rounded-xl"
|
||||
src="~/assets/images/servers/minecraft_server_icon.png"
|
||||
/>
|
||||
<UiServersServerIcon :image="icon" />
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button v-tooltip="'Synchronize icon with installed modpack'" @click="resetIcon">
|
||||
@@ -116,7 +100,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiServersPyroLoading v-else />
|
||||
<div v-else />
|
||||
<UiServersSaveBanner
|
||||
:is-visible="!!hasUnsavedChanges && !!isValidServerName"
|
||||
:server="props.server"
|
||||
@@ -234,67 +218,106 @@ const resetGeneral = () => {
|
||||
|
||||
const uploadFile = async (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
// down scale the image to 64x64
|
||||
if (!file) {
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "No file selected",
|
||||
text: "Please select a file to upload.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const scaledFile = await new Promise<File>((resolve, reject) => {
|
||||
if (!file) {
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "No file selected",
|
||||
text: "Please select a file to upload.",
|
||||
});
|
||||
reject(new Error("No file selected"));
|
||||
return;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
img.src = URL.createObjectURL(file);
|
||||
img.onload = () => {
|
||||
canvas.width = 64;
|
||||
canvas.height = 64;
|
||||
ctx?.drawImage(img, 0, 0, 64, 64);
|
||||
// turn the downscaled image back to a png file
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
const data = new File([blob], "server-icon.png", { type: "image/png" });
|
||||
resolve(data);
|
||||
resolve(new File([blob], "server-icon.png", { type: "image/png" }));
|
||||
} else {
|
||||
reject(new Error("Canvas toBlob failed"));
|
||||
}
|
||||
}, "image/png");
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
if (!file) return;
|
||||
if (data.value?.image) {
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||
}
|
||||
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
|
||||
await props.server.fs?.uploadFile("/server-icon-original.png", file);
|
||||
await props.server.refresh();
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server icon updated",
|
||||
text: "Your server icon was successfully changed.",
|
||||
});
|
||||
try {
|
||||
if (data.value?.image) {
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||
}
|
||||
|
||||
await props.server.fs?.uploadFile("/server-icon.png", scaledFile);
|
||||
await props.server.fs?.uploadFile("/server-icon-original.png", file);
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const img = new Image();
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => {
|
||||
canvas.width = 512;
|
||||
canvas.height = 512;
|
||||
ctx?.drawImage(img, 0, 0, 512, 512);
|
||||
const dataURL = canvas.toDataURL("image/png");
|
||||
useState(`server-icon-${props.server.serverId}`).value = dataURL;
|
||||
if (data.value) data.value.image = dataURL;
|
||||
resolve();
|
||||
URL.revokeObjectURL(img.src);
|
||||
};
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server icon updated",
|
||||
text: "Your server icon was successfully changed.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error uploading icon:", error);
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "Upload failed",
|
||||
text: "Failed to upload server icon.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resetIcon = async () => {
|
||||
if (data.value?.image) {
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
await reloadNuxtApp();
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server icon reset",
|
||||
text: "Your server icon was successfully reset.",
|
||||
});
|
||||
try {
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon.png", false);
|
||||
await props.server.fs?.deleteFileOrFolder("/server-icon-original.png", false);
|
||||
|
||||
useState(`server-icon-${props.server.serverId}`).value = undefined;
|
||||
if (data.value) data.value.image = undefined;
|
||||
|
||||
await props.server.refresh(["general"]);
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
title: "Server icon reset",
|
||||
text: "Your server icon was successfully reset.",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error resetting icon:", error);
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "error",
|
||||
title: "Reset failed",
|
||||
text: "Failed to reset server icon.",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
{{ data?.sftp_host }}
|
||||
</span>
|
||||
|
||||
<span class="text-xs uppercase text-secondary">server address</span>
|
||||
<span class="text-xs text-secondary">Server Address</span>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
@@ -41,9 +41,9 @@
|
||||
</div>
|
||||
<div class="-mt-2 flex flex-col gap-2 sm:mt-0 sm:flex-row">
|
||||
<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">
|
||||
{{ data?.sftp_username }}
|
||||
</span>
|
||||
@@ -57,12 +57,12 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-xs uppercase text-secondary">username</span>
|
||||
<span class="text-xs text-secondary">Username</span>
|
||||
</div>
|
||||
<div
|
||||
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">
|
||||
{{
|
||||
showPassword ? data?.sftp_password : "*".repeat(data?.sftp_password?.length ?? 0)
|
||||
@@ -89,7 +89,7 @@
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs uppercase text-secondary">password</span>
|
||||
<span class="text-xs text-secondary">Password</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@
|
||||
</form>
|
||||
</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">
|
||||
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
|
||||
<input
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="mb-1 mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!newAllocationName" type="submit">
|
||||
<SaveIcon /> Update Allocation
|
||||
<SaveIcon /> Update allocation
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
@@ -59,7 +59,29 @@
|
||||
/>
|
||||
|
||||
<div class="relative h-full w-full overflow-y-auto">
|
||||
<div v-if="data" class="flex h-full w-full flex-col justify-between gap-4">
|
||||
<div
|
||||
v-if="server.network?.error"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load network settings</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">
|
||||
We couldn't load your server's network settings. Here's what we know:
|
||||
<span class="break-all font-mono">{{ JSON.stringify(server.network.error) }}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['network'])">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
|
||||
<div class="flex h-full flex-col">
|
||||
<!-- Subdomain section -->
|
||||
<div class="card flex flex-col gap-4">
|
||||
@@ -94,7 +116,7 @@
|
||||
/>
|
||||
|
||||
<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
|
||||
class="w-full flex-none border-collapse truncate rounded-lg border-2 border-gray-300"
|
||||
@@ -108,7 +130,7 @@
|
||||
>
|
||||
{{ record.type }}
|
||||
</span>
|
||||
<span class="text-xs uppercase text-secondary">type</span>
|
||||
<span class="text-xs text-secondary">Type</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-2/6 py-3 md:w-1/3">
|
||||
@@ -118,7 +140,7 @@
|
||||
>
|
||||
{{ record.name }}
|
||||
</span>
|
||||
<span class="text-xs uppercase text-secondary">name</span>
|
||||
<span class="text-xs text-secondary">Name</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="w-3/6 py-3 pl-4 md:w-5/12 lg:w-5/12">
|
||||
@@ -128,7 +150,7 @@
|
||||
>
|
||||
{{ record.content }}
|
||||
</span>
|
||||
<span class="text-xs uppercase text-secondary">content</span>
|
||||
<span class="text-xs text-secondary">Content</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -155,7 +177,7 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="standard" color="brand" @click="showNewAllocationModal">
|
||||
<ButtonStyled type="standard" @click="showNewAllocationModal">
|
||||
<button class="!w-full sm:!w-auto">
|
||||
<PlusIcon />
|
||||
<span>New allocation</span>
|
||||
@@ -190,7 +212,7 @@
|
||||
<span class="text-md font-bold tracking-wide text-contrast">
|
||||
{{ allocation.name }}
|
||||
</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 class="flex flex-col gap-1">
|
||||
<span
|
||||
@@ -198,7 +220,7 @@
|
||||
>
|
||||
{{ allocation.port }}
|
||||
</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>
|
||||
@@ -247,6 +269,7 @@ import {
|
||||
SaveIcon,
|
||||
InfoIcon,
|
||||
UploadIcon,
|
||||
IssuesIcon,
|
||||
} from "@modrinth/assets";
|
||||
import { ButtonStyled, NewModal, ConfirmModal } from "@modrinth/ui";
|
||||
import { ref, computed, nextTick } from "vue";
|
||||
@@ -286,12 +309,11 @@ const addNewAllocation = async () => {
|
||||
|
||||
try {
|
||||
await props.server.network?.reserveAllocation(newAllocationName.value);
|
||||
await props.server.refresh(["network"]);
|
||||
|
||||
newAllocationModal.value?.hide();
|
||||
newAllocationName.value = "";
|
||||
|
||||
await props.server.refresh();
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
@@ -332,8 +354,8 @@ const confirmDeleteAllocation = async () => {
|
||||
if (allocationToDelete.value === null) return;
|
||||
|
||||
await props.server.network?.deleteAllocation(allocationToDelete.value);
|
||||
await props.server.refresh(["network"]);
|
||||
|
||||
await props.server.refresh();
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
@@ -349,12 +371,11 @@ const editAllocation = async () => {
|
||||
|
||||
try {
|
||||
await props.server.network?.updateAllocation(newAllocationPort.value, newAllocationName.value);
|
||||
await props.server.refresh(["network"]);
|
||||
|
||||
editAllocationModal.value?.hide();
|
||||
newAllocationName.value = "";
|
||||
|
||||
await props.server.refresh();
|
||||
|
||||
addNotification({
|
||||
group: "serverOptions",
|
||||
type: "success",
|
||||
|
||||
@@ -59,6 +59,11 @@ const preferences = {
|
||||
"When enabled, RAM will be displayed as bytes instead of a percentage in your server's Overview.",
|
||||
implemented: true,
|
||||
},
|
||||
hideSubdomainLabel: {
|
||||
displayName: "Hide subdomain label",
|
||||
description: "When enabled, the subdomain label will be hidden from the server header.",
|
||||
implemented: true,
|
||||
},
|
||||
autoRestart: {
|
||||
displayName: "Auto restart",
|
||||
description: "When enabled, your server will automatically restart if it crashes.",
|
||||
@@ -84,6 +89,7 @@ type UserPreferences = {
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
ramAsNumber: false,
|
||||
hideSubdomainLabel: false,
|
||||
autoRestart: false,
|
||||
powerDontAskAgain: 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