Compare commits
137 Commits
v0.9.1
...
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 | ||
|
|
acc379d14d | ||
|
|
5bc63a5ad3 | ||
|
|
a8630e93bc | ||
|
|
9574e8e639 | ||
|
|
14dacb2352 | ||
|
|
8baa2a72fb | ||
|
|
a5427f7287 | ||
|
|
9180f5c8d0 | ||
|
|
b5abce161f | ||
|
|
8b3ede4218 | ||
|
|
a4e024c690 | ||
|
|
e368e35e74 | ||
|
|
1e09305fb3 | ||
|
|
4d32bb2330 | ||
|
|
46710c9501 | ||
|
|
79d131c7eb | ||
|
|
b1955363a6 | ||
|
|
495dbbb7f8 | ||
|
|
e0d0736f7e | ||
|
|
12bfebd8b5 | ||
|
|
af791f78b7 | ||
|
|
0f4af98a21 | ||
|
|
75b357a069 | ||
|
|
d7814e115d | ||
|
|
24295ea482 | ||
|
|
701bf853d5 | ||
|
|
7fd3d737b8 | ||
|
|
497b0bca0b | ||
|
|
208015a911 | ||
|
|
8abe2283d7 | ||
|
|
9e97c068d8 | ||
|
|
abbfb3ca2f | ||
|
|
5c8e7a8b38 | ||
|
|
0d7934e3b8 | ||
|
|
e4cc8ef509 | ||
|
|
d670a5cbb6 | ||
|
|
227386bb0d | ||
|
|
abd679d716 | ||
|
|
ec5e3b0050 | ||
|
|
494616e9f2 | ||
|
|
82f81dc154 | ||
|
|
316fe72ea5 | ||
|
|
6266f29b99 | ||
|
|
fd9653e283 | ||
|
|
b2f4366415 | ||
|
|
f859c34442 | ||
|
|
c52d5e9a74 | ||
|
|
021fee616d | ||
|
|
0409fcec2f | ||
|
|
8e754cfeb5 | ||
|
|
adf3d9540d | ||
|
|
d5f2ada8f7 | ||
|
|
a01da5452c | ||
|
|
17d61277fa | ||
|
|
bb3de4b74b | ||
|
|
01fe08f079 | ||
|
|
8b7547ae38 | ||
|
|
0437503b75 | ||
|
|
2fea772ffb | ||
|
|
24765db045 | ||
|
|
82393f2ae7 | ||
|
|
c86c98d000 | ||
|
|
4d9741c424 | ||
|
|
81ec068747 |
@@ -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"]
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
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 }}
|
||||
|
||||
14
.github/workflows/theseus-release.yml
vendored
14
.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/**'
|
||||
@@ -53,11 +55,11 @@ jobs:
|
||||
!target/release/bundle/*/*.app.tar.gz
|
||||
!target/release/bundle/*/*.app.tar.gz.sig
|
||||
|
||||
!target/release/bundle/*/*.AppImage
|
||||
!target/release/bundle/*/*.AppImage.tar.gz
|
||||
!target/release/bundle/*/*.AppImage.tar.gz.sig
|
||||
!target/release/bundle/*/*.deb
|
||||
!target/release/bundle/*/*.rpm
|
||||
!target/release/bundle/appimage/*.AppImage
|
||||
!target/release/bundle/appimage/*.AppImage.tar.gz
|
||||
!target/release/bundle/appimage/*.AppImage.tar.gz.sig
|
||||
!target/release/bundle/deb/*.deb
|
||||
!target/release/bundle/rpm/*.rpm
|
||||
|
||||
!target/release/bundle/msi/*.msi
|
||||
!target/release/bundle/msi/*.msi.zip
|
||||
|
||||
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>
|
||||
859
Cargo.lock
generated
859
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 ="e88d4a1" }
|
||||
wry = { git = "https://github.com/modrinth/wry", rev = "51907c6" }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@modrinth/app-frontend",
|
||||
"private": true,
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
|
||||
import { RouterView, useRouter, useRoute } from 'vue-router'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
LogInIcon,
|
||||
CompassIcon,
|
||||
DownloadIcon,
|
||||
HomeIcon,
|
||||
LeftArrowIcon,
|
||||
LibraryIcon,
|
||||
LogInIcon,
|
||||
LogOutIcon,
|
||||
MaximizeIcon,
|
||||
MinimizeIcon,
|
||||
PlusIcon,
|
||||
RestoreIcon,
|
||||
RightArrowIcon,
|
||||
SettingsIcon,
|
||||
XIcon,
|
||||
DownloadIcon,
|
||||
CompassIcon,
|
||||
MinimizeIcon,
|
||||
MaximizeIcon,
|
||||
RestoreIcon,
|
||||
LogOutIcon,
|
||||
RightArrowIcon,
|
||||
LeftArrowIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
|
||||
import { useLoading, useTheming } from '@/store/state'
|
||||
@@ -32,12 +32,12 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
|
||||
import { handleError, useNotifications } from '@/store/notifications.js'
|
||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { type } from '@tauri-apps/plugin-os'
|
||||
import { isDev, getOS, restartApp } from '@/helpers/utils.js'
|
||||
import { initAnalytics, debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
|
||||
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
|
||||
import { install_from_file } from './helpers/pack'
|
||||
import { create_profile_and_install_from_file } from './helpers/pack'
|
||||
import { useError } from '@/store/error.js'
|
||||
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
|
||||
@@ -51,7 +51,7 @@ import { renderString } from '@modrinth/utils'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { get as getCreds, logout, login } from '@/helpers/mr_auth.js'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -296,7 +296,7 @@ async function handleCommand(e) {
|
||||
if (e.event === 'RunMRPack') {
|
||||
// RunMRPack should directly install a local mrpack given a path
|
||||
if (e.path.endsWith('.mrpack')) {
|
||||
await install_from_file(e.path).catch(handleError)
|
||||
await create_profile_and_install_from_file(e.path).catch(handleError)
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
<script setup>
|
||||
import { onUnmounted, ref, computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { SpinnerIcon, GameIcon, TimerIcon, StopCircleIcon, PlayIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Avatar } from '@modrinth/ui'
|
||||
import {
|
||||
DownloadIcon,
|
||||
GameIcon,
|
||||
PlayIcon,
|
||||
SpinnerIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, ButtonStyled } from '@modrinth/ui'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { kill, run } from '@/helpers/profile'
|
||||
import { finish_install, kill, run } from '@/helpers/profile'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { handleError } from '@/store/state.js'
|
||||
@@ -42,7 +49,8 @@ const modLoading = computed(
|
||||
currentEvent.value === 'installing' ||
|
||||
(currentEvent.value === 'launched' && !playing.value),
|
||||
)
|
||||
const installing = computed(() => props.instance.install_stage !== 'installed')
|
||||
const installing = computed(() => props.instance.install_stage.includes('installing'))
|
||||
const installed = computed(() => props.instance.install_stage === 'installed')
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -84,6 +92,12 @@ const stop = async (e, context) => {
|
||||
})
|
||||
}
|
||||
|
||||
const repair = async (e) => {
|
||||
e?.stopPropagation()
|
||||
|
||||
await finish_install(props.instance)
|
||||
}
|
||||
|
||||
const openFolder = async () => {
|
||||
await showProfileInFolder(props.instance.path)
|
||||
}
|
||||
@@ -195,6 +209,15 @@ onUnmounted(() => unlisten())
|
||||
class="animate-spin w-8 h-8"
|
||||
tabindex="-1"
|
||||
/>
|
||||
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="'Repair'"
|
||||
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else size="large" color="brand" circular>
|
||||
<button
|
||||
v-tooltip="'Play'"
|
||||
|
||||
@@ -199,16 +199,16 @@
|
||||
<script setup>
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import {
|
||||
PlusIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
CodeIcon,
|
||||
FolderOpenIcon,
|
||||
InfoIcon,
|
||||
FolderSearchIcon,
|
||||
InfoIcon,
|
||||
PlusIcon,
|
||||
UpdatedIcon,
|
||||
UploadIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, Chips, Checkbox } from '@modrinth/ui'
|
||||
import { Avatar, Button, Checkbox, Chips } from '@modrinth/ui'
|
||||
import { computed, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { get_loaders } from '@/helpers/tags'
|
||||
import { create } from '@/helpers/profile'
|
||||
@@ -218,7 +218,7 @@ import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import Multiselect from 'vue-multiselect'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_from_file } from '@/helpers/pack.js'
|
||||
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
|
||||
import {
|
||||
get_default_launcher_path,
|
||||
get_importable_instances,
|
||||
@@ -263,7 +263,7 @@ defineExpose({
|
||||
hide()
|
||||
const { paths } = event.payload
|
||||
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
|
||||
await install_from_file(paths[0]).catch(handleError)
|
||||
await create_profile_and_install_from_file(paths[0]).catch(handleError)
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileDrop',
|
||||
})
|
||||
@@ -419,7 +419,7 @@ const openFile = async () => {
|
||||
const newProject = await open({ multiple: false })
|
||||
if (!newProject) return
|
||||
hide()
|
||||
await install_from_file(newProject.path ?? newProject).catch(handleError)
|
||||
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
|
||||
|
||||
trackEvent('InstanceCreate', {
|
||||
source: 'CreationModalFileOpen',
|
||||
|
||||
@@ -20,7 +20,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
credentials: unknown | null
|
||||
signIn: () => void2
|
||||
signIn: () => void
|
||||
}>()
|
||||
|
||||
const userCredentials = computed(() => props.credentials)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
import { XIcon, DownloadIcon } from '@modrinth/assets'
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import { install as pack_install } from '@/helpers/pack'
|
||||
import { create_profile_and_install as pack_install } from '@/helpers/pack'
|
||||
import { ref } from 'vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { handleError } from '@/store/state.js'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
|
||||
import { create } from './profile'
|
||||
|
||||
// Installs pack from a version ID
|
||||
export async function install(projectId, versionId, packTitle, iconUrl) {
|
||||
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
|
||||
const location = {
|
||||
type: 'fromVersionId',
|
||||
project_id: projectId,
|
||||
@@ -28,8 +28,18 @@ export async function install(projectId, versionId, packTitle, iconUrl) {
|
||||
return await invoke('plugin:pack|pack_install', { location, profile })
|
||||
}
|
||||
|
||||
export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
|
||||
const location = {
|
||||
type: 'fromVersionId',
|
||||
project_id: projectId,
|
||||
version_id: versionId,
|
||||
title,
|
||||
}
|
||||
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
|
||||
}
|
||||
|
||||
// Installs pack from a path
|
||||
export async function install_from_file(path) {
|
||||
export async function create_profile_and_install_from_file(path) {
|
||||
const location = {
|
||||
type: 'fromFile',
|
||||
path: path,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
* and deserialized into a usable JS object.
|
||||
*/
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { install_to_existing_profile } from '@/helpers/pack.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
|
||||
/// Add instance
|
||||
/*
|
||||
@@ -186,3 +188,17 @@ export async function edit(path, editProfile) {
|
||||
export async function edit_icon(path, iconPath) {
|
||||
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
|
||||
}
|
||||
|
||||
export async function finish_install(instance) {
|
||||
if (instance.install_stage !== 'pack_installed') {
|
||||
let linkedData = instance.linked_data
|
||||
await install_to_existing_profile(
|
||||
linkedData.project_id,
|
||||
linkedData.version_id,
|
||||
instance.name,
|
||||
instance.path,
|
||||
).catch(handleError)
|
||||
} else {
|
||||
await install(instance.path, false).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
7
apps/app-frontend/src/helpers/types.d.ts
vendored
7
apps/app-frontend/src/helpers/types.d.ts
vendored
@@ -32,7 +32,12 @@ type GameInstance = {
|
||||
hooks: Hooks
|
||||
}
|
||||
|
||||
type InstallStage = 'installed' | 'installing' | 'pack_installing' | 'not_installed'
|
||||
type InstallStage =
|
||||
| 'installed'
|
||||
| 'minecraft_installing'
|
||||
| 'pack_installed'
|
||||
| 'pack_installing'
|
||||
| 'not_installed'
|
||||
|
||||
type LinkedData = {
|
||||
project_id: ModrinthId
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
"app.settings.tabs.resource-management": {
|
||||
"message": "Resource management"
|
||||
},
|
||||
"instance.filter.disabled": {
|
||||
"message": "Disabled projects"
|
||||
},
|
||||
"instance.filter.updates-available": {
|
||||
"message": "Updates available"
|
||||
},
|
||||
|
||||
@@ -30,9 +30,23 @@
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
|
||||
<ButtonStyled
|
||||
v-if="instance.install_stage.includes('installing')"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Installing...</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-else-if="instance.install_stage !== 'installed'"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button @click="repairInstance()">
|
||||
<DownloadIcon />
|
||||
Repair
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
||||
<button @click="stopInstance('InstancePage')">
|
||||
<StopCircleIcon />
|
||||
@@ -137,38 +151,39 @@
|
||||
<script setup>
|
||||
import {
|
||||
Avatar,
|
||||
ContentPageHeader,
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
ContentPageHeader,
|
||||
LoadingIndicator,
|
||||
OverflowMenu,
|
||||
} from '@modrinth/ui'
|
||||
import {
|
||||
UserPlusIcon,
|
||||
ServerIcon,
|
||||
PackageIcon,
|
||||
SettingsIcon,
|
||||
PlayIcon,
|
||||
StopCircleIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
ClipboardCopyIcon,
|
||||
PlusIcon,
|
||||
ExternalIcon,
|
||||
HashIcon,
|
||||
GlobeIcon,
|
||||
EyeIcon,
|
||||
XIcon,
|
||||
CheckCircleIcon,
|
||||
UpdatedIcon,
|
||||
MoreVerticalIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
GameIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
MoreVerticalIcon,
|
||||
PackageIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TimerIcon,
|
||||
UpdatedIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { get, get_full_path, kill, run } from '@/helpers/profile'
|
||||
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
|
||||
import { get_by_profile_path } from '@/helpers/process'
|
||||
import { process_listener, profile_listener } from '@/helpers/events'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref, onUnmounted, computed, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
|
||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
@@ -294,6 +309,10 @@ const stopInstance = async (context) => {
|
||||
})
|
||||
}
|
||||
|
||||
const repairInstance = async () => {
|
||||
await finish_install(instance.value)
|
||||
}
|
||||
|
||||
const handleRightClick = (event) => {
|
||||
const baseOptions = [
|
||||
{ name: 'add_content' },
|
||||
|
||||
@@ -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,14 +2,14 @@ import { defineStore } from 'pinia'
|
||||
import {
|
||||
add_project_from_version,
|
||||
check_installed,
|
||||
list,
|
||||
get,
|
||||
get_projects,
|
||||
list,
|
||||
remove_project,
|
||||
} from '@/helpers/profile.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { get_project, get_version_many } from '@/helpers/cache.js'
|
||||
import { install as packInstall } from '@/helpers/pack.js'
|
||||
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
|
||||
import { trackEvent } from '@/helpers/analytics.js'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
|
||||
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"
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "theseus_gui"
|
||||
version = "0.9.1"
|
||||
version = "0.9.3"
|
||||
description = "The Modrinth App is a desktop application for managing your Minecraft mods"
|
||||
license = "GPL-3.0-only"
|
||||
repository = "https://github.com/modrinth/code/apps/app/"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
)]
|
||||
|
||||
use native_dialog::{MessageDialog, MessageType};
|
||||
use std::env;
|
||||
use tauri::{Listener, Manager};
|
||||
use theseus::prelude::*;
|
||||
|
||||
@@ -29,7 +30,12 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
||||
theseus::EventState::init(app.clone()).await?;
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
{
|
||||
'updater: {
|
||||
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
|
||||
State::init().await?;
|
||||
break 'updater;
|
||||
}
|
||||
|
||||
use tauri_plugin_updater::UpdaterExt;
|
||||
|
||||
let updater = app.updater_builder().build()?;
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "Modrinth App",
|
||||
"version": "0.9.1",
|
||||
"version": "0.9.3",
|
||||
"mainBinaryName": "Modrinth App",
|
||||
"identifier": "ModrinthApp",
|
||||
"plugins": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ import { ChevronRightIcon } from "@modrinth/assets";
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
{
|
||||
// Clean.io
|
||||
src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
|
||||
},
|
||||
// {
|
||||
// // Clean.io
|
||||
// src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
|
||||
// },
|
||||
{
|
||||
// Aditude
|
||||
src: "https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js",
|
||||
|
||||
@@ -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(() =>
|
||||
[
|
||||
{
|
||||
@@ -411,18 +455,21 @@ Per section 5.2 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
||||
name: "Insufficient",
|
||||
resultingMessage: `## Insufficient Summary
|
||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) Your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||
},
|
||||
{
|
||||
name: "Repeat of title",
|
||||
resultingMessage: `## Insufficient Summary
|
||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not be the same as your project's Title. Your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||
},
|
||||
{
|
||||
name: "Formatting",
|
||||
resultingMessage: `## Insufficient Summary
|
||||
Per section 5.3 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) your Summary can not include any extra formatting such as lists, or links. Your project summary should provide a brief overview of your project that informs and entices users.
|
||||
|
||||
This is the first thing most people will see about your mod other than the Logo, so it's important it be accurate, reasonably detailed, and exciting.`,
|
||||
},
|
||||
],
|
||||
@@ -559,7 +606,9 @@ Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
||||
name: "Inaccurate (modpack)",
|
||||
resultingMessage: `## Incorrect Environment Information
|
||||
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
|
||||
|
||||
For a brief rundown of how this works:
|
||||
|
||||
Some modpacks can be client-side, usually aimed at providing utility and optimization while allowing the player to join an unmodded server, for instance, [Fabulously Optimized](https://modrinth.com/modpack/fabulously-optimized).
|
||||
Most other modpacks that change how the game is played are going to be required on both the client and server, like the modpack [Dying Light](https://modrinth.com/modpack/dying-light).
|
||||
When in doubt, test for yourself or check the requirements of the mods in your pack.`,
|
||||
@@ -568,10 +617,11 @@ When in doubt, test for yourself or check the requirements of the mods in your p
|
||||
name: "Inaccurate (mod)",
|
||||
resultingMessage: `## Environment Information
|
||||
Per section 5.1 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous), it is important that the metadata of your projects is accurate, including whether the project runs on the client or server side.
|
||||
|
||||
For a brief rundown of how this works:
|
||||
**Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium).
|
||||
**Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree).
|
||||
A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon).`,
|
||||
- **Client side** refers to a mod that is only required by the client, like [Sodium](https://modrinth.com/mod/sodium).
|
||||
- **Server side** mods change the behavior of the server without the client needing the mod, like Datapacks, recipes, or server-side behaviors, like [Falling Tree](https://modrinth.com/mod/fallingtree).
|
||||
- A mod that adds features, entities, or new blocks and items, generally will be required on **both** the server and the client, for example [Cobblemon](https://modrinth.com/mod/cobblemon).`,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -602,6 +652,7 @@ Per section 5.5 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#m
|
||||
name: "Incorrect additional files",
|
||||
resultingMessage: `## Incorrect Use of Additional Files
|
||||
It looks like you've uploaded multiple \`mod.jar\` files to one Version as Additional Files. Per section 5.7 of [Modrinth's Content Rules](https://modrinth.com/legal/rules#miscellaneous) each Version of your project must include only one \`mod.jar\` that corresponds to its respective Minecraft and loader versions. This allows users to easily find and download the file they need for the version they're on with ease. The Additional Files feature can be used for things like a \`Sources.jar\`.
|
||||
|
||||
Please upload each version of your mod separately, thank you.`,
|
||||
},
|
||||
{
|
||||
@@ -629,7 +680,9 @@ It looks like you've selected loaders for your Resource Pack that are causing it
|
||||
name: "Re-upload",
|
||||
resultingMessage: `## Reuploads are forbidden
|
||||
This project appears to contain content from %ORIGINAL_PROJECT% by %ORIGINAL_AUTHOR%.
|
||||
|
||||
Per section 4 of [Modrinth's Content Rules](https://modrinth.com/legal/rules) this is strictly forbidden.
|
||||
|
||||
If you believe this is an error, or you can verify you are the creator and rightful owner of this content please let us know. Otherwise, we ask that you **do not resubmit this project**.`,
|
||||
fillers: [
|
||||
{
|
||||
@@ -847,6 +900,7 @@ async function generateMessage() {
|
||||
for (const mod of mods) {
|
||||
message.value += `- ${mod}\n`;
|
||||
}
|
||||
message.value += "\n";
|
||||
}
|
||||
|
||||
if (modPackData.value && modPackData.value.length > 0) {
|
||||
@@ -913,7 +967,7 @@ async function generateMessage() {
|
||||
permanentNoMods.length > 0 ||
|
||||
unidentifiedMods.length > 0
|
||||
) {
|
||||
message.value += "## Copyrighted Content \n";
|
||||
message.value += "## Copyrighted content \n";
|
||||
|
||||
printMods(
|
||||
attributeMods,
|
||||
@@ -998,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];
|
||||
|
||||
@@ -1021,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>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.downloads"
|
||||
ref="tinyDownloadChart"
|
||||
:title="`Downloads since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
:title="`Downloads`"
|
||||
color="var(--color-brand)"
|
||||
:value="formatNumber(analytics.formattedData.value.downloads.sum, false)"
|
||||
:data="analytics.formattedData.value.downloads.chart.sumData"
|
||||
@@ -33,7 +33,7 @@
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.views"
|
||||
ref="tinyViewChart"
|
||||
:title="`Page views since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
:title="`Views`"
|
||||
color="var(--color-blue)"
|
||||
:value="formatNumber(analytics.formattedData.value.views.sum, false)"
|
||||
:data="analytics.formattedData.value.views.chart.sumData"
|
||||
@@ -50,7 +50,7 @@
|
||||
<CompactChart
|
||||
v-if="analytics.formattedData.value.revenue"
|
||||
ref="tinyRevenueChart"
|
||||
:title="`Revenue since ${dayjs(startDate).format('MMM D, YYYY')}`"
|
||||
:title="`Revenue`"
|
||||
color="var(--color-purple)"
|
||||
:value="formatMoney(analytics.formattedData.value.revenue.sum, false)"
|
||||
:data="analytics.formattedData.value.revenue.chart.sumData"
|
||||
@@ -71,6 +71,9 @@
|
||||
<span class="label__title">
|
||||
{{ formatCategoryHeader(selectedChart) }}
|
||||
</span>
|
||||
<span class="label__subtitle">
|
||||
{{ formattedCategorySubtitle }}
|
||||
</span>
|
||||
</h2>
|
||||
<div class="chart-controls__buttons">
|
||||
<Button v-tooltip="'Toggle project colors'" icon-only @click="onToggleColors">
|
||||
@@ -83,11 +86,12 @@
|
||||
<UpdatedIcon />
|
||||
</Button>
|
||||
<DropdownSelect
|
||||
class="range-dropdown"
|
||||
v-model="selectedRange"
|
||||
:options="selectableRanges"
|
||||
:options="ranges"
|
||||
name="Time range"
|
||||
:display-name="
|
||||
(o: (typeof selectableRanges)[number] | undefined) => o?.label || 'Custom'
|
||||
(o: RangeObject) => o?.getLabel([startDate, endDate]) ?? 'Loading...'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@@ -322,7 +326,7 @@ const props = withDefaults(
|
||||
* @deprecated Use `ranges` instead
|
||||
*/
|
||||
resoloutions?: Record<string, number>;
|
||||
ranges?: Record<number, [string, number] | string>;
|
||||
ranges?: RangeObject[];
|
||||
personal?: boolean;
|
||||
}>(),
|
||||
{
|
||||
@@ -335,12 +339,6 @@ const props = withDefaults(
|
||||
|
||||
const projects = ref(props.projects || []);
|
||||
|
||||
const selectableRanges = Object.entries(props.ranges).map(([duration, extra]) => ({
|
||||
label: typeof extra === "string" ? extra : extra[0],
|
||||
value: Number(duration),
|
||||
res: typeof extra === "string" ? Number(duration) : extra[1],
|
||||
}));
|
||||
|
||||
// const selectedChart = ref('downloads')
|
||||
const selectedChart = computed({
|
||||
get: () => {
|
||||
@@ -413,33 +411,78 @@ const isUsingProjectColors = computed({
|
||||
},
|
||||
});
|
||||
|
||||
const startDate = ref(dayjs().startOf("day"));
|
||||
const endDate = ref(dayjs().endOf("day"));
|
||||
const timeResolution = ref(30);
|
||||
|
||||
onBeforeMount(() => {
|
||||
// Load cached data and range from localStorage - cache.
|
||||
if (import.meta.client) {
|
||||
const rangeLabel = localStorage.getItem("analyticsSelectedRange");
|
||||
if (rangeLabel) {
|
||||
const range = props.ranges.find((r) => r.getLabel([dayjs(), dayjs()]) === rangeLabel)!;
|
||||
|
||||
if (range !== undefined) {
|
||||
internalRange.value = range;
|
||||
const ranges = range.getDates(dayjs());
|
||||
timeResolution.value = range.timeResolution;
|
||||
startDate.value = ranges.startDate;
|
||||
endDate.value = ranges.endDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
if (internalRange.value === null) {
|
||||
internalRange.value = props.ranges.find(
|
||||
(r) => r.getLabel([dayjs(), dayjs()]) === "Previous 30 days",
|
||||
)!;
|
||||
}
|
||||
|
||||
const ranges = selectedRange.value.getDates(dayjs());
|
||||
startDate.value = ranges.startDate;
|
||||
endDate.value = ranges.endDate;
|
||||
timeResolution.value = selectedRange.value.timeResolution;
|
||||
});
|
||||
|
||||
const internalRange: Ref<RangeObject> = ref(null as unknown as RangeObject);
|
||||
|
||||
const selectedRange = computed({
|
||||
get: () => {
|
||||
return internalRange.value;
|
||||
},
|
||||
set: (newRange) => {
|
||||
const ranges = newRange.getDates(dayjs());
|
||||
startDate.value = ranges.startDate;
|
||||
endDate.value = ranges.endDate;
|
||||
timeResolution.value = newRange.timeResolution;
|
||||
|
||||
internalRange.value = newRange;
|
||||
|
||||
if (import.meta.client) {
|
||||
localStorage.setItem(
|
||||
"analyticsSelectedRange",
|
||||
internalRange.value?.getLabel([dayjs(), dayjs()]) ?? "Previous 30 days",
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const analytics = useFetchAllAnalytics(
|
||||
resetCharts,
|
||||
projects,
|
||||
selectedDisplayProjects,
|
||||
props.personal,
|
||||
startDate,
|
||||
endDate,
|
||||
timeResolution,
|
||||
);
|
||||
|
||||
const { startDate, endDate, timeRange, timeResolution } = analytics;
|
||||
|
||||
const selectedRange = computed({
|
||||
get: () => {
|
||||
return (
|
||||
selectableRanges.find((option) => option.value === timeRange.value) || {
|
||||
label: "Custom",
|
||||
value: timeRange.value,
|
||||
}
|
||||
);
|
||||
},
|
||||
set: (newRange: { label: string; value: number; res?: number }) => {
|
||||
timeRange.value = newRange.value;
|
||||
startDate.value = Date.now() - timeRange.value * 60 * 1000;
|
||||
endDate.value = Date.now();
|
||||
|
||||
if (newRange?.res) {
|
||||
timeResolution.value = newRange.res;
|
||||
}
|
||||
},
|
||||
const formattedCategorySubtitle = computed(() => {
|
||||
return (
|
||||
selectedRange.value?.getLabel([dayjs(startDate.value), dayjs(endDate.value)]) ?? "Loading..."
|
||||
);
|
||||
});
|
||||
|
||||
const selectedDataSet = computed(() => {
|
||||
@@ -484,6 +527,9 @@ const onToggleColors = () => {
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* @deprecated Use `ranges` instead
|
||||
*/
|
||||
const defaultResoloutions: Record<string, number> = {
|
||||
"5 minutes": 5,
|
||||
"30 minutes": 30,
|
||||
@@ -493,17 +539,169 @@ const defaultResoloutions: Record<string, number> = {
|
||||
"A week": 10080,
|
||||
};
|
||||
|
||||
const defaultRanges: Record<number, [string, number] | string> = {
|
||||
30: ["Last 30 minutes", 1],
|
||||
60: ["Last hour", 5],
|
||||
720: ["Last 12 hours", 15],
|
||||
1440: ["Last day", 60],
|
||||
10080: ["Last week", 720],
|
||||
43200: ["Last month", 1440],
|
||||
129600: ["Last quarter", 10080],
|
||||
525600: ["Last year", 20160],
|
||||
1051200: ["Last two years", 40320],
|
||||
type DateRange = { startDate: dayjs.Dayjs; endDate: dayjs.Dayjs };
|
||||
|
||||
type RangeObject = {
|
||||
getLabel: (dateRange: [dayjs.Dayjs, dayjs.Dayjs]) => string;
|
||||
getDates: (currentDate: dayjs.Dayjs) => DateRange;
|
||||
// A time resolution in minutes.
|
||||
timeResolution: number;
|
||||
};
|
||||
|
||||
const defaultRanges: RangeObject[] = [
|
||||
{
|
||||
getLabel: () => "Previous 30 minutes",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(30, "minute"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous hour",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 5,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous 12 hours",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(12, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 12,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous 24 hours",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "day"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Today",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("day"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Yesterday",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "day").startOf("day"),
|
||||
endDate: dayjs(currentDate).startOf("day").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 30,
|
||||
},
|
||||
{
|
||||
getLabel: () => "This week",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("week").add(1, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 360,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Last week",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "week").startOf("week").add(1, "hour"),
|
||||
endDate: dayjs(currentDate).startOf("week").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous 7 days",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("day").subtract(7, "day").add(1, "hour"),
|
||||
endDate: currentDate.startOf("day"),
|
||||
}),
|
||||
timeResolution: 720,
|
||||
},
|
||||
{
|
||||
getLabel: () => "This month",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("month").add(1, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Last month",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "month").startOf("month").add(1, "hour"),
|
||||
endDate: dayjs(currentDate).startOf("month").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous 30 days",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("day").subtract(30, "day").add(1, "hour"),
|
||||
endDate: currentDate.startOf("day"),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "This quarter",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("quarter").add(1, "hour"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Last quarter",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "quarter").startOf("quarter").add(1, "hour"),
|
||||
endDate: dayjs(currentDate).startOf("quarter").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 1440,
|
||||
},
|
||||
{
|
||||
getLabel: () => "This year",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).startOf("year"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 20160,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Last year",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "year").startOf("year"),
|
||||
endDate: dayjs(currentDate).startOf("year").subtract(1, "second"),
|
||||
}),
|
||||
timeResolution: 20160,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous year",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(1, "year"),
|
||||
endDate: dayjs(currentDate),
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
{
|
||||
getLabel: () => "Previous two years",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(currentDate).subtract(2, "year"),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
{
|
||||
getLabel: () => "All Time",
|
||||
getDates: (currentDate: dayjs.Dayjs) => ({
|
||||
startDate: dayjs(0),
|
||||
endDate: currentDate,
|
||||
}),
|
||||
timeResolution: 40320,
|
||||
},
|
||||
];
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -524,6 +722,20 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
||||
min-height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.label__subtitle {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.range-dropdown {
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.chart-area {
|
||||
@@ -688,6 +900,7 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.percentage-bar {
|
||||
grid-area: bar;
|
||||
width: 100%;
|
||||
@@ -696,6 +909,7 @@ const defaultRanges: Record<number, [string, number] | string> = {
|
||||
border: 1px solid var(--color-button-bg);
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
|
||||
span {
|
||||
display: block;
|
||||
height: 100%;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -35,7 +33,7 @@ defineProps({
|
||||
const viewMode = ref("open");
|
||||
const reports = ref([]);
|
||||
|
||||
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report"));
|
||||
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
|
||||
|
||||
rawReports = rawReports.value.map((report) => {
|
||||
report.item_id = report.item_id.replace(/"/g, "");
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Auto backup</div>
|
||||
<p class="m-0">
|
||||
Automatically create a backup of your server every
|
||||
<strong>{{ autoBackupInterval == 1 ? "hour" : `${autoBackupInterval} hours` }}</strong>
|
||||
Automatically create a backup of your server
|
||||
<strong>{{ backupIntervalsLabel.toLowerCase() }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -22,54 +22,19 @@
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Interval</div>
|
||||
<p class="m-0">
|
||||
The amount of hours between each backup. This will only backup your server if it has
|
||||
been modified since the last backup.
|
||||
The amount of time between each backup. This will only backup your server if it has been
|
||||
modified since the last backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-contrast">
|
||||
<div
|
||||
class="flex w-fit items-center rounded-xl border border-solid border-button-border bg-table-alternateRow"
|
||||
>
|
||||
<button
|
||||
class="rounded-l-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
@click="autoBackupInterval = Math.max(autoBackupInterval - 1, 1)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="-2 0 18 2">
|
||||
<path
|
||||
d="M18,12H6"
|
||||
transform="translate(-5 -11)"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<input
|
||||
id="auto-backup-interval"
|
||||
v-model="autoBackupInterval"
|
||||
class="w-16 !appearance-none text-center [&&]:bg-transparent [&&]:focus:shadow-none"
|
||||
type="number"
|
||||
style="-moz-appearance: textfield; appearance: none"
|
||||
min="1"
|
||||
max="24"
|
||||
step="1"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="rounded-r-xl p-3 text-secondary enabled:hover:text-contrast [&&]:bg-transparent enabled:[&&]:hover:bg-button-bg"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
@click="autoBackupInterval = Math.min(autoBackupInterval + 1, 24)"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
</div>
|
||||
{{ autoBackupInterval == 1 ? "hour" : "hours" }}
|
||||
</div>
|
||||
<UiServersTeleportDropdownMenu
|
||||
:id="'interval-field'"
|
||||
v-model="backupIntervalsLabel"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
name="interval"
|
||||
:options="Object.keys(backupIntervals)"
|
||||
placeholder="Backup interval"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
@@ -92,7 +57,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import { PlusIcon, XIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { XIcon, SaveIcon } from "@modrinth/assets";
|
||||
import { ref, computed } from "vue";
|
||||
import type { Server } from "~/composables/pyroServers";
|
||||
|
||||
@@ -104,19 +69,25 @@ const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null);
|
||||
const autoBackupEnabled = ref(false);
|
||||
const autoBackupInterval = ref(6);
|
||||
const isLoadingSettings = ref(true);
|
||||
const isSaving = ref(false);
|
||||
|
||||
const validatedBackupInterval = computed(() => {
|
||||
const roundedValue = Math.round(autoBackupInterval.value);
|
||||
const backupIntervals = {
|
||||
"Every 3 hours": 3,
|
||||
"Every 6 hours": 6,
|
||||
"Every 12 hours": 12,
|
||||
Daily: 24,
|
||||
};
|
||||
|
||||
if (roundedValue < 1) {
|
||||
return 1;
|
||||
} else if (roundedValue > 24) {
|
||||
return 24;
|
||||
}
|
||||
return roundedValue;
|
||||
const backupIntervalsLabel = ref<keyof typeof backupIntervals>("Every 6 hours");
|
||||
|
||||
const autoBackupInterval = computed({
|
||||
get: () => backupIntervals[backupIntervalsLabel.value],
|
||||
set: (value) => {
|
||||
const [label] =
|
||||
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || [];
|
||||
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals;
|
||||
},
|
||||
});
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
@@ -124,7 +95,7 @@ const hasChanges = computed(() => {
|
||||
|
||||
return (
|
||||
autoBackupEnabled.value !== initialSettings.value.enabled ||
|
||||
autoBackupInterval.value !== initialSettings.value.interval
|
||||
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -135,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({
|
||||
@@ -143,6 +115,7 @@ const fetchSettings = async () => {
|
||||
text: "Failed to load backup settings",
|
||||
type: "error",
|
||||
});
|
||||
return false;
|
||||
} finally {
|
||||
isLoadingSettings.value = false;
|
||||
}
|
||||
@@ -182,14 +155,12 @@ const saveSettings = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
watch(autoBackupInterval, () => {
|
||||
autoBackupInterval.value = validatedBackupInterval.value;
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="isDragging"
|
||||
:class="[
|
||||
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white',
|
||||
overlayClass,
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UploadIcon class="mx-auto h-16 w-16" />
|
||||
<p class="mt-2 text-xl">
|
||||
Drop {{ type ? type.toLocaleLowerCase() : "file" }}s here to upload
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from "@modrinth/assets";
|
||||
import { ref } from "vue";
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "filesDropped", files: File[]): void;
|
||||
}>();
|
||||
|
||||
defineProps<{
|
||||
overlayClass?: string;
|
||||
type?: string;
|
||||
}>();
|
||||
|
||||
const isDragging = ref(false);
|
||||
const dragCounter = ref(0);
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
if (!event.dataTransfer?.types.includes("application/pyro-file-move")) {
|
||||
dragCounter.value++;
|
||||
isDragging.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
dragCounter.value--;
|
||||
if (dragCounter.value === 0) {
|
||||
isDragging.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
isDragging.value = false;
|
||||
dragCounter.value = 0;
|
||||
|
||||
const isInternalMove = event.dataTransfer?.types.includes("application/pyro-file-move");
|
||||
if (isInternalMove) return;
|
||||
|
||||
const files = event.dataTransfer?.files;
|
||||
if (files) {
|
||||
emit("filesDropped", Array.from(files));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Normal file
306
apps/frontend/src/components/ui/servers/FilesUploadDropdown.vue
Normal file
@@ -0,0 +1,306 @@
|
||||
<template>
|
||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||
<div
|
||||
ref="statusContentRef"
|
||||
:class="['flex flex-col p-4 text-sm text-contrast', $attrs.class]"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{ props.fileType ? props.fileType : "File" }} Uploads
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : "" }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<UiServersPanelSpinner
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="
|
||||
item.status === 'error' ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FolderOpenIcon, CheckCircleIcon, XCircleIcon } from "@modrinth/assets";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, nextTick } from "vue";
|
||||
|
||||
interface UploadItem {
|
||||
file: File;
|
||||
progress: number;
|
||||
status: "pending" | "uploading" | "completed" | "error" | "cancelled" | "incorrect-type";
|
||||
size: string;
|
||||
uploader?: any;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentPath: string;
|
||||
fileType?: string;
|
||||
marginBottom?: number;
|
||||
acceptedTypes?: Array<string>;
|
||||
fs: FSModule;
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "uploadComplete"): void;
|
||||
}>();
|
||||
|
||||
const uploadStatusRef = ref<HTMLElement | null>(null);
|
||||
const statusContentRef = ref<HTMLElement | null>(null);
|
||||
const uploadQueue = ref<UploadItem[]>([]);
|
||||
|
||||
const isUploading = computed(() => uploadQueue.value.length > 0);
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((item) => item.status === "pending" || item.status === "uploading"),
|
||||
);
|
||||
|
||||
const onUploadStatusEnter = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
|
||||
(el as HTMLElement).style.height = "0";
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
};
|
||||
|
||||
const onUploadStatusLeave = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0);
|
||||
(el as HTMLElement).style.height = `${height}px`;
|
||||
// eslint-disable-next-line no-void
|
||||
void (el as HTMLElement).offsetHeight;
|
||||
(el as HTMLElement).style.height = "0";
|
||||
};
|
||||
|
||||
watch(
|
||||
uploadQueue,
|
||||
() => {
|
||||
if (!uploadStatusRef.value) return;
|
||||
const el = uploadStatusRef.value;
|
||||
const itemsHeight = uploadQueue.value.length * 32;
|
||||
const headerHeight = 12;
|
||||
const gap = 8;
|
||||
const padding = 32;
|
||||
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0);
|
||||
el.style.height = `${totalHeight}px`;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + " KB";
|
||||
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + " MB";
|
||||
return (bytes / 1024 ** 3).toFixed(1) + " GB";
|
||||
};
|
||||
|
||||
const cancelUpload = (item: UploadItem) => {
|
||||
if (item.uploader && item.status === "uploading") {
|
||||
item.uploader.cancel();
|
||||
item.status = "cancelled";
|
||||
|
||||
setTimeout(async () => {
|
||||
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value.splice(index, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
const badFileTypeMsg = "Upload had incorrect file type";
|
||||
const uploadFile = async (file: File) => {
|
||||
const uploadItem: UploadItem = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: "pending",
|
||||
size: formatFileSize(file.size),
|
||||
};
|
||||
|
||||
uploadQueue.value.push(uploadItem);
|
||||
|
||||
try {
|
||||
if (
|
||||
props.acceptedTypes &&
|
||||
!props.acceptedTypes.includes(file.type) &&
|
||||
!props.acceptedTypes.some((type) => file.name.endsWith(type))
|
||||
) {
|
||||
throw new Error(badFileTypeMsg);
|
||||
}
|
||||
|
||||
uploadItem.status = "uploading";
|
||||
const filePath = `${props.currentPath}/${file.name}`.replace("//", "/");
|
||||
const uploader = await props.fs.uploadFile(filePath, file);
|
||||
uploadItem.uploader = uploader;
|
||||
|
||||
if (uploader?.onProgress) {
|
||||
uploader.onProgress(({ progress }: { progress: number }) => {
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1) {
|
||||
uploadQueue.value[index].progress = Math.round(progress);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
await uploader?.promise;
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status = "completed";
|
||||
uploadQueue.value[index].progress = 100;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
emit("uploadComplete");
|
||||
} catch (error) {
|
||||
console.error("Error uploading file:", error);
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (index !== -1 && uploadQueue.value[index].status !== "cancelled") {
|
||||
uploadQueue.value[index].status =
|
||||
error instanceof Error && error.message === badFileTypeMsg ? "incorrect-type" : "error";
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name);
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1);
|
||||
await nextTick();
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
if (error instanceof Error && error.message !== "Upload cancelled") {
|
||||
addNotification({
|
||||
group: "files",
|
||||
title: "Upload failed",
|
||||
text: `Failed to upload ${file.name}`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
uploadFile,
|
||||
cancelUpload,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-status {
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-status-enter-active,
|
||||
.upload-status-leave-active {
|
||||
transition: height 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-status-enter-from,
|
||||
.upload-status-leave-to {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.status-icon-enter-active,
|
||||
.status-icon-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.status-icon-enter-from,
|
||||
.status-icon-leave-to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.status-icon-enter-to,
|
||||
.status-icon-leave-from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
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"
|
||||
/>
|
||||
@@ -61,25 +60,38 @@
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
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"
|
||||
v-if="status === 'suspended' && suspension_reason === 'support'"
|
||||
class="relative -mt-4 flex w-full flex-row items-center gap-2 rounded-b-3xl bg-bg-blue 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.
|
||||
<HammerIcon />
|
||||
You recently requested support for your server and we are actively working on it. It will be
|
||||
back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason !== 'upgrading'"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, LockIcon } from "@modrinth/assets";
|
||||
import { ChevronRightIcon, HammerIcon, LockIcon } from "@modrinth/assets";
|
||||
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) {
|
||||
@@ -95,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,30 +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>
|
||||
@@ -138,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",
|
||||
@@ -154,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(() => {
|
||||
@@ -225,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;
|
||||
|
||||
@@ -261,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) {
|
||||
@@ -298,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);
|
||||
@@ -371,9 +254,6 @@ const focusNextOption = () => {
|
||||
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length;
|
||||
}
|
||||
scrollToFocused();
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const focusPreviousOption = () => {
|
||||
@@ -384,9 +264,6 @@ const focusPreviousOption = () => {
|
||||
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length;
|
||||
}
|
||||
scrollToFocused();
|
||||
nextTick(() => {
|
||||
focusedOptionRef.value?.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const scrollToFocused = () => {
|
||||
@@ -405,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);
|
||||
@@ -414,6 +404,10 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
window.addEventListener("close-all-dropdowns", closeDropdown);
|
||||
|
||||
if (selectedValue.value) {
|
||||
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value);
|
||||
}
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -425,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(
|
||||
@@ -441,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>
|
||||
@@ -104,22 +104,15 @@ export const initAuth = async (oldToken = null) => {
|
||||
return auth;
|
||||
};
|
||||
|
||||
export const getAuthUrl = (provider, redirect = "") => {
|
||||
export const getAuthUrl = (provider, redirect = "/dashboard") => {
|
||||
const config = useRuntimeConfig();
|
||||
const route = useNativeRoute();
|
||||
|
||||
if (redirect === "") {
|
||||
redirect = route.path;
|
||||
}
|
||||
const fullURL = route.query.launcher
|
||||
? "https://launcher-files.modrinth.com"
|
||||
: `${config.public.siteUrl}/auth/sign-in?redirect=${redirect}`;
|
||||
|
||||
let fullURL;
|
||||
if (route.query.launcher) {
|
||||
fullURL = `https://launcher-files.modrinth.com`;
|
||||
} else {
|
||||
fullURL = `${config.public.siteUrl}${redirect}`;
|
||||
}
|
||||
|
||||
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${fullURL}`;
|
||||
return `${config.public.apiBaseUrl}auth/init?provider=${provider}&url=${encodeURIComponent(fullURL)}`;
|
||||
};
|
||||
|
||||
export const removeAuthProvider = async (provider) => {
|
||||
|
||||
@@ -21,6 +21,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
developerMode: false,
|
||||
showVersionFilesInTable: false,
|
||||
showAdsWithPlus: false,
|
||||
alwaysShowChecklistAsPopup: true,
|
||||
|
||||
// Feature toggles
|
||||
projectTypesPrimaryNav: false,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"admin.billing.error.not-found": {
|
||||
"message": "User not found"
|
||||
},
|
||||
"auth.authorize.action.authorize": {
|
||||
"message": "Authorize"
|
||||
},
|
||||
@@ -284,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"
|
||||
},
|
||||
@@ -338,6 +386,12 @@
|
||||
"layout.nav.search": {
|
||||
"message": "Search"
|
||||
},
|
||||
"profile.button.billing": {
|
||||
"message": "Manage user billing"
|
||||
},
|
||||
"profile.button.info": {
|
||||
"message": "View user details"
|
||||
},
|
||||
"profile.button.manage-projects": {
|
||||
"message": "Manage projects"
|
||||
},
|
||||
@@ -476,6 +530,84 @@
|
||||
"project.versions.title": {
|
||||
"message": "Versions"
|
||||
},
|
||||
"report.already-reported": {
|
||||
"message": "You've already reported {title}"
|
||||
},
|
||||
"report.already-reported-description": {
|
||||
"message": "You have an open report for this {item} already. You can add more details to your report if you have more information to add."
|
||||
},
|
||||
"report.back-to-item": {
|
||||
"message": "Back to {item}"
|
||||
},
|
||||
"report.body.description": {
|
||||
"message": "Include links and images if possible and relevant. Empty or insufficient reports will be closed and ignored."
|
||||
},
|
||||
"report.body.title": {
|
||||
"message": "Please provide additional context about your report"
|
||||
},
|
||||
"report.checking": {
|
||||
"message": "Checking {item}..."
|
||||
},
|
||||
"report.could-not-find": {
|
||||
"message": "Could not find {item}"
|
||||
},
|
||||
"report.for.violation": {
|
||||
"message": "Violation of Modrinth <rules-link>Rules</rules-link> or <terms-link>Terms of Use</terms-link>"
|
||||
},
|
||||
"report.for.violation.description": {
|
||||
"message": "Examples include malicious, spam, offensive, deceptive, misleading, and illegal content."
|
||||
},
|
||||
"report.form-not-for": {
|
||||
"message": "This form is not for:"
|
||||
},
|
||||
"report.go-to-report": {
|
||||
"message": "Go to report"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"report.not-for.dmca.description": {
|
||||
"message": "See our <policy-link>Copyright Policy</policy-link>."
|
||||
},
|
||||
"report.note.copyright.1": {
|
||||
"message": "Please note that you are *not* submitting a DMCA takedown request, but rather a report of reuploaded content."
|
||||
},
|
||||
"report.note.copyright.2": {
|
||||
"message": "If you meant to file a DMCA takedown request (which is a legal action) instead, please see our <copyright-policy-link>Copyright Policy</copyright-policy-link>."
|
||||
},
|
||||
"report.note.malicious.1": {
|
||||
"message": "Reports for malicious or deceptive content must include substantial evidence of the behavior, such as code samples."
|
||||
},
|
||||
"report.note.malicious.2": {
|
||||
"message": "Summaries from Microsoft Defender, VirusTotal, or AI malware detection are not sufficient forms of evidence and will not be accepted."
|
||||
},
|
||||
"report.please-report": {
|
||||
"message": "Please report:"
|
||||
},
|
||||
"report.question.content-id": {
|
||||
"message": "What is the ID of the {item}?"
|
||||
},
|
||||
"report.question.content-type": {
|
||||
"message": "What type of content are you reporting?"
|
||||
},
|
||||
"report.question.report-reason": {
|
||||
"message": "Which of Modrinth's rules is this {item} violating?"
|
||||
},
|
||||
"report.report-content": {
|
||||
"message": "Report content to moderators"
|
||||
},
|
||||
"report.report-item": {
|
||||
"message": "Report {title} to moderators"
|
||||
},
|
||||
"report.submit": {
|
||||
"message": "Submit report"
|
||||
},
|
||||
"revenue.transfers.total": {
|
||||
"message": "You have withdrawn {amount} in total."
|
||||
},
|
||||
|
||||
@@ -184,7 +184,19 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewModal ref="downloadModal">
|
||||
<NewModal
|
||||
ref="downloadModal"
|
||||
:on-show="
|
||||
() => {
|
||||
navigateTo({ query: route.query, hash: '#download' });
|
||||
}
|
||||
"
|
||||
:on-hide="
|
||||
() => {
|
||||
navigateTo({ query: route.query, hash: '' });
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #title>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
|
||||
<div class="truncate text-lg font-extrabold text-contrast">
|
||||
@@ -275,7 +287,7 @@
|
||||
</div>
|
||||
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
|
||||
<ButtonStyled
|
||||
v-for="version in project.game_versions
|
||||
v-for="gameVersion in project.game_versions
|
||||
.filter(
|
||||
(x) =>
|
||||
(versionFilter && x.includes(versionFilter)) ||
|
||||
@@ -284,30 +296,39 @@
|
||||
)
|
||||
.slice()
|
||||
.reverse()"
|
||||
:key="version"
|
||||
:color="currentGameVersion === version ? 'brand' : 'standard'"
|
||||
:key="gameVersion"
|
||||
:color="currentGameVersion === gameVersion ? 'brand' : 'standard'"
|
||||
>
|
||||
<button
|
||||
v-tooltip="
|
||||
!possibleGameVersions.includes(version)
|
||||
? `${project.title} does not support ${version} for ${formatCategory(currentPlatform)}`
|
||||
!possibleGameVersions.includes(gameVersion)
|
||||
? `${project.title} does not support ${gameVersion} for ${formatCategory(currentPlatform)}`
|
||||
: null
|
||||
"
|
||||
:class="{
|
||||
'looks-disabled !text-brand-red': !possibleGameVersions.includes(version),
|
||||
'looks-disabled !text-brand-red': !possibleGameVersions.includes(gameVersion),
|
||||
}"
|
||||
@click="
|
||||
() => {
|
||||
userSelectedGameVersion = version;
|
||||
userSelectedGameVersion = gameVersion;
|
||||
gameVersionAccordion.close();
|
||||
if (!currentPlatform && platformAccordion) {
|
||||
platformAccordion.open();
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
|
||||
...(userSelectedPlatform && { loader: userSelectedPlatform }),
|
||||
},
|
||||
hash: route.hash,
|
||||
});
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ version }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === version" />
|
||||
{{ gameVersion }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
@@ -379,6 +400,15 @@
|
||||
if (!currentGameVersion && gameVersionAccordion) {
|
||||
gameVersionAccordion.open();
|
||||
}
|
||||
|
||||
navigateTo({
|
||||
query: {
|
||||
...route.query,
|
||||
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
|
||||
...(userSelectedPlatform && { loader: userSelectedPlatform }),
|
||||
},
|
||||
hash: route.hash,
|
||||
});
|
||||
}
|
||||
"
|
||||
>
|
||||
@@ -430,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">
|
||||
@@ -506,7 +540,7 @@
|
||||
placeholder="Search collections..."
|
||||
class="search-input menu-search"
|
||||
/>
|
||||
<div v-if="collections.length > 0" class="collections-list">
|
||||
<div v-if="collections.length > 0" class="collections-list text-primary">
|
||||
<Checkbox
|
||||
v-for="option in collections
|
||||
.slice()
|
||||
@@ -601,7 +635,7 @@
|
||||
auth.user ? reportProject(project.id) : navigateTo('/auth/sign-in'),
|
||||
color: 'red',
|
||||
hoverOnly: true,
|
||||
shown: !currentMember,
|
||||
shown: !isMember,
|
||||
},
|
||||
{ id: 'copy-id', action: () => copyId() },
|
||||
]"
|
||||
@@ -644,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>
|
||||
@@ -772,44 +806,50 @@
|
||||
:reset-members="resetMembers"
|
||||
:route="route"
|
||||
@on-download="triggerDownloadAnimation"
|
||||
@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>
|
||||
import {
|
||||
ScaleIcon,
|
||||
AlignLeftIcon as DescriptionIcon,
|
||||
BookmarkIcon,
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
ChartIcon,
|
||||
CheckIcon,
|
||||
ClipboardCopyIcon,
|
||||
CopyrightIcon,
|
||||
AlignLeftIcon as DescriptionIcon,
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
ImageIcon as GalleryIcon,
|
||||
GameIcon,
|
||||
HeartIcon,
|
||||
ImageIcon as GalleryIcon,
|
||||
InfoIcon,
|
||||
LinkIcon as LinksIcon,
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
SearchIcon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
} from "@modrinth/assets";
|
||||
import {
|
||||
Avatar,
|
||||
@@ -818,32 +858,33 @@ import {
|
||||
NewModal,
|
||||
OverflowMenu,
|
||||
PopoutMenu,
|
||||
ScrollablePanel,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectHeader,
|
||||
ProjectSidebarCompatibility,
|
||||
ProjectSidebarCreators,
|
||||
ProjectSidebarLinks,
|
||||
ProjectSidebarDetails,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectSidebarLinks,
|
||||
ScrollablePanel,
|
||||
} from "@modrinth/ui";
|
||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||
import dayjs from "dayjs";
|
||||
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
|
||||
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
|
||||
import { navigateTo } from "#app";
|
||||
import dayjs from "dayjs";
|
||||
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
import Badge from "~/components/ui/Badge.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
@@ -1172,6 +1213,10 @@ const members = computed(() => {
|
||||
return owner ? [owner, ...rest] : rest;
|
||||
});
|
||||
|
||||
const isMember = computed(
|
||||
() => auth.value.user && allMembers.value.some((x) => x.user.id === auth.value.user.id),
|
||||
);
|
||||
|
||||
const currentMember = computed(() => {
|
||||
let val = auth.value.user ? allMembers.value.find((x) => x.user.id === auth.value.user.id) : null;
|
||||
|
||||
@@ -1247,6 +1292,23 @@ if (!route.name.startsWith("type-id-settings")) {
|
||||
|
||||
const onUserCollectProject = useClientTry(userCollectProject);
|
||||
|
||||
const { version, loader } = route.query;
|
||||
if (version !== undefined && project.value.game_versions.includes(version)) {
|
||||
userSelectedGameVersion.value = version;
|
||||
}
|
||||
if (loader !== undefined && project.value.loaders.includes(loader)) {
|
||||
userSelectedPlatform.value = loader;
|
||||
}
|
||||
|
||||
watch(downloadModal, (modal) => {
|
||||
if (!modal) return;
|
||||
|
||||
// route.hash returns everything in the hash string, including the # itself
|
||||
if (route.hash === "#download") {
|
||||
modal.show();
|
||||
}
|
||||
});
|
||||
|
||||
async function setProcessing() {
|
||||
startLoading();
|
||||
|
||||
@@ -1378,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;
|
||||
@@ -1403,6 +1466,20 @@ function onDownload(event) {
|
||||
}, 400);
|
||||
}
|
||||
|
||||
async function deleteVersion(id) {
|
||||
if (!id) return;
|
||||
|
||||
startLoading();
|
||||
|
||||
await useBaseFetch(`version/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
versions.value = versions.value.filter((x) => x.id !== id);
|
||||
|
||||
stopLoading();
|
||||
}
|
||||
|
||||
const navLinks = computed(() => {
|
||||
const projectUrl = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -381,6 +381,7 @@
|
||||
/>
|
||||
<ButtonStyled v-if="isEditing">
|
||||
<button
|
||||
class="raised-button"
|
||||
:disabled="primaryFile.hashes.sha1 === file.hashes.sha1"
|
||||
@click="
|
||||
() => {
|
||||
@@ -639,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";
|
||||
|
||||
@@ -662,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,
|
||||
@@ -669,7 +670,6 @@ export default defineNuxtComponent({
|
||||
FileInput,
|
||||
Checkbox,
|
||||
ChevronRightIcon,
|
||||
Chips,
|
||||
Categories,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
@@ -821,6 +821,13 @@ export default defineNuxtComponent({
|
||||
if (route.query.version) {
|
||||
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
|
||||
}
|
||||
if (versionList.length === 0) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: "No version matches the filters",
|
||||
});
|
||||
}
|
||||
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
|
||||
} else {
|
||||
version = props.versions.find((x) => x.id === route.params.version);
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
<template>
|
||||
<ConfirmModal
|
||||
v-if="currentMember"
|
||||
ref="deleteVersionModal"
|
||||
title="Are you sure you want to delete this version?"
|
||||
description="This will remove this version forever (like really forever)."
|
||||
:has-to-type="false"
|
||||
proceed-label="Delete"
|
||||
@proceed="deleteVersion()"
|
||||
/>
|
||||
<section class="experimental-styles-within overflow-visible">
|
||||
<div
|
||||
v-if="currentMember && isPermission(currentMember?.permissions, 1 << 0)"
|
||||
@@ -41,7 +50,7 @@
|
||||
:href="getPrimaryFile(version).url"
|
||||
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
|
||||
aria-label="Download"
|
||||
@click="emits('onDownload')"
|
||||
@click="emit('onDownload')"
|
||||
>
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
</a>
|
||||
@@ -57,7 +66,7 @@
|
||||
hoverFilled: true,
|
||||
link: getPrimaryFile(version).url,
|
||||
action: () => {
|
||||
emits('onDownload');
|
||||
emit('onDownload');
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -89,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',
|
||||
@@ -101,8 +118,11 @@
|
||||
id: 'delete',
|
||||
color: 'red',
|
||||
hoverFilled: true,
|
||||
action: () => {},
|
||||
shown: currentMember && false,
|
||||
action: () => {
|
||||
selectedVersion = version.id;
|
||||
deleteVersionModal.show();
|
||||
},
|
||||
shown: currentMember,
|
||||
},
|
||||
]"
|
||||
aria-label="More options"
|
||||
@@ -136,6 +156,10 @@
|
||||
<TrashIcon aria-hidden="true" />
|
||||
Delete
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy ID
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
@@ -144,7 +168,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ButtonStyled, OverflowMenu, FileInput, ProjectPageVersions } from "@modrinth/ui";
|
||||
import {
|
||||
ButtonStyled,
|
||||
OverflowMenu,
|
||||
FileInput,
|
||||
ProjectPageVersions,
|
||||
ConfirmModal,
|
||||
} from "@modrinth/ui";
|
||||
import {
|
||||
DownloadIcon,
|
||||
MoreVerticalIcon,
|
||||
@@ -156,6 +186,7 @@ import {
|
||||
ReportIcon,
|
||||
UploadIcon,
|
||||
InfoIcon,
|
||||
ClipboardCopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
import DropArea from "~/components/ui/DropArea.vue";
|
||||
import { acceptFileFromProjectType } from "~/helpers/fileUtils.js";
|
||||
@@ -185,7 +216,10 @@ const tags = useTags();
|
||||
const flags = useFeatureFlags();
|
||||
const auth = await useAuth();
|
||||
|
||||
const emits = defineEmits(["onDownload"]);
|
||||
const deleteVersionModal = ref();
|
||||
const selectedVersion = ref(null);
|
||||
|
||||
const emit = defineEmits(["onDownload", "deleteVersion"]);
|
||||
|
||||
const router = useNativeRouter();
|
||||
|
||||
@@ -212,4 +246,9 @@ async function handleFiles(files) {
|
||||
async function copyToClipboard(text) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}
|
||||
|
||||
function deleteVersion() {
|
||||
emit("deleteVersion", selectedVersion.value);
|
||||
selectedVersion.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
342
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
342
apps/frontend/src/pages/admin/billing/[id].vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<template>
|
||||
<NewModal ref="refundModal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Refund charge</span>
|
||||
</template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="visibility" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Refund type
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> The type of refund to issue. </span>
|
||||
</label>
|
||||
<DropdownSelect
|
||||
id="refund-type"
|
||||
v-model="refundType"
|
||||
:options="refundTypes"
|
||||
name="Refund type"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="refundType === 'partial'" class="flex flex-col gap-2">
|
||||
<label for="amount" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Amount
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>
|
||||
Enter the amount in cents of USD. For example for $2, enter 200. (net
|
||||
{{ selectedCharge.net }})
|
||||
</span>
|
||||
</label>
|
||||
<input id="amount" v-model="refundAmount" type="number" autocomplete="off" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="unprovision" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Unprovision
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span> Whether or not the subscription should be unprovisioned on refund. </span>
|
||||
</label>
|
||||
<Toggle id="unprovision" v-model="unprovision" />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="refunding" @click="refundCharge">
|
||||
<CheckIcon aria-hidden="true" />
|
||||
Refund charge
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="refundModal.hide()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
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();
|
||||
const { formatMessage } = vintl;
|
||||
|
||||
const messages = defineMessages({
|
||||
userNotFoundError: {
|
||||
id: "admin.billing.error.not-found",
|
||||
defaultMessage: "User not found",
|
||||
},
|
||||
});
|
||||
|
||||
const { data: user } = await useAsyncData(`user/${route.params.id}`, () =>
|
||||
useBaseFetch(`user/${route.params.id}`),
|
||||
);
|
||||
|
||||
if (!user.value) {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
});
|
||||
}
|
||||
|
||||
let subscriptions, charges, refreshCharges;
|
||||
try {
|
||||
[{ data: subscriptions }, { data: charges, refresh: refreshCharges }] = await Promise.all([
|
||||
useAsyncData(`billing/subscriptions?user_id=${route.params.id}`, () =>
|
||||
useBaseFetch(`billing/subscriptions?user_id=${user.value.id}`, {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
useAsyncData(`billing/payments?user_id=${route.params.id}`, () =>
|
||||
useBaseFetch(`billing/payments?user_id=${user.value.id}`, {
|
||||
internal: true,
|
||||
}),
|
||||
),
|
||||
]);
|
||||
} catch {
|
||||
throw createError({
|
||||
fatal: true,
|
||||
statusCode: 404,
|
||||
message: formatMessage(messages.userNotFoundError),
|
||||
});
|
||||
}
|
||||
|
||||
const subscriptionCharges = computed(() => {
|
||||
return subscriptions.value.map((subscription) => {
|
||||
return {
|
||||
...subscription,
|
||||
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),
|
||||
),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
const refunding = ref(false);
|
||||
const refundModal = ref();
|
||||
const selectedCharge = ref(null);
|
||||
const refundType = ref("full");
|
||||
const refundTypes = ref(["full", "partial"]);
|
||||
const refundAmount = ref(0);
|
||||
const unprovision = ref(false);
|
||||
|
||||
function showRefundModal(charge) {
|
||||
selectedCharge.value = charge;
|
||||
refundType.value = "full";
|
||||
refundAmount.value = 0;
|
||||
unprovision.value = false;
|
||||
refundModal.value.show();
|
||||
}
|
||||
|
||||
async function refundCharge() {
|
||||
refunding.value = true;
|
||||
try {
|
||||
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
type: refundType.value,
|
||||
amount: refundAmount.value,
|
||||
unprovision: unprovision.value,
|
||||
}),
|
||||
internal: true,
|
||||
});
|
||||
await refreshCharges();
|
||||
refundModal.value.hide();
|
||||
} catch (err) {
|
||||
data.$notify({
|
||||
group: "main",
|
||||
title: "Error refunding",
|
||||
text: err.data?.description ?? err,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<ModalConfirm
|
||||
v-if="auth.user && auth.user.id === creator.id"
|
||||
<ConfirmModal
|
||||
v-if="canEdit"
|
||||
ref="deleteModal"
|
||||
:title="formatMessage(messages.deleteModalTitle)"
|
||||
:description="formatMessage(messages.deleteModalDescription)"
|
||||
@@ -365,34 +365,35 @@
|
||||
|
||||
<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";
|
||||
import WorldIcon from "assets/images/utils/world.svg";
|
||||
import UpToDate from "assets/images/illustrations/up_to_date.svg";
|
||||
import { addNotification } from "~/composables/notifs.js";
|
||||
import ModalConfirm from "~/components/ui/ModalConfirm.vue";
|
||||
import NavRow from "~/components/ui/NavRow.vue";
|
||||
import ProjectCard from "~/components/ui/ProjectCard.vue";
|
||||
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
@@ -596,7 +597,7 @@ useSeoMeta({
|
||||
const canEdit = computed(
|
||||
() =>
|
||||
auth.value.user &&
|
||||
auth.value.user.id === collection.value.user &&
|
||||
(auth.value.user.id === collection.value.user || isAdmin(auth.value.user)) &&
|
||||
collection.value.id !== "following",
|
||||
);
|
||||
|
||||
@@ -650,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,
|
||||
},
|
||||
@@ -685,7 +686,11 @@ async function deleteCollection() {
|
||||
method: "DELETE",
|
||||
apiVersion: 3,
|
||||
});
|
||||
await navigateTo("/dashboard/collections");
|
||||
if (auth.value.user.id === collection.value.user) {
|
||||
await navigateTo("/dashboard/collections");
|
||||
} else {
|
||||
await navigateTo(`/user/${collection.value.user}/collections`);
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
group: "main",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user