Merge tag 'v0.10.24' into beta

This commit is contained in:
2025-12-29 01:57:40 +03:00
422 changed files with 20967 additions and 8663 deletions

View File

@@ -13,7 +13,7 @@
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[vue]": {
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"

View File

@@ -56,7 +56,7 @@ Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse
### Postgres
Use `docker exec labrinth-postgres psql -U postgres` to access the PostgreSQL instance.
Use `docker exec labrinth-postgres psql -U labrinth -d labrinth -c "SELECT 1"` to access the PostgreSQL instance, replacing the `SELECT 1` with your query.
# Guidelines

745
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ members = [
"packages/app-lib",
"packages/ariadne",
"packages/daedalus",
"packages/modrinth-log",
"packages/modrinth-maxmind",
"packages/modrinth-util",
"packages/path-util",
@@ -107,6 +108,7 @@ lettre = { version = "0.11.19", default-features = false, features = [
] }
maxminddb = "0.26.0"
meilisearch-sdk = { version = "0.30.0", default-features = false }
modrinth-log = { path = "packages/modrinth-log" }
modrinth-maxmind = { path = "packages/modrinth-maxmind" }
modrinth-util = { path = "packages/modrinth-util" }
muralpay = { path = "packages/muralpay" }

View File

@@ -58,6 +58,7 @@
"tailwindcss": "^3.4.4",
"typescript": "^5.5.4",
"vite": "^5.4.6",
"vue-component-type-helpers": "^3.1.8",
"vue-tsc": "^2.1.6"
},
"packageManager": "pnpm@9.4.0",

View File

@@ -1,5 +1,5 @@
<script setup>
import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client'
import { AuthFeature, PanelVersionFeature, TauriModrinthClient } from '@modrinth/api-client'
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
@@ -37,6 +37,7 @@ import {
ProgressSpinner,
provideModrinthClient,
provideNotificationManager,
providePageContext,
useDebugLogger,
} from '@modrinth/ui'
import { renderString } from '@modrinth/utils'
@@ -110,10 +111,14 @@ const tauriApiClient = new TauriModrinthClient({
new AuthFeature({
token: async () => (await getCreds()).session,
}),
new PanelVersionFeature(),
],
})
provideModrinthClient(tauriApiClient)
providePageContext({
hierarchicalSidebarAvailable: ref(true),
showAds: ref(false),
})
const news = ref([])
const availableSurvey = ref(false)
@@ -647,7 +652,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
<NavButton
v-if="themeStore.featureFlags.servers_in_app"
v-tooltip.right="'Servers'"
to="/servers/manage"
to="/hosting/manage"
>
<ServerIcon />
</NavButton>

View File

@@ -1,24 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:serif="http://www.serif.com/" version="1.1" viewBox="0 0 1793 199">
<g>
<g id="Layer_1">
<g id="green" fill="var(--color-brand)">
<path d="M1184.1,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"/>
<path d="M1291.1,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"/>
<path d="M1357.2,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"/>
<path d="M1460,165.3l-40.8-95.1h23.2l35.1,83.9h-11.4l36.3-83.9h21.4l-40.8,95.1h-23Z"/>
<path d="M1579.6,166.6c-10.6,0-19.8-2.1-27.7-6.3-7.9-4.2-14-10-18.3-17.4-4.3-7.4-6.5-15.7-6.5-25.1s2.1-17.9,6.3-25.2c4.2-7.3,10-13,17.5-17.2,7.4-4.2,15.9-6.2,25.4-6.2s17.5,2,24.8,6.1c7.2,4,12.9,9.7,17.1,17.1,4.2,7.4,6.2,16,6.2,26s0,2,0,3.2c0,1.2-.2,2.3-.3,3.4h-79.3v-14.8h67.5l-8.7,4.6c.1-5.5-1-10.3-3.4-14.4-2.4-4.2-5.6-7.4-9.7-9.8-4.1-2.4-8.8-3.6-14.2-3.6s-10.2,1.2-14.3,3.6c-4.1,2.4-7.3,5.7-9.6,9.9-2.3,4.2-3.5,9.2-3.5,14.9v3.6c0,5.7,1.3,10.7,3.9,15.1,2.6,4.4,6.3,7.8,11,10.2,4.7,2.4,10.2,3.6,16.4,3.6s10.2-.8,14.4-2.5c4.3-1.7,8.1-4.3,11.4-7.8l11.9,13.7c-4.3,5-9.6,8.8-16.1,11.5-6.5,2.7-13.9,4-22.2,4Z"/>
<path d="M1645.7,165.3v-95.1h21.2v26.2l-2.5-7.7c2.8-6.4,7.3-11.3,13.4-14.6,6.1-3.3,13.7-5,22.9-5v21.2c-1-.2-1.8-.4-2.7-.4-.8,0-1.7,0-2.5,0-8.4,0-15.1,2.5-20.1,7.4-5,4.9-7.5,12.3-7.5,22v46.1h-22.3Z"/>
<path d="M1749.9,166.6c-8,0-15.6-1-22.9-3.1s-13.1-4.6-17.4-7.6l8.5-16.9c4.3,2.7,9.4,5,15.3,6.8,5.9,1.8,11.9,2.7,17.8,2.7s12.1-.9,15.2-2.8c3.1-1.9,4.7-4.5,4.7-7.7s-1.1-4.6-3.2-6c-2.1-1.4-4.9-2.4-8.4-3.1-3.4-.7-7.3-1.4-11.5-2-4.2-.6-8.4-1.4-12.6-2.4-4.2-1-8-2.5-11.5-4.5-3.4-2-6.2-4.6-8.4-7.9-2.1-3.3-3.2-7.7-3.2-13.2s1.7-11.3,5.2-15.8c3.4-4.5,8.3-7.9,14.5-10.3,6.2-2.4,13.6-3.7,22.2-3.7s12.9.7,19.4,2.1c6.5,1.4,11.9,3.4,16.2,6.1l-8.5,16.9c-4.5-2.7-9.1-4.6-13.6-5.6-4.6-1-9.1-1.5-13.6-1.5-6.8,0-11.8,1-15,3-3.3,2-4.9,4.6-4.9,7.7s1.1,5,3.2,6.4c2.1,1.4,4.9,2.6,8.4,3.4,3.4.8,7.3,1.5,11.5,2,4.2.5,8.4,1.3,12.6,2.4,4.2,1.1,8,2.5,11.5,4.4,3.5,1.8,6.3,4.4,8.5,7.7,2.1,3.3,3.2,7.7,3.2,13s-1.8,11.1-5.3,15.5c-3.5,4.4-8.5,7.8-14.9,10.2-6.4,2.4-14.1,3.7-23,3.7Z"/>
<g>
<path d="M9.8,143l63.4-38.1-5.8-15.3,18.1-18.6,22.9-4.9,6.6,8.2-10.6,10.7-9.2,2.9-6.6,6.8,3.2,9,6.5,6.9,9.2-2.5,6.6-7.2,14.3-4.5,4.3,9.6-14.8,18.1-24.8,7.8-11.1-12.4-63.6,38.2c-3-3.9-6.5-9.4-8.8-14.7ZM192.8,65.4l-50.4,13.6c2.8,7.4,3.7,11.7,4.5,16.5l50.3-13.6c-.8-5.4-2.2-10.8-4.4-16.5Z" fill-rule="evenodd"/>
<path d="M17.3,106.5c3.6,42.1,38.9,75.2,82,75.2s60.7-18.9,74-46.3l16.4,5.7c-15.8,34.1-50.3,57.9-90.4,57.9S3.6,158.2,0,106.5h17.3ZM.3,89.4C5.3,39.2,47.8,0,99.3,0s99.5,44.6,99.5,99.5-1.1,17.4-3.3,25.5l-16.3-5.7c1.6-6.5,2.4-13.1,2.4-19.8,0-45.4-36.9-82.3-82.3-82.3S22.6,48.7,17.6,89.4H.3Z" fill-rule="evenodd"/>
<path d="M99,51.6c-26.4,0-47.9,21.5-47.9,48s21.5,48,48,48,2.7,0,4-.2l4.8,16.8c-2.9.4-5.8.6-8.8.6-36,0-65.2-29.2-65.2-65.2S63.1,34.4,99,34.4s1.8,0,2.7,0l-2.7,17.1ZM118.6,37.4c26.4,8.3,45.6,33,45.6,62.2s-16.4,50.2-39.8,60l-4.8-16.7c16.2-7.7,27.4-24.2,27.4-43.3s-13-38.1-31.1-44.9l2.7-17.2Z" fill-rule="evenodd"/>
</g>
</g>
<g id="black" fill="currentColor">
<path d="M354.8,69.2c12,0,21.7,3.4,28.6,10.4,7,7.2,10.6,17.5,10.6,31.5v54.8h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,5-6.8,12.2-6.8,21.3v48.5h-22.4v-51.9c0-8.4-1.8-14.7-5.5-19-3.8-4.1-8.9-6.3-15.9-6.3s-13.6,2.5-18.1,7.3c-4.5,4.8-6.8,12-6.8,21.3v48.5h-22.4v-95.6h21.3v12.2c3.6-4.3,8.1-7.5,13.4-9.8,5.4-2.3,11.3-3.4,17.9-3.4s13.6,1.3,19.2,3.9c5.5,2.9,9.8,6.8,13.1,12,3.9-5,8.9-8.9,15.2-11.8,6.3-2.7,13.1-4.1,20.6-4.1ZM466,167.2c-9.7,0-18.4-2.1-26.1-6.3-7.6-4-13.8-10.1-18.1-17.5-4.5-7.3-6.6-15.7-6.6-25.2s2.1-17.9,6.6-25.2c4.3-7.4,10.6-13.4,18.1-17.4,7.7-4.1,16.5-6.3,26.1-6.3s18.6,2.1,26.3,6.3c7.7,4.1,13.8,10,18.3,17.4,4.3,7.3,6.4,15.7,6.4,25.2s-2.1,17.9-6.4,25.2c-4.5,7.5-10.6,13.4-18.3,17.5-7.7,4.1-16.5,6.3-26.3,6.3h0ZM466,148c8.2,0,15-2.7,20.4-8.2,5.4-5.5,8.1-12.7,8.1-21.7s-2.7-16.1-8.1-21.7c-5.4-5.5-12.2-8.2-20.4-8.2s-15,2.7-20.2,8.2c-5.4,5.5-8.1,12.7-8.1,21.7s2.7,16.1,8.1,21.7c5.2,5.5,12,8.2,20.2,8.2ZM631.5,33.1v132.8h-21.5v-12.3c-3.7,4.4-8.3,7.9-13.6,10.2-5.5,2.3-11.5,3.4-18.1,3.4s-17.4-2-24.7-6.1c-7.3-4.1-13.2-9.8-17.4-17.4-4.1-7.3-6.3-15.9-6.3-25.6s2.1-18.3,6.3-25.6c4.1-7.3,10-13.1,17.4-17.2,7.3-4.1,15.6-6.1,24.7-6.1s12.2,1.1,17.4,3.2c5.2,2.1,9.8,5.4,13.4,9.7v-49h22.4ZM581.1,148c5.4,0,10.2-1.3,14.5-3.8,4.3-2.3,7.7-5.9,10.2-10.4,2.5-4.5,3.8-9.8,3.8-15.7s-1.3-11.3-3.8-15.7c-2.5-4.5-5.9-8.1-10.2-10.6-4.3-2.3-9.1-3.6-14.5-3.6s-10.2,1.3-14.5,3.6c-4.3,2.5-7.7,6.1-10.2,10.6-2.5,4.5-3.8,9.8-3.8,15.7s1.3,11.3,3.8,15.7c2.5,4.5,5.9,8.1,10.2,10.4,4.3,2.5,9.1,3.8,14.5,3.8ZM681.6,84.3c6.4-10,17.7-15,34-15v21.3c-1.7-.3-3.4-.5-5.2-.5-8.8,0-15.6,2.5-20.4,7.5-4.8,5.2-7.3,12.5-7.3,22v46.4h-22.4v-95.6h21.3v14h0ZM734.1,70.3h22.4v95.6h-22.4v-95.6ZM745.4,54.6c-4.1,0-7.5-1.3-10.2-3.9-2.7-2.4-4.2-5.9-4.1-9.5,0-3.8,1.4-7,4.1-9.7,2.7-2.5,6.1-3.8,10.2-3.8s7.5,1.3,10.2,3.6c2.7,2.5,4.1,5.5,4.1,9.3s-1.3,7.2-3.9,9.8c-2.7,2.7-6.3,4.1-10.4,4.1ZM839.5,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4v-95.6h21.3v12.3c3.8-4.5,8.4-7.7,14-10,5.5-2.3,12-3.4,19-3.4ZM964.8,160.7c-2.8,2.2-6,3.9-9.5,4.8-3.9,1.1-7.9,1.6-12,1.6-10.6,0-18.6-2.7-24.3-8.2-5.7-5.5-8.6-13.4-8.6-24v-46h-15.7v-17.9h15.7v-21.8h22.4v21.8h25.6v17.9h-25.6v45.5c0,4.7,1.1,8.2,3.4,10.6,2.3,2.5,5.5,3.8,9.8,3.8s9.1-1.3,12.5-3.9l6.3,15.9ZM1036.9,69.2c12,0,21.7,3.6,29,10.6,7.3,7,10.9,17.5,10.9,31.3v54.8h-22.4v-51.9c0-8.4-2-14.7-5.9-19-3.9-4.1-9.5-6.3-16.8-6.3s-14.7,2.5-19.5,7.3c-4.8,5-7.2,12.2-7.2,21.5v48.3h-22.4V33.1h22.4v48.3c3.8-3.9,8.2-7,13.8-9.1,5.4-2,11.5-3,18.1-3Z"/>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -2,6 +2,8 @@
@tailwind components;
@tailwind utilities;
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
@font-face {
font-family: 'bundled-minecraft-font-mrapp';
font-style: normal;

View File

@@ -9,7 +9,7 @@ import useMemorySlider from '@/composables/useMemorySlider'
import { edit, get_optimal_jre_key } from '@/helpers/profile'
import { get } from '@/helpers/settings.ts'
import type { AppSettings, InstanceSettingsTabProps, MemorySettings } from '../../../helpers/types'
import type { AppSettings, InstanceSettingsTabProps } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
@@ -22,12 +22,12 @@ const overrideJavaInstall = ref(!!props.instance.java_path)
const optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError))
const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path })
const overrideJavaArgs = ref(props.instance.extra_launch_args?.length !== undefined)
const overrideJavaArgs = ref((props.instance.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref(
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
)
const overrideEnvVars = ref(props.instance.custom_env_vars?.length !== undefined)
const overrideEnvVars = ref((props.instance.custom_env_vars?.length ?? 0) > 0)
const envVars = ref(
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
.map((x) => x.join('='))
@@ -42,36 +42,23 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
}
const editProfileObject = computed(() => {
const editProfile: {
java_path?: string
extra_launch_args?: string[]
custom_env_vars?: string[][]
memory?: MemorySettings
} = {}
if (overrideJavaInstall.value) {
if (javaInstall.value.path !== '') {
editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe')
}
return {
java_path:
overrideJavaInstall.value && javaInstall.value.path !== ''
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
: null,
extra_launch_args: overrideJavaArgs.value
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
: null,
custom_env_vars: overrideEnvVars.value
? envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
: null,
memory: overrideMemorySettings.value ? memory.value : null,
}
if (overrideJavaArgs.value) {
editProfile.extra_launch_args = javaArgs.value.trim().split(/\s+/).filter(Boolean)
}
if (overrideEnvVars.value) {
editProfile.custom_env_vars = envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x) => x.split('=').filter(Boolean))
}
if (overrideMemorySettings.value) {
editProfile.memory = memory.value
}
return editProfile
})
watch(

View File

@@ -26,20 +26,16 @@ const fullscreenSetting: Ref<boolean> = ref(
)
const editProfileObject = computed(() => {
const editProfile: {
force_fullscreen?: boolean
game_resolution?: [number, number]
} = {}
if (overrideWindowSettings.value) {
editProfile.force_fullscreen = fullscreenSetting.value
if (!fullscreenSetting.value) {
editProfile.game_resolution = resolution.value
if (!overrideWindowSettings.value) {
return {
force_fullscreen: null,
game_resolution: null,
}
}
return editProfile
return {
force_fullscreen: fullscreenSetting.value,
game_resolution: fullscreenSetting.value ? null : resolution.value,
}
})
watch(
@@ -95,14 +91,6 @@ const messages = defineMessages({
<Checkbox
v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)"
@update:model-value="
(value) => {
if (!value) {
resolution = globalSettings.game_resolution
fullscreenSetting = globalSettings.force_fullscreen
}
}
"
/>
<div class="mt-2 flex items-center gap-4 justify-between">
<div>

View File

@@ -21,7 +21,7 @@ async function updateJavaVersion(version) {
}
</script>
<template>
<div v-for="(javaVersion, index) in [21, 17, 8]" :key="`java-${javaVersion}`">
<div v-for="(javaVersion, index) in [25, 21, 17, 8]" :key="`java-${javaVersion}`">
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
Java {{ javaVersion }} location
</h2>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
import type { Dayjs } from 'dayjs'
@@ -39,6 +40,7 @@ const props = defineProps<{
const theme = useTheming()
const jumpBackInItems = ref<JumpBackInItem[]>([])
const loading = ref(true)
const serverData = ref<Record<string, ServerData>>({})
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
@@ -71,9 +73,13 @@ watch([() => props.recentInstances, () => showWorlds.value], async () => {
})
})
await populateJumpBackIn().catch(() => {
console.error('Failed to populate jump back in')
})
populateJumpBackIn()
.catch(() => {
console.error('Failed to populate jump back in')
})
.finally(() => {
loading.value = false
})
async function populateJumpBackIn() {
console.info('Repopulating jump back in...')
@@ -233,7 +239,15 @@ onUnmounted(() => {
</script>
<template>
<div v-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<div v-if="loading" class="flex flex-col gap-2">
<span class="flex mt-1 mb-3 leading-none items-center gap-1 text-primary text-lg font-bold">
Jump back in
</span>
<div class="text-center py-4">
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
Jump back in
</HeadingLink>

View File

@@ -29,7 +29,7 @@ export default new createRouter({
},
},
{
path: '/servers/manage/',
path: '/hosting/manage/',
name: 'Servers',
component: ServersManagePageIndex,
meta: {

View File

@@ -10,6 +10,7 @@ const config: Config = {
'./src/error.vue',
// monorepo - TODO: migrate this to its own package
'../../packages/**/*.{js,vue,ts}',
'!../../packages/**/node_modules/**',
],
theme: {
extend: {

View File

@@ -394,15 +394,26 @@ components:
description: The hash of the file you're editing
example: aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj
file_type:
type: string
enum: [required-resource-pack, optional-resource-pack]
description: The hash algorithm of the file you're editing
example: required-resource-pack
nullable: true
allOf:
- $ref: '#/components/schemas/FileTypeEnum'
- nullable: true
description: The hash algorithm of the file you're editing
required:
- algorithm
- hash
- file_type
# https://github.com/modrinth/code/blob/main/apps/labrinth/src/models/v3/projects.rs#L981-990
FileTypeEnum:
type: string
enum:
- required-resource-pack
- optional-resource-pack
- sources-jar
- dev-jar
- javadoc-jar
- unknown
- signature
example: required-resource-pack
# https://github.com/modrinth/labrinth/blob/master/src/routes/version_creation.rs#L27-L57
CreatableVersion:
allOf:
@@ -506,11 +517,10 @@ components:
example: 1097270
description: The size of the file in bytes
file_type:
type: string
enum: [required-resource-pack, optional-resource-pack]
description: The type of the additional file, used mainly for adding resource packs to datapacks
example: required-resource-pack
nullable: true
allOf:
- $ref: '#/components/schemas/FileTypeEnum'
- nullable: true
description: The type of the additional file, used mainly for adding resource packs to datapacks
required:
- hashes
- url

View File

@@ -7,7 +7,7 @@ import { consola } from 'consola'
import { promises as fs } from 'fs'
import { globIterate } from 'glob'
import { defineNuxtConfig } from 'nuxt/config'
import { basename, relative, resolve } from 'pathe'
import { basename, relative } from 'pathe'
import svgLoader from 'vite-svg-loader'
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
@@ -31,7 +31,7 @@ const favicons = {
* Preferably only the locales that reach a certain threshold of complete
* translations would be included in this array.
*/
const enabledLocales: string[] = []
// const enabledLocales: string[] = []
/**
* Overrides for the categories of the certain locales.
@@ -154,7 +154,7 @@ export default defineNuxtConfig({
(state.errors ?? []).length === 0
) {
console.log(
'Tags already recently generated. Delete apps/frontend/generated/state.json to force regeneration.',
'Tags already recently generated. Delete apps/frontend/src/generated/state.json to force regeneration.',
)
return
}
@@ -176,27 +176,10 @@ export default defineNuxtConfig({
console.log('Tags generated!')
},
'pages:extend'(routes) {
routes.splice(
routes.findIndex((x) => x.name === 'search-searchProjectType'),
1,
)
const types = ['mods', 'modpacks', 'plugins', 'resourcepacks', 'shaders', 'datapacks']
types.forEach((type) =>
routes.push({
name: `search-${type}`,
path: `/${type}`,
file: resolve(__dirname, 'src/pages/search/[searchProjectType].vue'),
children: [],
}),
)
},
async 'vintl:extendOptions'(opts) {
opts.locales ??= []
const isProduction = getDomain() === 'https://modrinth.com'
// const isProduction = getDomain() === 'https://modrinth.com'
const resolveCompactNumberDataImport = await (async () => {
const compactNumberLocales: string[] = []
@@ -251,7 +234,9 @@ export default defineNuxtConfig({
for await (const localeDir of globIterate('src/locales/*/', { posix: true })) {
const tag = basename(localeDir)
if (isProduction && !enabledLocales.includes(tag) && opts.defaultLocale !== tag) continue
// NOTICE: temporarily disabled all locales except en-US
if (opts.defaultLocale !== tag) continue
const locale =
opts.locales.find((locale) => locale.tag === tag) ??

View File

@@ -17,6 +17,7 @@
"@formatjs/cli": "^6.2.12",
"@nuxt/devtools": "^1.3.3",
"@types/dompurify": "^3.0.5",
"@types/iso-3166-2": "^1.0.4",
"@types/node": "^20.1.0",
"@vintl/compact-number": "^2.0.5",
"@vintl/how-ago": "^3.0.1",
@@ -31,6 +32,7 @@
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite-svg-loader": "^5.1.0",
"vue-component-type-helpers": "^3.1.8",
"vue-tsc": "^2.0.24"
},
"dependencies": {
@@ -58,6 +60,7 @@
"floating-vue": "^5.2.2",
"fuse.js": "^6.6.2",
"highlight.js": "^11.7.0",
"iso-3166-2": "1.0.0",
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"markdown-it": "14.1.0",

View File

@@ -6,7 +6,12 @@
</NuxtLayout>
</template>
<script setup lang="ts">
import { NotificationPanel, provideModrinthClient, provideNotificationManager } from '@modrinth/ui'
import {
NotificationPanel,
provideModrinthClient,
provideNotificationManager,
providePageContext,
} from '@modrinth/ui'
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
import { createModrinthClient } from '~/helpers/api.ts'
@@ -23,4 +28,8 @@ const client = createModrinthClient(auth, {
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
})
</script>

View File

@@ -135,21 +135,6 @@
'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:
@@ -173,45 +158,6 @@
}
}
@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;
}

View File

@@ -1,3 +1,5 @@
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@@ -62,21 +62,21 @@ useHead({
const AD_PRESETS = {
medal: {
light: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-light-new.webp',
dark: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-dark-new.webp',
description: 'Host your next server with Modrinth Servers',
link: '/servers?plan&ref=medal',
light: 'https://cdn-raw.modrinth.com/modrinth-hosting-medal-light.webp',
dark: 'https://cdn-raw.modrinth.com/modrinth-hosting-medal-dark.webp',
description: 'Host your next server with Modrinth Hosting',
link: '/hosting?plan&ref=medal',
},
'modrinth-servers': {
light: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp',
dark: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp',
description: 'Host your next server with Modrinth Servers',
link: '/servers',
'modrinth-hosting': {
light: 'https://cdn-raw.modrinth.com/modrinth-hosting-light.webp',
dark: 'https://cdn-raw.modrinth.com/modrinth-hosting-dark.webp',
description: 'Host your next server with Modrinth Hosting',
link: '/hosting',
},
}
const currentAd = computed(() =>
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-servers'],
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-hosting'],
)
onMounted(() => {

View File

@@ -1,160 +0,0 @@
<template>
<nav class="navigation">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="rowLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="nav-link button-animation"
>
<span>{{ link.label }}</span>
</NuxtLink>
<div
class="nav-indicator"
:style="{
left: positionToMoveX,
top: positionToMoveY,
width: sliderWidth,
opacity: activeIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
</nav>
</template>
<script setup>
const route = useNativeRoute()
const props = defineProps({
links: {
default: () => [],
type: Array,
},
query: {
default: null,
type: String,
},
})
const sliderPositionX = ref(0)
const sliderPositionY = ref(18)
const selectedElementWidth = ref(0)
const activeIndex = ref(-1)
const oldIndex = ref(-1)
const filteredLinks = computed(() =>
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
)
const positionToMoveX = computed(() => `${sliderPositionX.value}px`)
const positionToMoveY = computed(() => `${sliderPositionY.value}px`)
const sliderWidth = computed(() => `${selectedElementWidth.value}px`)
function pickLink() {
activeIndex.value = props.query
? filteredLinks.value.findIndex(
(x) => (x.href === '' ? undefined : x.href) === route.path[props.query],
)
: filteredLinks.value.findIndex((x) => x.href === decodeURIComponent(route.path))
if (activeIndex.value !== -1) {
startAnimation()
} else {
oldIndex.value = -1
sliderPositionX.value = 0
selectedElementWidth.value = 0
}
}
const rowLinkElements = ref()
function startAnimation() {
const el = rowLinkElements.value[activeIndex.value].$el
if (!el || !el.offsetParent) return
sliderPositionX.value = el.offsetLeft
sliderPositionY.value = el.offsetTop + el.offsetHeight
selectedElementWidth.value = el.offsetWidth
}
onMounted(() => {
window.addEventListener('resize', pickLink)
pickLink()
})
onUnmounted(() => {
window.removeEventListener('resize', pickLink)
})
watch(route, () => pickLink())
</script>
<style lang="scss" scoped>
.navigation {
display: flex;
flex-direction: row;
align-items: center;
grid-gap: 1rem;
flex-wrap: wrap;
position: relative;
.nav-link {
text-transform: capitalize;
font-weight: var(--font-weight-bold);
color: var(--color-text);
position: relative;
&:hover {
color: var(--color-text);
&::after {
opacity: 0.4;
}
}
&:active::after {
opacity: 0.2;
}
&.router-link-exact-active {
color: var(--color-text);
&:not(:focus-visible) {
outline: 2px solid transparent;
outline-offset: 6px;
border-radius: 0.25rem;
}
&::after {
opacity: 1;
}
}
}
&.use-animation {
.nav-link {
&.is-active::after {
opacity: 0;
}
}
}
.nav-indicator {
position: absolute;
height: 0.25rem;
bottom: -5px;
left: 0;
width: 3rem;
transition: all ease-in-out 0.2s;
border-radius: var(--size-rounded-max);
background-color: var(--color-brand);
outline: 2px solid transparent;
outline-offset: -2px;
@media (prefers-reduced-motion) {
transition: none !important;
}
}
}
</style>

View File

@@ -1,23 +1,58 @@
<template>
<nav
ref="scrollContainer"
class="card-shadow experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
class="experimental-styles-within relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="[mode === 'navigation' ? 'card-shadow' : undefined]"
>
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="index"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="{
'text-button-textSelected': activeIndex === index && !subpageSelected,
'text-contrast': activeIndex === index && subpageSelected,
}"
>
<component :is="link.icon" v-if="link.icon" class="size-5" />
<span class="text-nowrap">{{ link.label }}</span>
</NuxtLink>
<template v-if="mode === 'navigation'">
<NuxtLink
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="link.href"
ref="tabLinkElements"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span class="text-nowrap text-contrast">{{ link.label }}</span>
</NuxtLink>
</template>
<template v-else>
<div
v-for="(link, index) in filteredLinks"
v-show="link.shown === undefined ? true : link.shown"
:key="link.href"
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
@click="emit('tabClick', index, link)"
>
<component
:is="link.icon"
v-if="link.icon"
class="size-5"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-secondary': currentActiveIndex !== index || subpageSelected,
}"
/>
<span
class="text-nowrap"
:class="{
'text-brand': currentActiveIndex === index && !subpageSelected,
'text-contrast': currentActiveIndex !== index || subpageSelected,
}"
>{{ link.label }}</span
>
</div>
</template>
<div
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
@@ -27,7 +62,8 @@
top: sliderTopPx,
right: sliderRightPx,
bottom: sliderBottomPx,
opacity: sliderLeft === 4 && sliderLeft === sliderRight ? 0 : activeIndex === -1 ? 0 : 1,
opacity:
sliderLeft === 4 && sliderLeft === sliderRight ? 0 : currentActiveIndex === -1 ? 0 : 1,
}"
aria-hidden="true"
></div>
@@ -35,7 +71,8 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import type { Component } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
const route = useNativeRoute()
@@ -43,13 +80,26 @@ interface Tab {
label: string
href: string
shown?: boolean
icon?: string
icon?: Component
subpages?: string[]
}
const props = defineProps<{
links: Tab[]
query?: string
const props = withDefaults(
defineProps<{
links: Tab[]
query?: string
mode?: 'navigation' | 'local'
activeIndex?: number
}>(),
{
mode: 'navigation',
query: undefined,
activeIndex: undefined,
},
)
const emit = defineEmits<{
tabClick: [index: number, tab: Tab]
}>()
const scrollContainer = ref<HTMLElement | null>(null)
@@ -58,7 +108,7 @@ const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
const activeIndex = ref(-1)
const currentActiveIndex = ref(-1)
const subpageSelected = ref(false)
const filteredLinks = computed(() =>
@@ -74,30 +124,36 @@ const tabLinkElements = ref()
function pickLink() {
let index = -1
subpageSelected.value = false
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
if (props.query) {
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
if (props.mode === 'local' && props.activeIndex !== undefined) {
index = Math.min(props.activeIndex, filteredLinks.value.length - 1)
} else {
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
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 (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i
subpageSelected.value = true
break
}
} else if (decodeURIComponent(route.path) === link.href) {
index = i
break
} else if (
decodeURIComponent(route.path).includes(link.href) ||
(link.subpages &&
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
) {
index = i
subpageSelected.value = true
break
}
}
activeIndex.value = index
if (activeIndex.value !== -1) {
startAnimation()
currentActiveIndex.value = index
if (currentActiveIndex.value !== -1) {
nextTick(() => startAnimation())
} else {
sliderLeft.value = 0
sliderRight.value = 0
@@ -105,7 +161,12 @@ function pickLink() {
}
function startAnimation() {
const el = tabLinkElements.value[activeIndex.value]?.$el
// In navigation mode, elements are NuxtLinks with $el property
// In local mode, elements are plain divs
const el =
props.mode === 'navigation'
? tabLinkElements.value[currentActiveIndex.value]?.$el
: tabLinkElements.value[currentActiveIndex.value]
if (!el || !el.offsetParent) return
@@ -156,7 +217,29 @@ onMounted(() => {
watch(
() => [route.path, route.query],
() => pickLink(),
() => {
if (props.mode === 'navigation') {
pickLink()
}
},
)
watch(
() => props.activeIndex,
() => {
if (props.mode === 'local') {
pickLink()
}
},
)
watch(
() => props.links,
() => {
// Re-trigger animation when links change
pickLink()
},
{ deep: true },
)
</script>

View File

@@ -20,20 +20,6 @@
</ButtonStyled>
</div>
</div>
<ModerationProjectNags
v-if="
(currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status)
"
:project="project"
:versions="versions"
:current-member="currentMember"
:collapsed="collapsed"
:route-name="routeName"
:tags="tags"
@toggle-collapsed="handleToggleCollapsed"
@set-processing="handleSetProcessing"
/>
</template>
<script setup lang="ts">
@@ -45,8 +31,6 @@ import { computed } from 'vue'
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
import ModerationProjectNags from './moderation/ModerationProjectNags.vue'
const { addNotification } = injectNotificationManager()
interface Tags {
@@ -71,12 +55,9 @@ interface Props {
currentMember?: Member | null
allMembers?: Member[] | null
isSettings?: boolean
collapsed?: boolean
routeName?: string
auth: Auth
tags: Tags
setProcessing?: (processing: boolean) => void
toggleCollapsed?: () => void
updateMembers?: () => void | Promise<void>
}
@@ -144,7 +125,6 @@ const props = withDefaults(defineProps<Props>(), {
allMembers: null,
isSettings: false,
collapsed: false,
routeName: '',
setProcessing: () => {},
toggleCollapsed: () => {},
updateMembers: async () => {},
@@ -164,14 +144,6 @@ const showInvitation = computed<boolean>(() => {
return false
})
function handleToggleCollapsed(): void {
if (props.toggleCollapsed) {
props.toggleCollapsed()
} else {
emit('toggleCollapsed')
}
}
async function handleUpdateMembers(): Promise<void> {
if (props.updateMembers) {
await props.updateMembers()

View File

@@ -0,0 +1,73 @@
<template>
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
MultiStageModal,
} from '@modrinth/ui'
import type { ComponentExposed } from 'vue-component-type-helpers'
import {
createManageVersionContext,
provideManageVersionContext,
} from '~/providers/version/manage-version-modal'
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
const ctx = createManageVersionContext(modal)
provideManageVersionContext(ctx)
const { newDraftVersion } = ctx
const { projectV2 } = injectProjectPageContext()
const { addNotification } = injectNotificationManager()
const { labrinth } = injectModrinthClient()
async function openEditVersionModal(versionId: string, projectId: string, stageId?: string | null) {
try {
const versionData = await labrinth.versions_v3.getVersion(versionId)
const draftVersionData: Labrinth.Versions.v3.DraftVersion = {
project_id: projectId,
version_id: versionId,
name: versionData.name ?? '',
version_number: versionData.version_number ?? '',
changelog: versionData.changelog ?? '',
game_versions: versionData.game_versions ?? [],
version_type: versionData.version_type ?? 'release',
loaders: versionData.loaders ?? [],
dependencies: versionData.dependencies ?? [],
existing_files: versionData.files ?? [],
environment: versionData.environment,
mrpack_loaders: versionData.mrpack_loaders,
}
openCreateVersionModal(draftVersionData, stageId)
} catch (err: any) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
function openCreateVersionModal(
version: Labrinth.Versions.v3.DraftVersion | null = null,
stageId: string | null = null,
) {
newDraftVersion(projectV2.value.id, version)
modal.value?.setStage(stageId ?? 0)
modal.value?.show()
}
defineExpose({
openEditVersionModal,
openCreateVersionModal,
})
</script>

View File

@@ -0,0 +1,56 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
<span v-tooltip="name || projectId" class="truncate font-semibold text-contrast">
{{ name || 'Unknown Project' }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
{{ dependencyType }}
</TagItem>
</div>
<span
v-if="versionName"
v-tooltip="versionName"
class="max-w-[35%] truncate whitespace-nowrap font-medium"
>
{{ versionName }}
</span>
<div class="flex items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<button aria-label="Remove file" class="!shadow-none" @click="emitRemove">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, TagItem } from '@modrinth/ui'
const emit = defineEmits<{
(e: 'fileTypeChange', type: string): void
(e: 'remove'): void
}>()
const { projectId, name, icon, dependencyType, versionName } = defineProps<{
projectId: string
name?: string
icon?: string
dependencyType: Labrinth.Versions.v2.DependencyType
versionName?: string
}>()
function emitRemove() {
emit('remove')
}
</script>

View File

@@ -0,0 +1,86 @@
<template>
<Combobox
v-model="projectId"
placeholder="Select project"
:options="options"
:searchable="true"
search-placeholder="Search by name or paste ID..."
:no-options-message="searchLoading ? 'Loading...' : 'No results found'"
:disable-search-filter="true"
@search-input="(query) => handleSearch(query)"
/>
</template>
<script lang="ts" setup>
import type { ComboboxOption } from '@modrinth/ui'
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { useDebounceFn } from '@vueuse/core'
import { defineAsyncComponent, h } from 'vue'
const { addNotification } = injectNotificationManager()
const projectId = defineModel<string>()
const searchLoading = ref(false)
const options = ref<ComboboxOption<string>[]>([])
const { labrinth } = injectModrinthClient()
const search = async (query: string) => {
query = query.trim()
if (!query) {
searchLoading.value = false
return
}
try {
const results = await labrinth.projects_v2.search({
query: query,
limit: 20,
facets: [
[
'project_type:mod',
'project_type:plugin',
'project_type:shader ',
'project_type:resourcepack',
'project_type:datapack',
],
],
})
const resultsByProjectId = await labrinth.projects_v2.search({
query: '',
limit: 20,
facets: [[`project_id:${query.replace(/[^a-zA-Z0-9]/g, '')}`]], // remove any non-alphanumeric characters
})
options.value = [...resultsByProjectId.hits, ...results.hits].map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
}))
} catch (error: any) {
addNotification({
title: 'An error occurred',
text: error.data ? error.data.description : error,
type: 'error',
})
}
searchLoading.value = false
}
const throttledSearch = useDebounceFn(search, 500)
const handleSearch = async (query: string) => {
searchLoading.value = true
await throttledSearch(query)
}
</script>

View File

@@ -0,0 +1,121 @@
<template>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">Loaders <span class="text-red">*</span></span>
<Chips
v-model="loaderGroup"
:items="groupLabels"
:never-empty="true"
:capitalize="true"
size="small"
/>
<div
class="flex min-h-[150px] flex-1 flex-col gap-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3"
>
<div v-if="groupedLoaders[loaderGroup].length" class="flex flex-col gap-1.5">
<div class="flex flex-wrap gap-2">
<TagItem
v-for="loader in groupedLoaders[loaderGroup]"
:key="`loader-${loader.name}`"
:action="() => toggleLoader(loader.name)"
class="border !border-solid !transition-all hover:bg-button-bgHover hover:no-underline"
:class="
selectedLoaders.includes(loader.name)
? 'border-brand bg-highlight-green text-brand'
: 'border-surface-5'
"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
</TagItem>
</div>
</div>
</div>
<span>Select one or more loaders this version supports.</span>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { Chips, TagItem } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
const selectedLoaders = defineModel<string[]>({ default: [] })
const { loaders } = defineProps<{
loaders: Labrinth.Tags.v2.Loader[]
toggleLoader: (loader: string) => void
}>()
const loaderGroup = ref<GroupLabels>('mods')
type GroupLabels = 'mods' | 'plugins' | 'packs' | 'shaders' | 'other'
const groupLabels: GroupLabels[] = ['mods', 'plugins', 'packs', 'shaders']
function groupLoaders(loaders: Labrinth.Tags.v2.Loader[]) {
const groups: Record<GroupLabels, Labrinth.Tags.v2.Loader[]> = {
mods: [],
plugins: [],
packs: [],
shaders: [],
other: [],
}
const MOD_SORT = [
'fabric',
'neoforge',
'forge',
'quilt',
'liteloader',
'rift',
'ornithe',
'nilloader',
'risugami',
'legacy-fabric',
'bta-babric',
'babric',
'modloader',
'java-agent',
]
const PLUGIN_SORT = [
'paper',
'purpur',
'spigot',
'bukkit',
'sponge',
'folia',
'bungeecord',
'velocity',
'waterfall',
'geyser',
]
const SHADER_SORT = ['optifine', 'iris', 'canvas', 'vanilla']
const PACKS_SORT = ['minecraft', 'datapack']
for (const loader of loaders) {
const name = loader.name.toLowerCase()
if (PACKS_SORT.includes(name)) groups.packs.push(loader)
else if (SHADER_SORT.includes(name)) groups.shaders.push(loader)
else if (PLUGIN_SORT.includes(name)) groups.plugins.push(loader)
else if (MOD_SORT.includes(name)) groups.mods.push(loader)
else groups.other.push(loader)
}
const sortByOrder = (arr: any[], order: string[]) =>
arr.sort((a, b) => order.indexOf(a.name) - order.indexOf(b.name))
sortByOrder(groups.mods, MOD_SORT)
sortByOrder(groups.plugins, PLUGIN_SORT)
sortByOrder(groups.shaders, SHADER_SORT)
return groups
}
const groupedLoaders = computed(() => groupLoaders(loaders))
</script>

View File

@@ -0,0 +1,208 @@
<template>
<div class="space-y-2.5">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
Minecraft versions <span class="text-red">*</span>
</span>
<Chips
v-model="versionType"
:items="['release', 'all']"
:never-empty="true"
:capitalize="true"
size="small"
/>
</div>
<div class="iconified-input w-full">
<SearchIcon aria-hidden="true" />
<input v-model="searchQuery" type="text" placeholder="Search versions" />
</div>
<div
class="flex h-72 select-none flex-col gap-3 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div v-for="group in groupedGameVersions" :key="group.key" class="space-y-1.5">
<span class="font-semibold">{{ group.key }}</span>
<div class="flex flex-wrap gap-2 gap-x-1.5">
<ButtonStyled
v-for="version in group.versions"
:key="version"
:color="
holdingShift && version === anchorVersion
? 'purple'
: modelValue.includes(version)
? 'green'
: 'standard'
"
:highlighted="modelValue.includes(version)"
type="chip"
>
<button
class="!py-1.5 focus:outline-none"
:class="[
versionType === 'all' && !group.isReleaseGroup ? 'w-max' : 'w-16',
modelValue.includes(version) ? '!text-contrast' : '',
]"
@click="() => handleToggleVersion(version)"
@blur="
() => {
if (!holdingShift) anchorVersion = ''
}
"
>
{{ version }}
</button>
</ButtonStyled>
</div>
</div>
<span v-if="!filteredVersions.length">No versions found.</span>
</div>
<div>Hold shift and click to select range.</div>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Chips } from '@modrinth/ui'
import { useMagicKeys } from '@vueuse/core'
import { computed, ref } from 'vue'
type GameVersion = Labrinth.Tags.v2.GameVersion
const props = defineProps<{
modelValue: string[]
gameVersions: Labrinth.Tags.v2.GameVersion[]
}>()
const emit = defineEmits<{
(e: 'update:modelValue', value: string[]): void
}>()
const keys = useMagicKeys()
const holdingShift = computed(() => keys.shift.value)
const versionType = ref<string | null>('release')
const searchQuery = ref('')
const filteredVersions = computed(() =>
props.gameVersions
.filter((v) => versionType.value === 'all' || v.version_type === versionType.value)
.filter(searchFilter),
)
const groupedGameVersions = computed(() => groupVersions(filteredVersions.value))
const allVersionsFlat = computed(() => groupedGameVersions.value.flatMap((group) => group.versions))
const anchorVersion = ref<string | null>(null)
const handleToggleVersion = (version: string) => {
const flat = allVersionsFlat.value
if (holdingShift.value && anchorVersion.value && flat.length) {
const anchorIdx = flat.indexOf(anchorVersion.value)
const targetIdx = flat.indexOf(version)
if (anchorIdx === -1 || targetIdx === -1) {
return toggleVersion(version)
}
const start = Math.min(anchorIdx, targetIdx)
const end = Math.max(anchorIdx, targetIdx)
const range = flat.slice(start, end + 1)
const isTargetSelected = props.modelValue.includes(version)
if (isTargetSelected) {
emit(
'update:modelValue',
props.modelValue.filter((v) => !range.includes(v)),
)
} else {
const newVersions = range.filter((v) => !props.modelValue.includes(v))
emit('update:modelValue', [...props.modelValue, ...newVersions])
}
anchorVersion.value = ''
return
}
toggleVersion(version)
anchorVersion.value = version
}
const toggleVersion = (version: string) => {
const isSelected = props.modelValue.includes(version)
const next = isSelected
? props.modelValue.filter((v) => v !== version)
: [...props.modelValue, version]
emit('update:modelValue', next)
}
const DEV_RELEASE_KEY = 'Snapshots'
function groupVersions(gameVersions: GameVersion[]) {
gameVersions = [...gameVersions].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
)
const getGroupKey = (v: string) => v.split('.').slice(0, 2).join('.')
const groups: Record<string, string[]> = {}
let currentGroupKey = getGroupKey(gameVersions.find((v) => v.major)?.version || '')
gameVersions.forEach((gameVersion) => {
if (gameVersion.version_type === 'release') {
currentGroupKey = getGroupKey(gameVersion.version)
if (!groups[currentGroupKey]) groups[currentGroupKey] = []
groups[currentGroupKey].push(gameVersion.version)
} else {
const key = `${currentGroupKey} ${DEV_RELEASE_KEY}`
if (!groups[key]) groups[key] = []
groups[key].push(gameVersion.version)
}
})
const sortedKeys = Object.keys(groups).sort(compareGroupKeys)
return sortedKeys.map((key) => ({
key,
versions: groups[key].sort((a, b) => compareVersions(b, a)),
isReleaseGroup: !key.includes(DEV_RELEASE_KEY),
}))
}
const getBaseVersion = (key: string) => key.split(' ')[0]
function compareVersions(a: string, b: string) {
const pa = a.split('.').map(Number)
const pb = b.split('.').map(Number)
for (let i = 0; i < 2; i++) {
const na = pa[i] || 0
const nb = pb[i] || 0
if (na > nb) return 1
if (na < nb) return -1
}
return 0
}
function compareGroupKeys(a: string, b: string) {
const aBase = getBaseVersion(a)
const bBase = getBaseVersion(b)
const versionSort = compareVersions(aBase, bBase)
if (versionSort !== 0) return -versionSort // descending
const isADev = a.includes(DEV_RELEASE_KEY)
const isBDev = b.includes(DEV_RELEASE_KEY)
if (isADev && !isBDev) return 1
if (!isADev && isBDev) return -1
return 0
}
function searchFilter(gameVersion: Labrinth.Tags.v2.GameVersion) {
return gameVersion.version.toLowerCase().includes(searchQuery.value.toLowerCase())
}
</script>

View File

@@ -0,0 +1,65 @@
<template>
<div v-if="visibleDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Suggested dependencies</span>
<div class="flex flex-col gap-2">
<template v-for="(dependency, index) in visibleDependencies">
<SuggestedDependency
v-if="dependency"
:key="index"
:project-id="dependency.project_id"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependency_type"
:version-name="dependency.versionName"
@on-add-suggestion="
() =>
handleAddSuggestion({
dependency_type: dependency.dependency_type,
project_id: dependency.project_id,
version_id: dependency.version_id,
})
"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import SuggestedDependency from './SuggestedDependency.vue'
export interface SuggestedDependency extends Labrinth.Versions.v3.Dependency {
icon?: string
name?: string
versionName?: string
}
const props = defineProps<{
suggestedDependencies: SuggestedDependency[]
}>()
const { draftVersion } = injectManageVersionContext()
const visibleDependencies = computed<SuggestedDependency[]>(() =>
props.suggestedDependencies
.filter(
(dep) =>
!draftVersion.value.dependencies?.some(
(d) => d.project_id === dep.project_id && d.version_id === dep.version_id,
),
)
.sort((a, b) => (a.name || '').localeCompare(b.name || '')),
)
const emit = defineEmits<{
(e: 'onAddSuggestion', dependency: Labrinth.Versions.v3.Dependency): void
}>()
function handleAddSuggestion(dependency: Labrinth.Versions.v3.Dependency) {
emit('onAddSuggestion', dependency)
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
>
<div class="grid max-w-[75%] grid-cols-[auto_1fr_auto] items-center gap-2">
<Avatar v-if="icon" :src="icon" alt="dependency-icon" size="20px" :no-shadow="true" />
<span v-tooltip="name || 'Unknown Project'" class="truncate font-semibold text-contrast">
{{ name || 'Unknown Project' }}
</span>
<TagItem class="shrink-0 border !border-solid border-surface-5">
{{ dependencyType }}
</TagItem>
</div>
<span
v-if="versionName"
v-tooltip="versionName"
class="max-w-[35%] truncate whitespace-nowrap font-medium"
>
{{ versionName }}
</span>
<div class="flex items-center justify-end gap-1">
<ButtonStyled size="standard" :circular="true">
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
<PlusIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { PlusIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, TagItem } from '@modrinth/ui'
const emit = defineEmits<{
(e: 'onAddSuggestion'): void
}>()
const { name, icon, dependencyType, versionName } = defineProps<{
name?: string
icon?: string
dependencyType: Labrinth.Versions.v2.DependencyType
versionName?: string
}>()
function emitAddSuggestion() {
emit('onAddSuggestion')
}
</script>

View File

@@ -0,0 +1,107 @@
<template>
<div
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-2 text-button-text"
>
<div class="flex items-center gap-2 overflow-hidden">
<div class="grid h-5 min-h-5 w-5 min-w-5 place-content-center rounded-full bg-green">
<CheckIcon class="text-sm text-black" />
</div>
<span v-tooltip="name" class="overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{{ name }}
</span>
</div>
<div class="flex items-center gap-1">
<template v-if="!isPrimary">
<div class="w-36">
<Combobox
v-model="selectedType"
:searchable="false"
class="rounded-xl border border-solid border-surface-5 text-sm"
:options="versionTypes"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
@update:model-value="emitFileTypeChange"
/>
</div>
</template>
<ButtonStyled v-if="onRemove" size="standard" :circular="true">
<button aria-label="Remove file" class="!shadow-none" @click="onRemove">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
<ButtonStyled v-if="isPrimary" size="standard" :circular="true">
<button
v-tooltip="
editingVersion
? 'Primary file cannot be changed after version is uploaded'
: 'Replace primary file'
"
aria-label="Change primary file"
class="!shadow-none"
:disabled="editingVersion"
@click="primaryFileInput?.click()"
>
<ArrowLeftRightIcon aria-hidden="true" />
<input
ref="primaryFileInput"
class="hidden"
type="file"
:accept="acceptFileFromProjectType(projectV2.project_type)"
:disabled="editingVersion"
@change="
(e) => {
emit('setPrimaryFile', (e.target as HTMLInputElement)?.files?.[0])
;(e.target as HTMLInputElement).value = ''
}
"
/>
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ArrowLeftRightIcon, CheckIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectProjectPageContext } from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import { acceptFileFromProjectType } from '@modrinth/utils'
const { projectV2 } = injectProjectPageContext()
const emit = defineEmits<{
(e: 'setPrimaryFile', file?: File): void
(e: 'setFileType', type: Labrinth.Versions.v3.FileType): void
}>()
const { name, isPrimary, onRemove, initialFileType, editingVersion } = defineProps<{
name: string
isPrimary: boolean
onRemove?: () => void
initialFileType?: Labrinth.Versions.v3.FileType | 'primary'
editingVersion: boolean
}>()
const selectedType = ref<Labrinth.Versions.v3.FileType | 'primary'>(initialFileType || 'unknown')
const primaryFileInput = ref<HTMLInputElement>()
const versionTypes = [
!editingVersion && { class: 'text-sm', value: 'primary', label: 'Primary' },
{ class: 'text-sm', value: 'unknown', label: 'Other' },
{ class: 'text-sm', value: 'required-resource-pack', label: 'Required RP' },
{ class: 'text-sm', value: 'optional-resource-pack', label: 'Optional RP' },
{ class: 'text-sm', value: 'sources-jar', label: 'Sources JAR' },
{ class: 'text-sm', value: 'dev-jar', label: 'Dev JAR' },
{ class: 'text-sm', value: 'javadoc-jar', label: 'Javadoc JAR' },
{ class: 'text-sm', value: 'signature', label: 'Signature' },
].filter(Boolean) as DropdownOption<Labrinth.Versions.v3.FileType | 'primary'>[]
function emitFileTypeChange() {
if (selectedType.value === 'primary') emit('setPrimaryFile')
else emit('setFileType', selectedType.value)
}
</script>

View File

@@ -0,0 +1,23 @@
<template>
<div class="w-full">
<MarkdownEditor
v-model="draftVersion.changelog"
:on-image-upload="onImageUpload"
:max-height="500"
/>
</div>
</template>
<script lang="ts" setup>
import { MarkdownEditor } from '@modrinth/ui'
import { useImageUpload } from '~/composables/image-upload.ts'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion } = injectManageVersionContext()
async function onImageUpload(file: File) {
const response = await useImageUpload(file, { context: 'version' })
return response.url
}
</script>

View File

@@ -0,0 +1,283 @@
<template>
<div class="flex w-full max-w-full flex-col gap-6 sm:w-[512px]">
<div class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Add dependency</span>
<div class="flex flex-col gap-3 rounded-2xl border border-solid border-surface-5 p-4">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast">Project <span class="text-red">*</span></span>
<DependencySelect v-model="newDependencyProjectId" />
</div>
<template v-if="newDependencyProjectId">
<div class="grid gap-2.5">
<span class="font-semibold text-contrast"> Version </span>
<Combobox
v-model="newDependencyVersionId"
placeholder="Select version"
:options="[{ label: 'Any version', value: null }, ...newDependencyVersions]"
:searchable="true"
/>
</div>
<div class="grid gap-2.5">
<span class="font-semibold text-contrast"> Dependency relation </span>
<Combobox
v-model="newDependencyType"
placeholder="Select dependency type"
:options="[
{ label: 'Required', value: 'required' },
{ label: 'Optional', value: 'optional' },
{ label: 'Incompatible', value: 'incompatible' },
{ label: 'Embedded', value: 'embedded' },
]"
/>
</div>
<ButtonStyled>
<button
class="self-start"
:disabled="!newDependencyProjectId"
@click="
() =>
addDependency(
toRaw({
project_id: newDependencyProjectId,
version_id: newDependencyVersionId || undefined,
dependency_type: newDependencyType,
}),
)
"
>
Add Dependency
</button>
</ButtonStyled>
</template>
</div>
</div>
<SuggestedDependencies
:suggested-dependencies="suggestedDependencies"
@on-add-suggestion="handleAddSuggestedDependency"
/>
<div v-if="addedDependencies.length" class="flex flex-col gap-4">
<span class="font-semibold text-contrast">Added dependencies</span>
<div class="5 flex flex-col gap-2">
<template v-for="(dependency, index) in addedDependencies">
<AddedDependencyRow
v-if="dependency"
:key="index"
:project-id="dependency.projectId"
:name="dependency.name"
:icon="dependency.icon"
:dependency-type="dependency.dependencyType"
:version-name="dependency.versionName"
@remove="() => removeDependency(index)"
/>
</template>
<span v-if="!addedDependencies.length"> No dependencies added. </span>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import {
ButtonStyled,
Combobox,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
} from '@modrinth/ui'
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
import DependencySelect from '~/components/ui/create-project-version/components/DependencySelect.vue'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import AddedDependencyRow from '../components/AddedDependencyRow.vue'
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
const { addNotification } = injectNotificationManager()
const { labrinth } = injectModrinthClient()
const errorNotification = (err: any) => {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
const newDependencyProjectId = ref<string>()
const newDependencyType = ref<Labrinth.Versions.v2.DependencyType>('required')
const newDependencyVersionId = ref<string | null>(null)
const newDependencyVersions = ref<DropdownOption<string>[]>([])
const projectsFetchLoading = ref(false)
const suggestedDependencies = ref<
Array<Labrinth.Versions.v3.Dependency & { name?: string; icon?: string; versionName?: string }>
>([])
// reset to defaults when select different project
watch(newDependencyProjectId, async () => {
newDependencyVersionId.value = null
newDependencyType.value = 'required'
if (!newDependencyProjectId.value) {
newDependencyVersions.value = []
} else {
try {
const versions = await labrinth.versions_v3.getProjectVersions(newDependencyProjectId.value)
newDependencyVersions.value = versions.map((version) => ({
label: version.name,
value: version.id,
}))
} catch (error: any) {
errorNotification(error)
}
}
})
const { draftVersion, dependencyProjects, dependencyVersions, getProject, getVersion } =
injectManageVersionContext()
const { projectV2: project } = injectProjectPageContext()
const getSuggestedDependencies = async () => {
try {
suggestedDependencies.value = []
if (!draftVersion.value.game_versions?.length || !draftVersion.value.loaders?.length) {
return
}
try {
const versions = await labrinth.versions_v3.getProjectVersions(project.value.id, {
loaders: draftVersion.value.loaders,
})
// Get the most recent matching version and extract its dependencies
if (versions.length > 0) {
const mostRecentVersion = versions[0]
for (const dep of mostRecentVersion.dependencies) {
suggestedDependencies.value.push({
project_id: dep.project_id,
version_id: dep.version_id,
dependency_type: dep.dependency_type,
file_name: dep.file_name,
})
}
}
} catch (error: any) {
console.error(`Failed to get versions for project ${project.value.id}:`, error)
}
for (const dep of suggestedDependencies.value) {
try {
if (dep.project_id) {
const proj = await getProject(dep.project_id)
dep.name = proj.name
dep.icon = proj.icon_url
}
if (dep.version_id) {
const version = await getVersion(dep.version_id)
dep.versionName = version.name
}
} catch (error: any) {
console.error(`Failed to fetch project/version data for dependency:`, error)
}
}
} catch (error: any) {
errorNotification(error)
}
}
onMounted(() => {
getSuggestedDependencies()
})
watch(
draftVersion,
async (draftVersion) => {
const deps = draftVersion.dependencies || []
for (const dep of deps) {
if (dep?.project_id) {
try {
await getProject(dep.project_id)
} catch (error: any) {
errorNotification(error)
}
}
if (dep?.version_id) {
try {
await getVersion(dep.version_id)
} catch (error: any) {
errorNotification(error)
}
}
}
projectsFetchLoading.value = false
},
{ immediate: true, deep: true },
)
const addedDependencies = computed(() =>
(draftVersion.value.dependencies || [])
.map((dep) => {
if (!dep.project_id) return null
const dependencyProject = dependencyProjects.value[dep.project_id]
const versionName = dependencyVersions.value[dep.version_id || '']?.name ?? ''
if (!dependencyProject && projectsFetchLoading.value) return null
return {
projectId: dep.project_id,
name: dependencyProject?.name,
icon: dependencyProject?.icon_url,
dependencyType: dep.dependency_type,
versionName,
}
})
.filter(Boolean),
)
const addDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
// already added
if (
draftVersion.value.dependencies.find(
(d) => d.project_id === dependency.project_id && d.version_id === dependency.version_id,
)
) {
addNotification({
title: 'Dependency already added',
text: 'You cannot add the same dependency twice.',
type: 'error',
})
return
}
projectsFetchLoading.value = true
draftVersion.value.dependencies.push(dependency)
newDependencyProjectId.value = undefined
}
const removeDependency = (index: number) => {
if (!draftVersion.value.dependencies) return
draftVersion.value.dependencies.splice(index, 1)
}
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
draftVersion.value.dependencies?.push({
project_id: dependency.project_id,
version_id: dependency.version_id,
dependency_type: dependency.dependency_type,
})
}
</script>

View File

@@ -0,0 +1,275 @@
<template>
<div class="flex flex-col gap-6 sm:w-[512px]">
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version type <span class="text-red">*</span>
</span>
<Chips
v-model="draftVersion.version_type"
:items="['release', 'beta', 'alpha']"
:never-empty="true"
:capitalize="true"
/>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Version number <span class="text-red">*</span>
</span>
<input
id="version-number"
v-model="draftVersion.version_number"
placeholder="Enter version number, e.g. 1.2.3-alpha.1"
type="text"
autocomplete="off"
maxlength="32"
/>
<span> The version number differentiates this specific version from others. </span>
</div>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast"> Version subtitle </span>
<input
id="version-number"
v-model="draftVersion.name"
placeholder="Enter subtitle..."
type="text"
autocomplete="off"
maxlength="256"
/>
</div>
<template v-if="!noLoadersProject && (inferredVersionData?.loaders?.length || editingVersion)">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedLoaders ? 'Detected loaders' : 'Loaders' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editLoaders"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template
v-for="loader in draftVersionLoaders.map((selectedLoader) =>
loaders.find((loader) => selectedLoader === loader.name),
)"
>
<TagItem
v-if="loader"
:key="`loader-${loader.name}`"
class="border !border-solid border-surface-5 hover:no-underline"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
</TagItem>
</template>
<span v-if="!draftVersion.loaders.length">No loaders selected.</span>
</div>
</div>
</div>
</template>
<template v-if="inferredVersionData?.game_versions?.length || editingVersion">
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast">
{{ usingDetectedVersions ? 'Detected versions' : 'Versions' }}
</span>
<ButtonStyled type="transparent" size="standard">
<button
v-tooltip="isModpack ? 'Modpack versions cannot be edited' : undefined"
:disabled="isModpack"
@click="editVersions"
>
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div
class="flex max-h-56 flex-col gap-1.5 gap-y-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<TagItem
v-for="version in draftVersion.game_versions"
:key="version"
class="border !border-solid border-surface-5 hover:no-underline"
>
{{ version }}
</TagItem>
<span v-if="!draftVersion.game_versions.length">No versions selected.</span>
</div>
</div>
</div>
</template>
<template
v-if="
!noEnvironmentProject &&
((!editingVersion && inferredVersionData?.environment) ||
(editingVersion && draftVersion.environment))
"
>
<div class="flex flex-col gap-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Environment </span>
<ButtonStyled type="transparent" size="standard">
<button @click="editEnvironment">
<EditIcon />
Edit
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
<div v-if="draftVersion.environment" class="flex flex-col gap-1">
<div class="font-semibold text-contrast">
{{ environmentCopy.title }}
</div>
<div class="text-sm font-medium">{{ environmentCopy.description }}</div>
</div>
<span v-else class="text-sm font-medium">No environment has been set.</span>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import { EditIcon } from '@modrinth/assets'
import { ButtonStyled, Chips, TagItem } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { useGeneratedState } from '~/composables/generated'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const {
draftVersion,
inferredVersionData,
projectType,
editingVersion,
noLoadersProject,
noEnvironmentProject,
modal,
} = injectManageVersionContext()
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const isModpack = computed(() => projectType.value === 'modpack')
const draftVersionLoaders = computed(() =>
[
...new Set([...draftVersion.value.loaders, ...(draftVersion.value.mrpack_loaders ?? [])]),
].filter((loader) => loader !== 'mrpack'),
)
const editLoaders = () => {
modal.value?.setStage('from-details-loaders')
}
const editVersions = () => {
modal.value?.setStage('from-details-mc-versions')
}
const editEnvironment = () => {
modal.value?.setStage('from-details-environment')
}
const usingDetectedVersions = computed(() => {
if (!inferredVersionData.value?.game_versions) return false
const versionsMatch =
draftVersion.value.game_versions.length === inferredVersionData.value.game_versions.length &&
draftVersion.value.game_versions.every((version) =>
inferredVersionData.value?.game_versions?.includes(version),
)
return versionsMatch
})
const usingDetectedLoaders = computed(() => {
if (!inferredVersionData.value?.loaders) return false
const loadersMatch =
draftVersion.value.loaders.length === inferredVersionData.value.loaders.length &&
draftVersion.value.loaders.every((loader) =>
inferredVersionData.value?.loaders?.includes(loader),
)
return loadersMatch
})
const environmentCopy = computed(() => {
const emptyMessage = {
title: 'No environment set',
description: 'The environment for this version has not been specified.',
}
if (!draftVersion.value.environment) return emptyMessage
const envCopy: Record<string, { title: string; description: string }> = {
client_only: {
title: 'Client-side only',
description: 'All functionality is done client-side and is compatible with vanilla servers.',
},
server_only: {
title: 'Server-side only',
description: 'All functionality is done server-side and is compatible with vanilla clients.',
},
singleplayer_only: {
title: 'Singleplayer only',
description: 'Only functions in Singleplayer or when not connected to a Multiplayer server.',
},
dedicated_server_only: {
title: 'Server-side only',
description: 'All functionality is done server-side and is compatible with vanilla clients.',
},
client_and_server: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_only_server_optional: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
server_only_client_optional: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_or_server: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
client_or_server_prefers_both: {
title: 'Client and server',
description: 'Has some functionality on both the client and server, even if only partially.',
},
unknown: {
title: 'Unknown environment',
description: 'The environment for this version could not be determined.',
},
}
return (
envCopy[draftVersion.value.environment] || {
title: 'Unknown environment',
description: `The environment: "${draftVersion.value.environment}" is not recognized.`,
}
)
})
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div class="sm:w-[512px]">
<ProjectSettingsEnvSelector v-model="draftVersion.environment" />
</div>
</template>
<script lang="ts" setup>
import { ProjectSettingsEnvSelector } from '@modrinth/ui'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
const { draftVersion } = injectManageVersionContext()
</script>

View File

@@ -0,0 +1,232 @@
<template>
<div class="flex w-full flex-col gap-4 sm:w-[512px]">
<template v-if="!(filesToAdd.length || draftVersion.existing_files?.length)">
<DropzoneFileInput
aria-label="Upload file"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
@change="handleNewFiles"
/>
</template>
<template v-else>
<div class="flex flex-col gap-2">
<span class="text-base font-semibold text-contrast">Primary file</span>
<div class="flex flex-col gap-2.5">
<VersionFileRow
v-if="primaryFile"
:key="primaryFile.name"
:name="primaryFile.name"
:is-primary="true"
:editing-version="editingVersion"
:on-remove="undefined"
@set-primary-file="
(file) => {
if (file && !editingVersion) filesToAdd[0] = { file }
}
"
/>
</div>
<span>
The primary file is the default file a user downloads when installing the project.
</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex flex-col gap-2">
<Admonition v-if="hasSupplementaryFiles" type="warning">
{{ formatMessage(messages.addFilesAdmonition) }}
</Admonition>
<span class="text-base font-semibold text-contrast">Supplementary files</span>
<DropzoneFileInput
aria-label="Upload additional file"
multiple
:accept="acceptFileFromProjectType(projectV2.project_type)"
:max-size="524288000"
size="small"
:primary-prompt="null"
secondary-prompt="Drag and drop files or click to browse"
@change="handleNewFiles"
/>
<div v-if="hasSupplementaryFiles" class="flex flex-col gap-2.5">
<VersionFileRow
v-for="versionFile in supplementaryExistingFiles"
:key="versionFile.filename"
:name="versionFile.filename"
:is-primary="false"
:initial-file-type="versionFile.file_type"
:editing-version="editingVersion"
:on-remove="() => handleRemoveExistingFile(versionFile.hashes.sha1 || '')"
@set-file-type="(type) => (versionFile.file_type = type)"
/>
<VersionFileRow
v-for="(versionFile, idx) in supplementaryNewFiles"
:key="versionFile.file.name"
:name="versionFile.file.name"
:is-primary="false"
:initial-file-type="versionFile.fileType"
:editing-version="editingVersion"
:on-remove="() => handleRemoveFile(idx + (primaryFile?.existing ? 0 : 1))"
@set-file-type="(type) => (versionFile.fileType = type)"
@set-primary-file="handleSetPrimaryFile(idx + (primaryFile?.existing ? 0 : 1))"
/>
</div>
</div>
<span>
You can optionally add supplementary files such as source code, documentation, or required
resource packs.
</span>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import { Admonition, DropzoneFileInput, injectProjectPageContext } from '@modrinth/ui'
import { acceptFileFromProjectType } from '@modrinth/utils'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import VersionFileRow from '../components/VersionFileRow.vue'
const { projectV2 } = injectProjectPageContext()
const { formatMessage } = useVIntl()
const {
draftVersion,
filesToAdd,
existingFilesToDelete,
setPrimaryFile,
setInferredVersionData,
editingVersion,
} = injectManageVersionContext()
const addDetectedData = async () => {
if (editingVersion.value) return
const primaryFile = filesToAdd.value[0]?.file
if (!primaryFile) return
try {
const inferredData = await setInferredVersionData(primaryFile, projectV2.value)
const mappedInferredData: Partial<Labrinth.Versions.v3.DraftVersion> = {
...inferredData,
name: inferredData.name || '',
}
draftVersion.value = {
...draftVersion.value,
...mappedInferredData,
}
} catch (err) {
console.error('Error parsing version file data', err)
}
}
// add detected data when the primary file changes
watch(
() => filesToAdd.value[0]?.file,
() => addDetectedData(),
)
function handleNewFiles(newFiles: File[]) {
// detect primary file if no primary file is set
const primaryFileIndex = primaryFile.value ? null : detectPrimaryFileIndex(newFiles)
newFiles.forEach((file) => filesToAdd.value.push({ file }))
if (primaryFileIndex !== null) {
if (primaryFileIndex) setPrimaryFile(primaryFileIndex)
}
}
function handleRemoveFile(index: number) {
filesToAdd.value.splice(index, 1)
}
function detectPrimaryFileIndex(files: File[]): number {
const extensionPriority = ['.jar', '.zip', '.litemod', '.mrpack', '.mrpack-primary']
for (const ext of extensionPriority) {
const matches = files.filter((file) => file.name.toLowerCase().endsWith(ext))
if (matches.length > 0) {
const shortest = matches.reduce((a, b) => (a.name.length < b.name.length ? a : b))
return files.indexOf(shortest)
}
}
return 0
}
function handleRemoveExistingFile(sha1: string) {
existingFilesToDelete.value.push(sha1)
draftVersion.value.existing_files = draftVersion.value.existing_files?.filter(
(file) => file.hashes.sha1 !== sha1,
)
}
function handleSetPrimaryFile(index: number) {
setPrimaryFile(index)
}
interface PrimaryFile {
name: string
fileType?: string
existing?: boolean
}
const primaryFile = computed<PrimaryFile | null>(() => {
const existingPrimaryFile = draftVersion.value.existing_files?.[0]
if (existingPrimaryFile) {
return {
name: existingPrimaryFile.filename,
fileType: existingPrimaryFile.file_type,
existing: true,
}
}
const addedPrimaryFile = filesToAdd.value[0]
if (addedPrimaryFile) {
return {
name: addedPrimaryFile.file.name,
fileType: addedPrimaryFile.fileType,
existing: false,
}
}
return null
})
const supplementaryNewFiles = computed(() => {
if (primaryFile.value?.existing) {
return filesToAdd.value
} else {
return filesToAdd.value.slice(1)
}
})
const supplementaryExistingFiles = computed(() => {
if (primaryFile.value?.existing) {
return draftVersion.value.existing_files?.slice(1)
} else {
return draftVersion.value.existing_files
}
})
const hasSupplementaryFiles = computed(
() => filesToAdd.value.length + (draftVersion.value.existing_files?.length || 0) > 1,
)
const messages = defineMessages({
addFilesAdmonition: {
id: 'create-project-version.create-modal.stage.add-files.admonition',
defaultMessage:
'Supplementary files are for supporting resources like source code, not for alternative versions or variants.',
},
})
</script>

View File

@@ -0,0 +1,69 @@
<template>
<div class="space-y-6 sm:w-[512px]">
<LoaderPicker
v-model="draftVersion.loaders"
:loaders="generatedState.loaders"
:toggle-loader="toggleLoader"
/>
<div v-if="draftVersion.loaders.length" class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Added loaders </span>
<ButtonStyled type="transparent" size="standard">
<button @click="onClearAll()">Clear all</button>
</ButtonStyled>
</div>
<div
class="flex flex-col gap-1.5 gap-y-4 rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template
v-for="loader in draftVersion.loaders.map((loaderName) =>
loaders.find((loader) => loaderName === loader.name),
)"
>
<TagItem
v-if="loader"
:key="`loader-${loader.name}`"
:action="() => toggleLoader(loader.name)"
class="border !border-solid border-surface-5 !transition-all hover:bg-button-bgHover hover:no-underline"
:style="`--_color: var(--color-platform-${loader.name})`"
>
<div v-html="loader.icon"></div>
{{ formatCategory(loader.name) }}
<XIcon class="text-secondary" />
</TagItem>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { XIcon } from '@modrinth/assets'
import { ButtonStyled, TagItem } from '@modrinth/ui'
import { formatCategory } from '@modrinth/utils'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import LoaderPicker from '../components/LoaderPicker.vue'
const generatedState = useGeneratedState()
const loaders = computed(() => generatedState.value.loaders)
const { draftVersion } = injectManageVersionContext()
const toggleLoader = (loader: string) => {
if (draftVersion.value.loaders.includes(loader)) {
draftVersion.value.loaders = draftVersion.value.loaders.filter((l) => l !== loader)
} else {
draftVersion.value.loaders = [...draftVersion.value.loaders, loader]
}
}
const onClearAll = () => {
draftVersion.value.loaders = []
}
</script>

View File

@@ -0,0 +1,59 @@
<template>
<div class="flex flex-col gap-6 sm:w-[512px]">
<McVersionPicker v-model="draftVersion.game_versions" :game-versions="gameVersions" />
<div v-if="draftVersion.game_versions.length" class="space-y-1">
<div class="flex items-center justify-between">
<span class="font-semibold text-contrast"> Added versions </span>
<ButtonStyled type="transparent" size="standard">
<button @click="clearAllVersions()">Clear all</button>
</ButtonStyled>
</div>
<div
class="flex max-h-56 flex-col gap-1.5 gap-y-4 overflow-y-auto rounded-xl border border-solid border-surface-5 p-3 py-4"
>
<div class="flex flex-wrap gap-2">
<template v-if="draftVersion.game_versions.length">
<TagItem
v-for="version in draftVersion.game_versions"
:key="version"
:action="() => toggleVersion(version)"
class="border !border-solid border-surface-5 !transition-all hover:bg-button-bgHover hover:no-underline"
>
{{ version }}
<XIcon />
</TagItem>
</template>
<template v-else>
<span>No versions selected.</span>
</template>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { XIcon } from '@modrinth/assets'
import { ButtonStyled, TagItem } from '@modrinth/ui'
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
import McVersionPicker from '../components/McVersionPicker.vue'
const generatedState = useGeneratedState()
const gameVersions = generatedState.value.gameVersions
const { draftVersion } = injectManageVersionContext()
const toggleVersion = (version: string) => {
if (draftVersion.value.game_versions.includes(version)) {
draftVersion.value.game_versions = draftVersion.value.game_versions.filter((v) => v !== version)
} else {
draftVersion.value.game_versions.push(version)
}
}
const clearAllVersions = () => {
draftVersion.value.game_versions = []
}
</script>

View File

@@ -247,13 +247,7 @@ async function createProject() {
})
modal.value.hide()
await router.push({
name: 'type-id',
params: {
type: 'project',
id: slug.value,
},
})
await router.push(`/project/${slug.value}/settings`)
} catch (err) {
addNotification({
title: formatMessage(messages.errorTitle),

View File

@@ -158,12 +158,18 @@ import {
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { Admonition, ButtonStyled, Chips, injectNotificationManager, NewModal } from '@modrinth/ui'
import {
Admonition,
ButtonStyled,
Chips,
injectNotificationManager,
NewModal,
normalizeChildren,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { type FormRequestResponse, useAvalara1099 } from '@/composables/avalara1099'
import { normalizeChildren } from '@/utils/vue-children.ts'
const props = withDefaults(
defineProps<{

View File

@@ -8,10 +8,21 @@
leave-to-class="opacity-0 max-h-0"
>
<div v-if="amount > 0" class="flex flex-col gap-2.5 rounded-[20px] bg-surface-2 p-4">
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownAmount) }}</span>
<span class="font-semibold text-contrast">{{ formatMoney(amount || 0) }}</span>
</div>
<template v-if="isGiftCard && shouldShowExchangeRate">
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownGiftCardValue) }}</span>
<span class="font-semibold text-contrast"
>{{ formatMoney(amount || 0) }} ({{ formattedLocalCurrency }})</span
>
</div>
</template>
<template v-else>
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownAmount) }}</span>
<span class="font-semibold text-contrast">{{ formatMoney(amount || 0) }}</span>
</div>
</template>
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownFee) }}</span>
<span class="h-4 font-semibold text-contrast">
@@ -21,6 +32,7 @@
<template v-else>-{{ formatMoney(fee || 0) }}</template>
</span>
</div>
<div class="h-px bg-surface-5" />
<div class="flex items-center justify-between">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownNetAmount) }}</span>
@@ -31,7 +43,7 @@
</template>
</span>
</div>
<template v-if="shouldShowExchangeRate">
<template v-if="shouldShowExchangeRate && !isGiftCard">
<div class="flex items-center justify-between text-sm">
<span class="text-primary">{{ formatMessage(messages.feeBreakdownExchangeRate) }}</span>
<span class="text-secondary"
@@ -56,10 +68,12 @@ const props = withDefaults(
feeLoading: boolean
exchangeRate?: number | null
localCurrency?: string
isGiftCard?: boolean
}>(),
{
exchangeRate: null,
localCurrency: undefined,
isGiftCard: false,
},
)
@@ -115,5 +129,13 @@ const messages = defineMessages({
id: 'dashboard.creator-withdraw-modal.fee-breakdown-exchange-rate',
defaultMessage: 'FX rate',
},
feeBreakdownGiftCardValue: {
id: 'dashboard.creator-withdraw-modal.fee-breakdown-gift-card-value',
defaultMessage: 'Gift card value',
},
feeBreakdownUsdEquivalent: {
id: 'dashboard.creator-withdraw-modal.fee-breakdown-usd-equivalent',
defaultMessage: 'USD equivalent',
},
})
</script>

View File

@@ -124,6 +124,7 @@
</template>
<script setup lang="ts">
import { normalizeChildren } from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
@@ -133,7 +134,6 @@ import ConfettiExplosion from 'vue-confetti-explosion'
import { type TremendousProviderData, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { getRailConfig } from '@/utils/muralpay-rails'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData } = useWithdrawContext()
const { formatMessage } = useVIntl()

View File

@@ -104,7 +104,13 @@
<script setup lang="ts">
import { CheckIcon, PayPalColorIcon, SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Checkbox, financialMessages, formFieldLabels } from '@modrinth/ui'
import {
ButtonStyled,
Checkbox,
financialMessages,
formFieldLabels,
normalizeChildren,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { useDebounceFn } from '@vueuse/core'
@@ -114,7 +120,6 @@ import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
useWithdrawContext()

View File

@@ -84,6 +84,7 @@ import {
ButtonStyled,
Combobox,
injectNotificationManager,
normalizeChildren,
useDebugLogger,
} from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
@@ -93,7 +94,6 @@ import { useGeolocation } from '@vueuse/core'
import { useCountries, useFormattedCountries, useUserCountry } from '@/composables/country.ts'
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const debug = useDebugLogger('MethodSelectionStage')
const {

View File

@@ -207,6 +207,9 @@ import {
financialMessages,
formFieldLabels,
formFieldPlaceholders,
getBlockchainColor,
getBlockchainIcon,
normalizeChildren,
} from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
@@ -217,14 +220,7 @@ import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
import { useGeneratedState } from '@/composables/generated'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
import {
getBlockchainColor,
getBlockchainIcon,
getCurrencyColor,
getCurrencyIcon,
} from '@/utils/finance-icons.ts'
import { getRailConfig } from '@/utils/muralpay-rails'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees } = useWithdrawContext()
const { formatMessage } = useVIntl()

View File

@@ -220,14 +220,14 @@
<script setup lang="ts">
import { Chips, Combobox, formFieldLabels, formFieldPlaceholders } from '@modrinth/ui'
import { useVIntl } from '@vintl/vintl'
// TODO: Switch to using Muralpay's improved endpoint when it's available.
import iso3166 from 'iso-3166-2'
import { useFormattedCountries } from '@/composables/country.ts'
import { useGeneratedState } from '@/composables/generated.ts'
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
const { withdrawData } = useWithdrawContext()
const { formatMessage } = useVIntl()
const generatedState = useGeneratedState()
const providerData = withdrawData.value.providerData
const existingKycData = providerData.type === 'muralpay' ? providerData.kycData : null
@@ -283,12 +283,15 @@ const subdivisionOptions = computed(() => {
const selectedCountry = formData.value.physicalAddress.country
if (!selectedCountry) return []
const subdivisions = generatedState.value.subdivisions?.[selectedCountry] ?? []
const country = iso3166.country(selectedCountry)
if (!country) return []
return subdivisions.map((sub) => ({
value: sub.code.includes('-') ? sub.code.split('-')[1] : sub.code,
label: sub.localVariant || sub.name,
}))
return Object.entries(country.sub)
.map(([code, sub]) => ({
value: code.split('-').slice(1).join('-'),
label: sub.name,
}))
.sort((a, b) => a.label.localeCompare(b.label))
})
watch(

View File

@@ -74,14 +74,13 @@
<script setup lang="ts">
import { FileTextIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled } from '@modrinth/ui'
import { Admonition, ButtonStyled, normalizeChildren } from '@modrinth/ui'
import { formatMoney } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { computed } from 'vue'
import { TAX_THRESHOLD_ACTUAL } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const props = defineProps<{
balance: any

View File

@@ -111,28 +111,202 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{ formatMoney(effectiveMinAmount) }} min,
{{ formatMoney(selectedMethodDetails.interval?.standard?.max ?? effectiveMaxAmount) }}
{{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
fixedDenominationMin ?? effectiveMinAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
min,
{{
formatMoney(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
max withdrawal amount.
</span>
<span
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
You need at least {{ formatMoney(effectiveMinAmount)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
effectiveMinAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
to use this gift card.
</span>
</div>
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast"
>{{ formatMessage(formFieldLabels.amount) }} <span class="text-red">*</span></span
>
<span class="text-md font-semibold text-contrast">
<template v-if="useDenominationSuggestions">
{{ formatMessage(messages.searchAmountLabel) }} ({{ selectedMethodCurrencyCode }})
</template>
<template v-else>
{{ formatMessage(formFieldLabels.amount) }}
</template>
<span class="text-red">*</span>
</span>
</label>
<div v-if="showGiftCardSelector && useFixedDenominations" class="flex flex-col gap-2.5">
<Chips
v-model="selectedDenomination"
:items="denominationOptions"
:format-label="(amt: number) => formatMoney(amt)"
:never-empty="false"
:capitalize="false"
/>
<span v-if="denominationOptions.length === 0" class="text-error text-sm">
<template v-if="useDenominationSuggestions">
<div class="iconified-input w-full">
<SearchIcon aria-hidden="true" />
<input
v-model.number="denominationSearchInput"
type="number"
step="0.01"
:min="0"
:disabled="effectiveMinAmount > roundedMaxAmount"
:placeholder="formatMessage(messages.enterDenominationPlaceholder)"
class="!bg-surface-4"
@input="hasTouchedSuggestions = true"
/>
</div>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span
v-if="
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
"
class="text-sm text-secondary"
>
{{
formatMessage(messages.balanceWorthHint, {
usdBalance: formatMoney(roundedMaxAmount),
localBalance: formatAmountForDisplay(
roundedMaxAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
),
})
}}
</span>
</Transition>
</template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-96"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-96"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="
!useDenominationSuggestions ||
(denominationSearchInput && displayedSuggestions.length > 0)
"
class="overflow-hidden pt-0"
>
<span
v-if="useDenominationSuggestions"
class="mb-1 block text-sm font-medium text-secondary"
>
{{ formatMessage(messages.availableDenominationsLabel) }}
</span>
<div class="p-[2px]">
<Chips
v-model="selectedDenomination"
:items="useDenominationSuggestions ? displayedSuggestions : denominationOptions"
:format-label="
(amt: number) =>
formatAmountForDisplay(
amt,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
"
:never-empty="false"
:capitalize="false"
/>
</div>
<span
v-if="useDenominationSuggestions && hasTouchedSuggestions && !hasSelectedDenomination"
class="mt-2.5 block text-sm text-orange"
>
{{ formatMessage(messages.selectDenominationRequired) }}
</span>
<span
v-if="
!useDenominationSuggestions &&
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
"
class="mt-2 block text-sm text-secondary"
>
{{
formatMessage(messages.balanceWorthHint, {
usdBalance: formatMoney(roundedMaxAmount),
localBalance: formatAmountForDisplay(
roundedMaxAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
),
})
}}
</span>
</div>
</Transition>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span
v-if="
useDenominationSuggestions &&
denominationSearchInput &&
displayedSuggestions.length === 0
"
class="text-sm text-secondary"
>
{{ noSuggestionsMessage }}
</span>
</Transition>
<span
v-if="!useDenominationSuggestions && denominationOptions.length === 0"
class="text-error text-sm"
>
No denominations available for your current balance
</span>
</div>
@@ -149,12 +323,15 @@
</div>
<WithdrawFeeBreakdown
v-if="allRequiredFieldsFilled"
v-if="allRequiredFieldsFilled && formData.amount && formData.amount > 0"
:amount="formData.amount || 0"
:fee="calculatedFee"
:fee-loading="feeLoading"
:exchange-rate="exchangeRate"
:local-currency="showPayPalCurrencySelector ? selectedCurrency : undefined"
:exchange-rate="showGiftCardSelector ? selectedMethodExchangeRate : giftCardExchangeRate"
:local-currency="
showGiftCardSelector ? (selectedMethodCurrencyCode ?? undefined) : giftCardCurrencyCode
"
:is-gift-card="showGiftCardSelector"
/>
<Checkbox v-model="agreedTerms">
@@ -173,6 +350,7 @@
</template>
<script setup lang="ts">
import { SearchIcon } from '@modrinth/assets'
import {
Admonition,
Checkbox,
@@ -181,6 +359,7 @@ import {
financialMessages,
formFieldLabels,
formFieldPlaceholders,
normalizeChildren,
paymentMethodMessages,
useDebugLogger,
} from '@modrinth/ui'
@@ -195,7 +374,6 @@ import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown
import { useAuth } from '@/composables/auth.js'
import { useBaseFetch } from '@/composables/fetch.js'
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
import { normalizeChildren } from '@/utils/vue-children.ts'
const debug = useDebugLogger('TremendousDetailsStage')
const {
@@ -285,6 +463,9 @@ const formData = ref<Record<string, any>>({
const selectedGiftCardId = ref<string | null>(withdrawData.value.selection.methodId || null)
const denominationSearchInput = ref<number | undefined>(undefined)
const hasTouchedSuggestions = ref(false)
const currencyOptions = [
{ value: 'USD', label: 'USD' },
{ value: 'AUD', label: 'AUD' },
@@ -373,6 +554,8 @@ const rewardOptions = ref<
fixed?: { values: number[] }
standard?: { min: number; max: number }
}
currencyCode?: string | null
exchangeRate?: number | null
}
}>
>([])
@@ -390,24 +573,188 @@ const selectedMethodDetails = computed(() => {
return option?.methodDetails || null
})
const selectedMethodCurrencyCode = computed(() => selectedMethodDetails.value?.currencyCode || null)
const selectedMethodExchangeRate = computed(() => selectedMethodDetails.value?.exchangeRate || null)
const giftCardCurrencyCode = computed(() => {
if (showPayPalCurrencySelector.value) {
return selectedCurrency.value !== 'USD' ? selectedCurrency.value : undefined
}
if (
showGiftCardSelector.value &&
selectedMethodCurrencyCode.value &&
selectedMethodCurrencyCode.value !== 'USD'
) {
return selectedMethodCurrencyCode.value
}
return undefined
})
const giftCardExchangeRate = computed(() => {
if (showPayPalCurrencySelector.value) {
return exchangeRate.value
}
if (
showGiftCardSelector.value &&
selectedMethodCurrencyCode.value &&
selectedMethodCurrencyCode.value !== 'USD'
) {
return selectedMethodExchangeRate.value
}
return exchangeRate.value
})
function formatAmountForDisplay(
usdAmount: number,
currencyCode: string | null | undefined,
rate: number | null | undefined,
): string {
if (!currencyCode || currencyCode === 'USD' || !rate) {
return formatMoney(usdAmount)
}
const localAmount = usdAmount * rate
try {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currencyCode,
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(localAmount)
} catch {
return `${currencyCode} ${localAmount.toFixed(2)}`
}
}
const useFixedDenominations = computed(() => {
const hasFixed = !!selectedMethodDetails.value?.interval?.fixed?.values
debug('Use fixed denominations:', hasFixed, selectedMethodDetails.value?.interval)
return hasFixed
const interval = selectedMethodDetails.value?.interval
if (!interval) return false
if (interval.fixed?.values?.length) {
debug('Use fixed denominations: true (has fixed values)')
return true
}
// treat min=max as single fixed value
if (interval.standard) {
const { min, max } = interval.standard
const isSingleValue = min === max
debug('Use fixed denominations:', isSingleValue, '(min=max:', min, '=', max, ')')
return isSingleValue
}
return false
})
const useDenominationSuggestions = computed(() => {
if (!useFixedDenominations.value) return false
const interval = selectedMethodDetails.value?.interval
if (!interval?.fixed?.values) return false
return interval.fixed.values.length > 10
})
const denominationSuggestions = computed(() => {
const allDenominations = denominationOptions.value
if (allDenominations.length === 0) return []
const input = denominationSearchInput.value
// When no search input, use the user's balance as the target
const exchangeRate = selectedMethodExchangeRate.value
const targetInUsd =
input && input > 0 ? (exchangeRate ? input / exchangeRate : input) : roundedMaxAmount.value
const rangeSize = targetInUsd * 0.2
let lowerBound = targetInUsd - rangeSize / 2
let upperBound = targetInUsd + rangeSize / 2
const minAvailable = allDenominations[0]
const maxAvailable = allDenominations[allDenominations.length - 1]
// shift range when hitting boundaries to maintain ~20% total range
if (upperBound > maxAvailable) {
const overflow = upperBound - maxAvailable
upperBound = maxAvailable
lowerBound = Math.max(minAvailable, lowerBound - overflow)
} else if (lowerBound < minAvailable) {
const underflow = minAvailable - lowerBound
lowerBound = minAvailable
upperBound = Math.min(maxAvailable, upperBound + underflow)
}
return allDenominations
.filter((amt) => amt >= lowerBound && amt <= upperBound)
.sort((a, b) => a - b)
})
const maxDisplayedSuggestions = 10
const displayedSuggestions = computed(() => {
const all = denominationSuggestions.value
if (all.length <= maxDisplayedSuggestions) return all
const input = denominationSearchInput.value
const exchangeRate = selectedMethodExchangeRate.value
// Use balance as target when no search input
const targetInUsd =
input && input > 0 ? (exchangeRate ? input / exchangeRate : input) : roundedMaxAmount.value
// select values closest to target, then sort ascending for display
const closest = [...all]
.sort((a, b) => Math.abs(a - targetInUsd) - Math.abs(b - targetInUsd))
.slice(0, maxDisplayedSuggestions)
return closest.sort((a, b) => a - b)
})
const noSuggestionsMessage = computed(() => {
if (!denominationSearchInput.value || denominationSearchInput.value <= 0) {
return null
}
if (denominationSuggestions.value.length === 0) {
const maxDenom = fixedDenominationMax.value
if (maxDenom) {
const maxInLocal = formatAmountForDisplay(
maxDenom,
selectedMethodCurrencyCode.value,
selectedMethodExchangeRate.value,
)
return `No denominations near this amount. The highest available is ${maxInLocal}.`
}
return 'No denominations near this amount'
}
return null
})
const hasSelectedDenomination = computed(() => {
return (
formData.value.amount !== undefined &&
formData.value.amount > 0 &&
denominationOptions.value.includes(formData.value.amount)
)
})
const denominationOptions = computed(() => {
const fixedValues = selectedMethodDetails.value?.interval?.fixed?.values
if (!fixedValues) return []
const interval = selectedMethodDetails.value?.interval
if (!interval) return []
const filtered = fixedValues
.filter((amount) => amount <= roundedMaxAmount.value)
.sort((a, b) => a - b)
let values: number[] = []
if (interval.fixed?.values) {
values = [...interval.fixed.values]
} else if (interval.standard && interval.standard.min === interval.standard.max) {
// min=max case: treat as single fixed value
values = [interval.standard.min]
}
if (values.length === 0) return []
const filtered = values.filter((amount) => amount <= roundedMaxAmount.value).sort((a, b) => a - b)
debug(
'Denomination options (filtered by max):',
filtered,
'from',
fixedValues,
values,
'max:',
roundedMaxAmount.value,
)
@@ -426,6 +773,20 @@ const effectiveMaxAmount = computed(() => {
return roundedMaxAmount.value
})
const fixedDenominationMin = computed(() => {
if (!useFixedDenominations.value) return null
const options = denominationOptions.value
if (options.length === 0) return null
return options[0]
})
const fixedDenominationMax = computed(() => {
if (!useFixedDenominations.value) return null
const options = denominationOptions.value
if (options.length === 0) return null
return options[options.length - 1]
})
const selectedDenomination = computed({
get: () => formData.value.amount,
set: (value) => {
@@ -542,6 +903,8 @@ onMounted(async () => {
id: m.id,
name: m.name,
interval: m.interval,
currencyCode: m.currency_code,
exchangeRate: m.exchange_rate,
},
}))
@@ -564,6 +927,8 @@ watch(
selectedGiftCardId.value = null
calculatedFee.value = 0
exchangeRate.value = null
denominationSearchInput.value = undefined
hasTouchedSuggestions.value = false
// Clear currency when switching away from PayPal International
if (newMethod !== 'paypal' && withdrawData.value.providerData.type === 'tremendous') {
@@ -573,6 +938,31 @@ watch(
},
)
watch(selectedGiftCardId, (newId, oldId) => {
if (oldId && newId !== oldId) {
// Reset state when gift card changes
hasTouchedSuggestions.value = false
formData.value.amount = undefined
// denominationSearchInput will be prefilled by the watch below
denominationSearchInput.value = undefined
}
})
// Prefill denomination search with balance in local currency when suggestions mode is enabled
watch(
[useDenominationSuggestions, selectedMethodExchangeRate],
([showSuggestions, exchangeRate]) => {
if (showSuggestions && denominationSearchInput.value === undefined) {
const balanceInLocal = exchangeRate
? roundedMaxAmount.value * exchangeRate
: roundedMaxAmount.value
denominationSearchInput.value = Math.floor(balanceInLocal * 100) / 100
hasTouchedSuggestions.value = true
}
},
{ immediate: true },
)
async function switchToDirectPaypal() {
withdrawData.value.selection.country = {
id: 'US',
@@ -649,5 +1039,33 @@ const messages = defineMessages({
defaultMessage:
'You selected USD for PayPal International. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> for better fees (≈2% instead of ≈6%).',
},
enterDenominationPlaceholder: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.enter-denomination-placeholder',
defaultMessage: 'Enter amount',
},
enterAmountHint: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.enter-amount-hint',
defaultMessage: 'Find gift cards near this value.',
},
balanceWorthHint: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.balance-worth-hint',
defaultMessage: 'Your balance of {usdBalance} is currently worth {localBalance}.',
},
searchAmountLabel: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.search-amount-label',
defaultMessage: 'Search amount',
},
availableDenominationsLabel: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.available-denominations-label',
defaultMessage: 'Available denominations',
},
selectDenominationHint: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.select-denomination-hint',
defaultMessage: 'Select a denomination:',
},
selectDenominationRequired: {
id: 'dashboard.creator-withdraw-modal.tremendous-details.select-denomination-required',
defaultMessage: 'Please select a denomination to continue',
},
})
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div
class="flex flex-col gap-4 rounded-2xl border-[1px] border-solid border-blue bg-highlight-blue p-4"
>
<div class="flex flex-row justify-between">
<div class="flex flex-col text-contrast">
<span class="text-xl font-semibold">Batch scan in progress</span>
<span>{{ progress?.complete }} of {{ progress?.total }} projects completed</span>
</div>
<ButtonStyled circular color="blue" type="outlined">
<button class="!px-4" @click="emit('cancel-scan')">Cancel scan</button>
</ButtonStyled>
</div>
<div class="w-full rounded-full bg-highlight-blue">
<div
class="h-3 rounded-[inherit] bg-blue"
:style="`width: ${((progress?.complete ?? 0) / (progress?.total ?? 1)) * 100}%`"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ButtonStyled } from '@modrinth/ui'
import { defineProps } from 'vue'
export interface BatchScanProgress {
total: number
complete: number
}
defineProps<{
progress?: BatchScanProgress
}>()
const emit = defineEmits<{
(e: 'cancel-scan'): void
}>()
</script>

View File

@@ -0,0 +1,168 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
export type UnsafeFile = {
file: Labrinth.TechReview.Internal.FileReport & { version_id: string }
projectName: string
projectId: string
userId: string
username: string
}
const props = defineProps<{
unsafeFiles: UnsafeFile[]
}>()
const modalRef = useTemplateRef<InstanceType<typeof NewModal>>('modalRef')
const versionDataCache = ref<
Map<
string,
{
files: Map<string, string>
loading: boolean
error?: string
}
>
>(new Map())
async function fetchVersionHashes(versionIds: string[]) {
const uniqueIds = [...new Set(versionIds)]
for (const versionId of uniqueIds) {
if (versionDataCache.value.has(versionId)) continue
versionDataCache.value.set(versionId, { files: new Map(), loading: true })
try {
// TODO: switch to api-client once truman's vers stuff is merged
const version = (await useBaseFetch(`version/${versionId}`)) as {
files: Array<{
filename: string
file_name?: string
hashes: { sha512: string; sha1: string }
}>
}
const filesMap = new Map<string, string>()
for (const file of version.files) {
const name = file.file_name ?? file.filename
filesMap.set(name, file.hashes.sha512)
}
versionDataCache.value.set(versionId, { files: filesMap, loading: false })
} catch (error) {
console.error(`Failed to fetch version ${versionId}:`, error)
versionDataCache.value.set(versionId, {
files: new Map(),
loading: false,
error: 'Failed',
})
}
}
}
function getFileHash(versionId: string, fileName: string): string | undefined {
return versionDataCache.value.get(versionId)?.files.get(fileName)
}
function isHashLoading(versionId: string): boolean {
return versionDataCache.value.get(versionId)?.loading ?? false
}
function show() {
const versionIds = props.unsafeFiles.map((f) => f.file.version_id)
fetchVersionHashes(versionIds)
modalRef.value?.show()
}
function hide() {
modalRef.value?.hide()
}
async function copy(text: string) {
await navigator.clipboard.writeText(text)
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modalRef"
header="Malicious file(s) summary"
:close-on-click-outside="false"
:close-on-esc="false"
:closable="false"
>
<div class="markdown-body inset-0">
<div v-if="unsafeFiles.length > 0" class="mb-4 flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="text-tertiary text-sm font-medium">Project:</span>
<CopyCode :text="unsafeFiles[0].projectName" />
<CopyCode :text="unsafeFiles[0].projectId" />
</div>
<div class="flex items-center gap-2">
<span class="text-tertiary text-sm font-medium">User:</span>
<CopyCode :text="unsafeFiles[0].username" />
<CopyCode :text="unsafeFiles[0].userId" />
</div>
</div>
<table v-if="unsafeFiles.length > 0" class="w-full text-sm">
<thead>
<tr class="text-tertiary text-left text-xs font-medium">
<th class="pb-2">Hash</th>
<th class="pb-2">Version ID</th>
<th class="pb-2">File Name</th>
<th class="pb-2">CDN Link</th>
</tr>
</thead>
<tbody>
<tr v-for="item in unsafeFiles" :key="item.file.file_id">
<td class="py-1 pr-2">
<LoaderCircleIcon
v-if="isHashLoading(item.file.version_id)"
class="size-4 animate-spin text-secondary"
/>
<ButtonStyled
v-else-if="getFileHash(item.file.version_id, item.file.file_name)"
size="small"
type="standard"
>
<button @click="copy(getFileHash(item.file.version_id, item.file.file_name)!)">
<ClipboardCopyIcon class="size-4" />
Copy
</button>
</ButtonStyled>
<span v-else class="text-tertiary italic">N/A</span>
</td>
<td class="py-1 pr-2">
<CopyCode :text="item.file.version_id" />
</td>
<td class="py-1 pr-2">
<CopyCode :text="item.file.file_name" />
</td>
<td class="py-1">
<ButtonStyled size="small" type="standard">
<button @click="copy(item.file.download_url)">
<ClipboardCopyIcon class="size-4" />
Copy
</button>
</ButtonStyled>
</td>
</tr>
</tbody>
</table>
<p v-else class="text-sm italic text-secondary">No files currently marked as malicious.</p>
<div class="flex justify-end">
<ButtonStyled>
<button @click="hide">
<XIcon class="size-4" />
Close
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>

View File

@@ -1,182 +0,0 @@
<template>
<div class="universal-card">
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar :src="report.project.icon_url" size="3rem" class="flex-shrink-0" />
<div class="min-w-0 flex-1">
<h3 class="truncate text-lg font-semibold">{{ report.project.title }}</h3>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
Score: {{ report.priority_score }}
</span>
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold"
:class="{
'text-brand': report.status === 'approved',
'text-red': report.status === 'rejected',
'text-secondary': report.status === 'pending',
}"
>
{{ report.status.charAt(0).toUpperCase() + report.status.slice(1) }}
</span>
<span class="max-w-[200px] truncate font-mono text-xs sm:max-w-none">
{{
report.version.files.find((file) => file.primary)?.filename ||
'Unknown primary file'
}}
</span>
</div>
</div>
</div>
</div>
<div
class="mt-2 flex flex-col items-stretch gap-2 sm:mt-0 sm:flex-row sm:items-center sm:gap-2"
>
<span class="hidden whitespace-nowrap text-sm text-secondary sm:block">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</span>
<div class="flex flex-col gap-2 sm:flex-row">
<div class="flex gap-2">
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Accept
</button>
</ButtonStyled>
<ButtonStyled class="flex-1 sm:flex-none">
<button
v-tooltip="!isPending ? 'This report has already been dealt with.' : undefined"
:disabled="!isPending"
class="w-full sm:w-auto"
>
Reject
</button>
</ButtonStyled>
</div>
<div class="flex justify-center gap-2 sm:justify-start">
<ButtonStyled circular>
<nuxt-link :to="versionUrl">
<EyeIcon />
</nuxt-link>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
<div class="text-sm text-secondary sm:hidden">
{{ formatRelativeTime(dayjs(report.detected_at).toDate()) }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
ClipboardCopyIcon,
EllipsisVerticalIcon,
EyeIcon,
LinkIcon,
OrganizationIcon,
} from '@modrinth/assets'
import type { ExtendedDelphiReport } from '@modrinth/moderation'
import {
Avatar,
ButtonStyled,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from '@modrinth/ui'
import dayjs from 'dayjs'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
report: ExtendedDelphiReport
}>()
const formatRelativeTime = useRelativeTime()
const isPending = computed(() => props.report.status === 'pending')
const quickActions: OverflowMenuOption[] = [
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const reviewUrl = `${base}/moderation/tech-reviews?q=${props.report.version.id}`
navigator.clipboard.writeText(reviewUrl).then(() => {
addNotification({
type: 'success',
title: 'Tech review link copied',
text: 'The link to this tech review has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.report.version.id).then(() => {
addNotification({
type: 'success',
title: 'Version ID copied',
text: 'The ID of this version has been copied to your clipboard.',
})
})
},
},
]
const versionUrl = computed(() => {
return `/${props.report.project.project_type}/${props.report.project.slug}/version/${props.report.version.id}`
})
</script>
<style lang="scss" scoped></style>

View File

@@ -1,143 +1,127 @@
<template>
<div
class="universal-card flex min-h-[6rem] flex-col justify-between gap-3 rounded-lg p-4 sm:h-24 sm:flex-row sm:items-center sm:gap-0"
>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex-shrink-0 rounded-lg">
<Avatar size="48px" :src="queueEntry.project.icon_url" />
</div>
<div class="flex min-w-0 flex-1 flex-col">
<h3 class="truncate text-lg font-semibold">
{{ queueEntry.project.name }}
</h3>
<nuxt-link
v-if="queueEntry.owner"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/user/${queueEntry.owner.user.username}`"
>
<Avatar
:src="queueEntry.owner.user.avatar_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
</nuxt-link>
<nuxt-link
v-else-if="queueEntry.org"
target="_blank"
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
:to="`/organization/${queueEntry.org.slug}`"
>
<Avatar
:src="queueEntry.org.icon_url"
circle
size="16px"
class="inline-block flex-shrink-0"
/>
<span class="truncate">{{ queueEntry.org.name }}</span>
</nuxt-link>
</div>
</div>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
<BoxIcon
v-if="queueEntry.project.project_type === 'mod'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="queueEntry.project.project_type === 'resourcepack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<BracesIcon
v-else-if="queueEntry.project.project_type === 'datapack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="queueEntry.project.project_type === 'modpack'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="queueEntry.project.project_type === 'shader'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<PlugIcon
v-else-if="queueEntry.project.project_type === 'plugin'"
class="size-4 flex-shrink-0"
aria-hidden="true"
/>
<span class="hidden sm:inline">{{
props.queueEntry.project.project_types.map(formatProjectType).join(', ')
}}</span>
<span class="sm:hidden">{{
props.queueEntry.project.project_types.map(formatProjectType).join(', ')
}}</span>
</span>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<div class="flex flex-row gap-2 text-sm">
Requesting
<Badge
v-if="props.queueEntry.project.requested_status"
:type="props.queueEntry.project.requested_status"
class="status"
/>
<div class="shadow-card rounded-2xl border border-surface-5 bg-surface-3 p-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Avatar
:src="queueEntry.project.icon_url"
size="4rem"
class="rounded-2xl border border-surface-5 bg-surface-4 !shadow-none"
/>
<div class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="`/project/${queueEntry.project.slug}`"
target="_blank"
class="text-lg font-semibold text-contrast hover:underline"
>
{{ queueEntry.project.name }}
</NuxtLink>
<div
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<component
:is="getProjectTypeIcon(queueEntry.project.project_types[0] as any)"
aria-hidden="true"
class="h-4 w-4"
/>
<span class="text-sm font-medium text-secondary">
{{
queueEntry.project.project_types.map((t) => formatProjectType(t, true)).join(', ')
}}
</span>
</div>
<div
v-if="queueEntry.project.requested_status"
class="flex items-center gap-2 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<span class="text-sm text-secondary">Requesting</span>
<Badge :type="queueEntry.project.requested_status" class="status" />
</div>
</div>
<div v-if="queueEntry.owner" class="flex items-center gap-1">
<Avatar
:src="queueEntry.owner.user.avatar_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/user/${queueEntry.owner.user.username}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ queueEntry.owner.user.username }}
</NuxtLink>
</div>
<div v-else-if="queueEntry.org" class="flex items-center gap-1">
<Avatar
:src="queueEntry.org.icon_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/organization/${queueEntry.org.slug}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ queueEntry.org.name }}
</NuxtLink>
</div>
</div>
</div>
<span class="hidden text-sm sm:inline">&#x2022;</span>
<div class="flex items-center gap-3">
<span
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
class="truncate text-sm"
class="text-base text-secondary"
:class="{
'text-red': daysInQueue > 4,
'text-orange': daysInQueue > 2,
'text-orange': daysInQueue > 2 && daysInQueue <= 4,
}"
>
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
<span class="sm:hidden">{{
getSubmittedTime(queueEntry).replace('Submitted ', '')
}}</span>
{{ formattedDate }}
</span>
</div>
<div class="flex items-center justify-end gap-2 sm:justify-start">
<ButtonStyled circular>
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
<EyeIcon class="size-4" />
</NuxtLink>
</ButtonStyled>
<ButtonStyled circular color="orange" @click="openProjectForReview">
<button>
<ScaleIcon class="size-4" />
</button>
</ButtonStyled>
<div class="flex items-center gap-2">
<ButtonStyled circular color="orange">
<button @click="openProjectForReview">
<ScaleIcon class="size-5" />
</button>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, EllipsisVerticalIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
import {
BoxIcon,
BracesIcon,
EyeIcon,
GlassesIcon,
PackageOpenIcon,
PaintbrushIcon,
PlugIcon,
ScaleIcon,
} from '@modrinth/assets'
import { Avatar, Badge, ButtonStyled, useRelativeTime } from '@modrinth/ui'
Avatar,
Badge,
ButtonStyled,
getProjectTypeIcon,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
@@ -145,6 +129,7 @@ import { computed } from 'vue'
import type { ModerationProject } from '~/helpers/moderation'
import { useModerationStore } from '~/store/moderation.ts'
const { addNotification } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const moderationStore = useModerationStore()
@@ -170,6 +155,49 @@ const daysInQueue = computed(() => {
return getDaysQueued(queuedDate.value.toDate())
})
const formattedDate = computed(() => {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
try {
return formatRelativeTime(dayjs(date).toISOString())
} catch {
return 'Unknown'
}
})
const quickActions: OverflowMenuOption[] = [
{
id: 'copy-link',
action: () => {
const base = window.location.origin
const projectUrl = `${base}/project/${props.queueEntry.project.slug}`
navigator.clipboard.writeText(projectUrl).then(() => {
addNotification({
type: 'success',
title: 'Project link copied',
text: 'The link to this project has been copied to your clipboard.',
})
})
},
},
{
id: 'copy-id',
action: () => {
navigator.clipboard.writeText(props.queueEntry.project.id).then(() => {
addNotification({
type: 'success',
title: 'Project ID copied',
text: 'The ID of this project has been copied to your clipboard.',
})
})
},
},
]
function openProjectForReview() {
moderationStore.setSingleProject(props.queueEntry.project.id)
navigateTo({
@@ -183,18 +211,4 @@ function openProjectForReview() {
},
})
}
function getSubmittedTime(): string {
const date =
props.queueEntry.project.queued ||
props.queueEntry.project.created ||
props.queueEntry.project.updated
if (!date) return 'Unknown'
try {
return `Submitted ${formatRelativeTime(dayjs(date).toISOString())}`
} catch {
return 'Unknown'
}
}
</script>

View File

@@ -1,176 +1,287 @@
<template>
<div class="universal-card">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
Reported for
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
{{ formattedReportType }}
<div class="overflow-hidden rounded-2xl">
<div class="bg-bg-raised p-4">
<div
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
>
<span class="text-md flex flex-col gap-2 sm:flex-row sm:items-center">
<span class="flex items-center gap-2">
<span class="text-secondary">Reported for</span>
<span class="font-semibold text-contrast">
{{ formattedReportType }}
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden text-secondary sm:inline">By</span>
<span class="text-secondary sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
target="_blank"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<span class="flex items-center gap-2">
<span class="hidden sm:inline">By</span>
<span class="sm:hidden">Reporter:</span>
<nuxt-link
:to="`/user/${report.reporter_user.username}`"
class="inline-flex flex-row items-center gap-1 transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.reporter_user.avatar_url"
circle
size="1.75rem"
class="flex-shrink-0"
/>
<span class="truncate">{{ report.reporter_user.username }}</span>
</nuxt-link>
</span>
</span>
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="text-md whitespace-nowrap text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled v-if="visibleQuickReplies.length > 0" circular>
<OverflowMenu :options="visibleQuickReplies">
<span class="hidden sm:inline">Quick Reply</span>
<span class="sr-only sm:hidden">Quick Reply</span>
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<hr class="my-4 rounded-xl border-solid text-divider" />
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center">
<div class="flex min-w-0 flex-1 items-center gap-3">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="3rem"
class="flex-shrink-0"
/>
<div class="min-w-0 flex-1">
<span class="block truncate text-lg font-semibold">{{ reportItemTitle }}</span>
<div class="flex flex-col gap-2 text-sm text-secondary sm:flex-row sm:items-center">
<nuxt-link
v-if="report.target && report.item_type != 'user'"
:to="`/${report.target.type}/${report.target.slug}`"
class="inline-flex flex-row items-center gap-1 truncate transition-colors duration-100 ease-in-out hover:text-brand"
>
<Avatar
:src="report.target?.avatar_url"
:circle="report.target.type === 'user'"
size="1rem"
class="flex-shrink-0"
/>
<span class="truncate">
<OrganizationIcon
v-if="report.target.type === 'organization'"
class="align-middle"
/>
{{ report.target.name || 'Unknown User' }}
</span>
</nuxt-link>
<div class="flex flex-wrap items-center gap-2">
<span
class="whitespace-nowrap rounded-full bg-button-bg p-0.5 px-2 text-xs font-semibold text-secondary"
>
{{ formattedItemType }}
</span>
<span
v-if="report.item_type === 'version' && report.version"
class="max-w-[200px] truncate font-mono text-xs sm:max-w-none"
>
{{
report.version.files.find((file) => file.primary)?.filename || 'Unknown Version'
}}
</span>
</div>
</div>
</div>
</div>
<div class="flex justify-end sm:justify-start">
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
<span class="whitespace-nowrap text-sm text-secondary">{{
formatRelativeTime(report.created)
}}</span>
<ButtonStyled circular>
<nuxt-link :to="reportItemUrl">
<EyeIcon />
</nuxt-link>
<OverflowMenu :options="quickActions">
<template #default>
<EllipsisVerticalIcon class="size-4" />
</template>
<template #copy-id>
<ClipboardCopyIcon />
<span class="hidden sm:inline">Copy ID</span>
</template>
<template #copy-link>
<LinkIcon />
<span class="hidden sm:inline">Copy link</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</div>
</div>
<CollapsibleRegion ref="collapsibleRegion" class="my-4">
<ReportThread
v-if="report.thread"
ref="reportThread"
class="mb-16 sm:mb-0"
:thread="report.thread"
:report="report"
:reporter="report.reporter_user"
@update-thread="updateThread"
/>
<div class="my-4 h-px bg-surface-5" />
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<Avatar
:src="reportItemAvatarUrl"
:circle="report.item_type === 'user'"
size="4rem"
:class="[
'flex-shrink-0 border border-surface-5 bg-surface-4 !shadow-none',
report.item_type !== 'user' && 'rounded-2xl',
]"
/>
<div v-if="report.item_type === 'user'" class="flex flex-col gap-1.5">
<NuxtLink
:to="`/user/${report.user?.username}`"
target="_blank"
class="text-base font-semibold text-contrast hover:underline"
>
{{ report.user?.username || 'Unknown User' }}
</NuxtLink>
<span
v-if="report.user?.created"
v-tooltip="formatExactDate(report.user.created)"
class="cursor-help text-sm text-secondary"
>
Joined {{ formatRelativeTime(report.user.created) }}
</span>
</div>
<div v-else class="flex flex-col gap-1.5">
<div class="flex items-center gap-2">
<NuxtLink
:to="reportItemUrl"
target="_blank"
class="text-base font-semibold text-contrast hover:underline"
>
{{ reportItemTitle }}
</NuxtLink>
<div
v-if="report.project?.project_type"
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
>
<component
:is="getProjectTypeIcon(report.project.project_type as any)"
aria-hidden="true"
class="h-4 w-4"
/>
<span class="text-sm font-medium text-secondary">
{{ formatProjectType(report.project.project_type, true) }}
</span>
</div>
<span
v-if="report.item_type === 'version' && report.version"
class="text-sm text-secondary"
>
{{ report.version.files.find((f) => f.primary)?.filename || 'Unknown Version' }}
</span>
</div>
<div v-if="report.target" class="flex items-center gap-1">
<Avatar
:src="report.target.avatar_url"
size="1.5rem"
circle
class="border border-surface-5 bg-surface-4 !shadow-none"
/>
<NuxtLink
:to="`/${report.target.type}/${report.target.slug}`"
target="_blank"
class="text-sm font-medium text-secondary hover:underline"
>
{{ report.target.name }}
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
<CollapsibleRegion
v-model:collapsed="isThreadCollapsed"
:expand-text="expandText"
collapse-text="Collapse thread"
>
<div class="bg-surface-2 p-4 pt-2">
<ThreadView
v-if="report.thread"
ref="reportThread"
:thread="report.thread"
:quick-replies="reportQuickReplies"
:quick-reply-context="report"
:closed="reportClosed"
@update-thread="updateThread"
>
<template #closedActions>
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2">
<button class="w-full gap-2 sm:w-auto" @click="reopenReport()">
<CheckCircleIcon class="size-4" />
Reopen Thread
</button>
</ButtonStyled>
</template>
<template #additionalActions="{ hasReply }">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="hasReply" color="red">
<button class="w-full gap-2 sm:w-auto" @click="closeReport(true)">
<CheckCircleIcon class="size-4" />
Reply and close
</button>
</ButtonStyled>
<ButtonStyled v-else color="red">
<button class="w-full gap-2 sm:w-auto" @click="closeReport()">
<CheckCircleIcon class="size-4" />
Close report
</button>
</ButtonStyled>
</template>
</template>
</ThreadView>
</div>
</CollapsibleRegion>
</div>
</template>
<script setup lang="ts">
import {
CheckCircleIcon,
ClipboardCopyIcon,
EllipsisVerticalIcon,
EyeIcon,
LinkIcon,
OrganizationIcon,
} from '@modrinth/assets'
import {
type ExtendedReport,
reportQuickReplies,
type ReportQuickReply,
} from '@modrinth/moderation'
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
import type { OverflowMenuOption } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
CollapsibleRegion,
getProjectTypeIcon,
injectNotificationManager,
OverflowMenu,
type OverflowMenuOption,
useRelativeTime,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
import ReportThread from '../thread/ReportThread.vue'
import { isStaff } from '~/helpers/users.js'
import ThreadView from '../thread/ThreadView.vue'
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const props = defineProps<{
report: ExtendedReport
}>()
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null)
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null)
const reportThread = ref<{
setReplyContent: (content: string) => void
sendReply: (privateMessage?: boolean) => Promise<void>
} | null>(null)
const isThreadCollapsed = ref(true)
const didCloseReport = ref(false)
const reportClosed = computed(() => {
return didCloseReport.value || props.report.closed
})
const remainingMessageCount = computed(() => {
if (!props.report.thread?.messages) return 0
return Math.max(0, props.report.thread.messages.length - 1)
})
const expandText = computed(() => {
if (remainingMessageCount.value === 0) return 'Expand'
if (remainingMessageCount.value === 1) return 'Show 1 more message'
return `Show ${remainingMessageCount.value} more messages`
})
async function closeReport(reply = false) {
if (reply && reportThread.value) {
await reportThread.value.sendReply()
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: true,
},
})
updateThread(props.report.thread)
didCloseReport.value = true
} catch (err: any) {
addNotification({
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: false,
},
})
updateThread(props.report.thread)
didCloseReport.value = false
} catch (err: any) {
addNotification({
title: 'Error reopening report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const formatRelativeTime = useRelativeTime()
function formatExactDate(date: string): string {
return dayjs(date).format('MMMM D, YYYY [at] h:mm A')
}
function updateThread(newThread: any) {
if (props.report.thread) {
Object.assign(props.report.thread, newThread)
@@ -206,34 +317,6 @@ const quickActions: OverflowMenuOption[] = [
},
]
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
return reportQuickReplies
.filter((reply) => {
if (reply.shouldShow === undefined) return true
if (typeof reply.shouldShow === 'function') {
return reply.shouldShow(props.report)
}
return reply.shouldShow
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply),
}) as OverflowMenuOption,
)
})
async function handleQuickReply(reply: ReportQuickReply) {
const message =
typeof reply.message === 'function' ? await reply.message(props.report) : reply.message
collapsibleRegion.value?.setCollapsed(false)
await nextTick()
reportThread.value?.setReplyContent(message)
}
const reportItemAvatarUrl = computed(() => {
switch (props.report.item_type) {
case 'project':
@@ -265,11 +348,6 @@ const reportItemUrl = computed(() => {
}
})
const formattedItemType = computed(() => {
const itemType = props.report.item_type
return itemType.charAt(0).toUpperCase() + itemType.slice(1)
})
const formattedReportType = computed(() => {
const reportType = props.report.report_type
@@ -278,5 +356,3 @@ const formattedReportType = computed(() => {
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
})
</script>
<style lang="scss" scoped></style>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
<template>
<div class="mx-2 p-4 !py-8 sm:mx-8 sm:p-32">
<div class="mx-auto max-w-[1280px] p-4 !py-8 sm:py-32">
<div class="my-8 flex items-center justify-between">
<h2 class="m-0 mx-auto text-3xl font-extrabold sm:text-4xl">
{{ formatMessage(messages.latestNews) }}

View File

@@ -1,126 +0,0 @@
<template>
<NewModal ref="modal" header="Creating backup" @show="focusInput">
<div class="flex flex-col gap-2 md:w-[600px]">
<label for="backup-name-input">
<span class="text-lg font-semibold text-contrast"> Name </span>
</label>
<input
id="backup-name-input"
ref="input"
v-model="backupName"
type="text"
class="bg-bg-input w-full rounded-lg p-4"
:placeholder="`Backup #${newBackupAmount}`"
maxlength="48"
/>
<div v-if="nameExists && !isCreating" class="flex items-center gap-1">
<IssuesIcon class="hidden text-orange sm:block" />
<span class="text-sm text-orange">
You already have a backup named '<span class="font-semibold">{{ trimmedName }}</span
>'
</span>
</div>
<div v-if="isRateLimited" class="mt-2 text-sm text-red">
You're creating backups too fast. Please wait a moment before trying again.
</div>
</div>
<div class="mt-2 flex justify-start gap-2">
<ButtonStyled color="brand">
<button :disabled="isCreating || nameExists" @click="createBackup">
<PlusIcon />
Create backup
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hideModal">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { IssuesIcon, PlusIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ModrinthServersFetchError, type ServerBackup } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const input = ref<HTMLInputElement>()
const isCreating = ref(false)
const isRateLimited = ref(false)
const backupName = ref('')
const newBackupAmount = computed(() =>
props.server.backups?.data?.length === undefined ? 1 : props.server.backups?.data?.length + 1,
)
const trimmedName = computed(() => backupName.value.trim())
const nameExists = computed(() => {
if (!props.server.backups?.data) return false
return props.server.backups.data.some(
(backup: ServerBackup) => backup.name.trim().toLowerCase() === trimmedName.value.toLowerCase(),
)
})
const focusInput = () => {
nextTick(() => {
setTimeout(() => {
input.value?.focus()
}, 100)
})
}
function show() {
backupName.value = ''
isCreating.value = false
modal.value?.show()
}
const hideModal = () => {
modal.value?.hide()
}
const createBackup = async () => {
if (backupName.value.trim().length === 0) {
backupName.value = `Backup #${newBackupAmount.value}`
}
isCreating.value = true
isRateLimited.value = false
try {
await props.server.backups?.create(trimmedName.value)
hideModal()
await props.server.refresh()
} catch (error) {
if (error instanceof ModrinthServersFetchError && error?.statusCode === 429) {
isRateLimited.value = true
addNotification({
type: 'error',
title: 'Error creating backup',
text: "You're creating backups too fast.",
})
} else {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Error creating backup', text: message })
}
} finally {
isCreating.value = false
}
}
defineExpose({
show,
hide: hideModal,
})
</script>

View File

@@ -1,42 +0,0 @@
<template>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to delete this backup?"
proceed-label="Delete backup"
:confirmation-text="currentBackup?.name ?? 'null'"
has-to-type
@proceed="emit('delete', currentBackup)"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
</template>
<script setup lang="ts">
import { ConfirmModal } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { ref } from 'vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
const emit = defineEmits<{
(e: 'delete', backup: Backup | undefined): void
}>()
const modal = ref<InstanceType<typeof ConfirmModal>>()
const currentBackup = ref<Backup | undefined>(undefined)
function show(backup: Backup) {
currentBackup.value = backup
modal.value?.show()
}
defineExpose({
show,
})
</script>

View File

@@ -1,277 +0,0 @@
<script setup lang="ts">
import {
BotIcon,
DownloadIcon,
EditIcon,
FolderArchiveIcon,
HistoryIcon,
LockIcon,
LockOpenIcon,
MoreVerticalIcon,
RotateCounterClockwiseIcon,
SpinnerIcon,
TrashIcon,
XIcon,
} from '@modrinth/assets'
import { ButtonStyled, commonMessages, OverflowMenu, ProgressBar } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { defineMessages, useVIntl } from '@vintl/vintl'
import dayjs from 'dayjs'
import { computed } from 'vue'
const flags = useFeatureFlags()
const { formatMessage } = useVIntl()
const emit = defineEmits<{
(e: 'download' | 'rename' | 'restore' | 'lock' | 'retry'): void
(e: 'delete', skipConfirmation?: boolean): void
}>()
const props = withDefaults(
defineProps<{
backup: Backup
preview?: boolean
kyrosUrl?: string
jwt?: string
}>(),
{
preview: false,
kyrosUrl: undefined,
jwt: undefined,
},
)
const backupQueued = computed(
() =>
props.backup.task?.create?.progress === 0 ||
(props.backup.ongoing && !props.backup.task?.create),
)
const automated = computed(() => props.backup.automated)
const failedToCreate = computed(() => props.backup.interrupted)
const inactiveStates = ['failed', 'cancelled']
const creating = computed(() => {
const task = props.backup.task?.create
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
if (props.backup.ongoing) {
return {
progress: 0,
state: 'ongoing',
}
}
return undefined
})
const restoring = computed(() => {
const task = props.backup.task?.restore
if (task && task.progress < 1 && !inactiveStates.includes(task.state)) {
return task
}
return undefined
})
const failedToRestore = computed(() => props.backup.task?.restore?.state === 'failed')
const messages = defineMessages({
locked: {
id: 'servers.backups.item.locked',
defaultMessage: 'Locked',
},
lock: {
id: 'servers.backups.item.lock',
defaultMessage: 'Lock',
},
unlock: {
id: 'servers.backups.item.unlock',
defaultMessage: 'Unlock',
},
restore: {
id: 'servers.backups.item.restore',
defaultMessage: 'Restore',
},
rename: {
id: 'servers.backups.item.rename',
defaultMessage: 'Rename',
},
queuedForBackup: {
id: 'servers.backups.item.queued-for-backup',
defaultMessage: 'Queued for backup',
},
creatingBackup: {
id: 'servers.backups.item.creating-backup',
defaultMessage: 'Creating backup...',
},
restoringBackup: {
id: 'servers.backups.item.restoring-backup',
defaultMessage: 'Restoring from backup...',
},
failedToCreateBackup: {
id: 'servers.backups.item.failed-to-create-backup',
defaultMessage: 'Failed to create backup',
},
failedToRestoreBackup: {
id: 'servers.backups.item.failed-to-restore-backup',
defaultMessage: 'Failed to restore from backup',
},
automated: {
id: 'servers.backups.item.automated',
defaultMessage: 'Automated',
},
retry: {
id: 'servers.backups.item.retry',
defaultMessage: 'Retry',
},
})
</script>
<template>
<div
:class="
preview
? 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr]'
: 'grid-cols-[min-content_1fr_1fr] sm:grid-cols-[min-content_3fr_2fr_1fr] md:grid-cols-[auto_3fr_2fr_1fr_2fr]'
"
class="grid items-center gap-4 rounded-2xl bg-bg-raised px-4 py-3"
>
<div
class="flex h-12 w-12 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg"
>
<SpinnerIcon
v-if="creating"
class="h-6 w-6 animate-spin"
:class="{ 'text-orange': backupQueued, 'text-green': !backupQueued }"
/>
<FolderArchiveIcon v-else class="h-6 w-6" />
</div>
<div class="col-span-2 flex flex-col gap-1 sm:col-span-1">
<span class="font-bold text-contrast">
{{ backup.name }}
</span>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span v-if="backup.locked" class="flex items-center gap-1 text-sm text-secondary">
<LockIcon /> {{ formatMessage(messages.locked) }}
</span>
<span v-if="automated && backup.locked"></span>
<span v-if="automated" class="flex items-center gap-1 text-secondary">
<BotIcon /> {{ formatMessage(messages.automated) }}
</span>
<span v-if="(failedToCreate || failedToRestore) && (automated || backup.locked)"></span>
<span
v-if="failedToCreate || failedToRestore"
class="flex items-center gap-1 text-sm text-red"
>
<XIcon />
{{
formatMessage(
failedToCreate ? messages.failedToCreateBackup : messages.failedToRestoreBackup,
)
}}
</span>
</div>
</div>
<div v-if="creating" class="col-span-2 flex flex-col gap-3">
<span v-if="backupQueued" class="text-orange">
{{ formatMessage(messages.queuedForBackup) }}
</span>
<span v-else class="text-green"> {{ formatMessage(messages.creatingBackup) }} </span>
<ProgressBar
:progress="creating.progress"
:color="backupQueued ? 'orange' : 'green'"
:waiting="creating.progress === 0"
class="max-w-full"
/>
</div>
<div v-else-if="restoring" class="col-span-2 flex flex-col gap-3 text-purple">
{{ formatMessage(messages.restoringBackup) }}
<ProgressBar
:progress="restoring.progress"
color="purple"
:waiting="restoring.progress === 0"
class="max-w-full"
/>
</div>
<template v-else>
<div class="col-span-2">
{{ dayjs(backup.created_at).format('MMMM D, YYYY [at] h:mm A') }}
</div>
<div v-if="false">{{ 245 }} MiB</div>
</template>
<div
v-if="!preview"
class="col-span-full flex justify-normal gap-2 md:col-span-1 md:justify-end"
>
<template v-if="failedToCreate">
<ButtonStyled>
<button @click="() => emit('retry')">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="() => emit('delete', true)">
<TrashIcon />
Remove
</button>
</ButtonStyled>
</template>
<ButtonStyled v-else-if="creating">
<button @click="() => emit('delete')">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled>
<a
:class="{
disabled: !kyrosUrl || !jwt,
}"
:href="`https://${kyrosUrl}/modrinth/v0/backups/${backup.id}/download?auth=${jwt}`"
@click="() => emit('download')"
>
<DownloadIcon />
{{ formatMessage(commonMessages.downloadButton) }}
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="[
{ id: 'rename', action: () => emit('rename') },
{
id: 'restore',
action: () => emit('restore'),
disabled: !!restoring,
},
{ id: 'lock', action: () => emit('lock') },
{ divider: true },
{
id: 'delete',
color: 'red',
action: () => emit('delete'),
disabled: !!restoring,
},
]"
>
<MoreVerticalIcon />
<template #rename> <EditIcon /> {{ formatMessage(messages.rename) }} </template>
<template #restore> <HistoryIcon /> {{ formatMessage(messages.restore) }} </template>
<template v-if="backup.locked" #lock>
<LockOpenIcon /> {{ formatMessage(messages.unlock) }}
</template>
<template v-else #lock> <LockIcon /> {{ formatMessage(messages.lock) }} </template>
<template #delete>
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</div>
<pre
v-if="!preview && flags.advancedDebugInfo"
class="col-span-full m-0 rounded-xl bg-button-bg text-xs"
>{{ backup }}</pre
>
</div>
</template>

View File

@@ -1,63 +0,0 @@
<template>
<ConfirmModal
ref="modal"
danger
title="Are you sure you want to restore from this backup?"
proceed-label="Restore from backup"
description="This will **overwrite all files on your server** and replace them with the files from the backup."
@proceed="restoreBackup"
>
<BackupItem
v-if="currentBackup"
:backup="currentBackup"
preview
class="border-px border-solid border-button-border"
/>
</ConfirmModal>
</template>
<script setup lang="ts">
import type { NewModal } from '@modrinth/ui'
import { ConfirmModal, injectNotificationManager } from '@modrinth/ui'
import type { Backup } from '@modrinth/utils'
import { ref } from 'vue'
import BackupItem from '~/components/ui/servers/BackupItem.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const currentBackup = ref<Backup | null>(null)
function show(backup: Backup) {
currentBackup.value = backup
modal.value?.show()
}
const restoreBackup = async () => {
if (!currentBackup.value) {
addNotification({
type: 'error',
title: 'Failed to restore backup',
text: 'Current backup is null',
})
return
}
try {
await props.server.backups?.restore(currentBackup.value.id)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({ type: 'error', title: 'Failed to restore backup', text: message })
}
}
defineExpose({
show,
})
</script>

View File

@@ -208,7 +208,7 @@
the mod.
<NuxtLink
class="mt-2 flex items-center gap-1"
:to="`/servers/manage/${props.serverId}/options/loader`"
:to="`/hosting/manage/${props.serverId}/options/loader`"
target="_blank"
>
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version

View File

@@ -68,26 +68,24 @@
import {
DownloadIcon,
EditIcon,
FileArchiveIcon,
FileIcon,
FolderOpenIcon,
MoreHorizontalIcon,
PackageOpenIcon,
RightArrowIcon,
TrashIcon,
} from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, ref, shallowRef } from 'vue'
import {
ButtonStyled,
CODE_EXTENSIONS,
getFileExtensionIcon,
IMAGE_EXTENSIONS,
TEXT_EXTENSIONS,
} from '@modrinth/ui'
import { computed, h, ref, shallowRef } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { useRoute, useRouter } from 'vue-router'
import {
UiServersIconsCodeFileIcon,
UiServersIconsCogFolderIcon,
UiServersIconsEarthIcon,
UiServersIconsImageFileIcon,
UiServersIconsTextFileIcon,
} from '#components'
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
import PaletteIcon from '~/assets/icons/palette.svg?component'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
@@ -116,36 +114,6 @@ const emit = defineEmits<{
const isDragOver = ref(false)
const isDragging = ref(false)
const codeExtensions = Object.freeze([
'json',
'json5',
'jsonc',
'java',
'kt',
'kts',
'sh',
'bat',
'ps1',
'yml',
'yaml',
'toml',
'js',
'ts',
'py',
'rb',
'php',
'html',
'css',
'cpp',
'c',
'h',
'rs',
'go',
])
const textExtensions = Object.freeze(['txt', 'md', 'log', 'cfg', 'conf', 'properties', 'ini', 'sk'])
const imageExtensions = Object.freeze(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'])
const supportedArchiveExtensions = Object.freeze(['zip'])
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
const route = shallowRef(useRoute())
@@ -199,12 +167,7 @@ const iconComponent = computed(() => {
return FolderOpenIcon
}
const ext = fileExtension.value
if (codeExtensions.includes(ext)) return UiServersIconsCodeFileIcon
if (textExtensions.includes(ext)) return UiServersIconsTextFileIcon
if (imageExtensions.includes(ext)) return UiServersIconsImageFileIcon
if (supportedArchiveExtensions.includes(ext)) return FileArchiveIcon
return FileIcon
return getFileExtensionIcon(fileExtension.value)
})
const subText = computed(() => {
@@ -245,9 +208,9 @@ const isEditableFile = computed(() => {
const ext = fileExtension.value
return (
!props.name.includes('.') ||
textExtensions.includes(ext) ||
codeExtensions.includes(ext) ||
imageExtensions.includes(ext)
TEXT_EXTENSIONS.includes(ext) ||
CODE_EXTENSIONS.includes(ext) ||
IMAGE_EXTENSIONS.includes(ext)
)
}
return false
@@ -294,32 +257,7 @@ const selectItem = () => {
}
const getDragIcon = async () => {
let iconToUse
if (props.type === 'directory') {
if (props.name === 'config') {
iconToUse = UiServersIconsCogFolderIcon
} else if (props.name === 'world') {
iconToUse = UiServersIconsEarthIcon
} else if (props.name === 'resourcepacks') {
iconToUse = PaletteIcon
} else {
iconToUse = FolderOpenIcon
}
} else {
const ext = fileExtension.value
if (codeExtensions.includes(ext)) {
iconToUse = UiServersIconsCodeFileIcon
} else if (textExtensions.includes(ext)) {
iconToUse = UiServersIconsTextFileIcon
} else if (imageExtensions.includes(ext)) {
iconToUse = UiServersIconsImageFileIcon
} else {
iconToUse = FileIcon
}
}
return await renderToString(h(iconToUse))
return await renderToString(h(iconComponent.value))
}
const handleDragStart = async (event: DragEvent) => {

View File

@@ -135,6 +135,6 @@ const emit = defineEmits<{
const goHome = () => {
emit('cancel')
router.push({ path: '/servers/manage/' + route.params.id + '/files' })
router.push({ path: '/hosting/manage/' + route.params.id + '/files' })
}
</script>

View File

@@ -52,7 +52,7 @@
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server.serverId}/backups`" />
<BackupWarning :backup-link="`/hosting/manage/${props.server.serverId}/backups`" />
<div class="flex justify-start gap-2">
<ButtonStyled color="brand">
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">

File diff suppressed because one or more lines are too long

View File

@@ -126,7 +126,7 @@ import { useStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import LoadingIcon from './icons/LoadingIcon.vue'
import PanelSpinner from './PanelSpinner.vue'
@@ -214,7 +214,7 @@ const menuOptions = computed(() => [
id: 'allServers',
label: 'All servers',
icon: ServerIcon,
action: () => router.push('/servers/manage'),
action: () => router.push('/hosting/manage'),
},
{
id: 'details',

View File

@@ -69,7 +69,7 @@
</div>
</div>
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
<BackupWarning :backup-link="`/hosting/manage/${props.server?.serverId}/backups`" />
</div>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
@@ -133,7 +133,7 @@ import { ModrinthServersFetchError } from '@modrinth/utils'
import { onMounted, onUnmounted } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
const { addNotification } = injectNotificationManager()

View File

@@ -159,7 +159,7 @@
<BackupWarning
v-if="!initialSetup"
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
:backup-link="`/hosting/manage/${props.server?.serverId}/backups`"
/>
</div>
@@ -218,7 +218,7 @@ import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch } from 'ofetch'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import LoaderIcon from './icons/LoaderIcon.vue'
import LoadingIcon from './icons/LoadingIcon.vue'

View File

@@ -56,7 +56,7 @@
Switch modpack
</button>
</template>
<nuxt-link v-else :to="`/modpacks?sid=${props.server.serverId}`">
<nuxt-link v-else :to="`/discover/modpacks?sid=${props.server.serverId}`">
<TransferIcon class="size-4" />
Switch modpack
</nuxt-link>
@@ -99,7 +99,7 @@
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
:class="{ disabled: backupInProgress }"
class="!w-full sm:!w-auto"
:to="`/modpacks?sid=${props.server.serverId}`"
:to="`/discover/modpacks?sid=${props.server.serverId}`"
>
<CompassIcon class="size-4" /> Find a modpack
</nuxt-link>
@@ -163,7 +163,7 @@ import { ButtonStyled, NewProjectCard } from '@modrinth/ui'
import type { Loaders } from '@modrinth/utils'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
import LoaderSelector from './LoaderSelector.vue'
import PlatformChangeModpackVersionModal from './PlatformChangeModpackVersionModal.vue'

View File

@@ -39,7 +39,7 @@ import { RightArrowIcon } from '@modrinth/assets'
import type { RouteLocationNormalized } from 'vue-router'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
import type { BackupInProgressReason } from '~/pages/hosting/manage/[id].vue'
const emit = defineEmits(['reinstall'])

View File

@@ -53,7 +53,7 @@
</div>
</div>
<nuxt-link
:to="loading ? undefined : `/servers/manage/${serverId}/files`"
:to="loading ? undefined : `/hosting/manage/${serverId}/files`"
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
>

View File

@@ -1,232 +1,22 @@
<template>
<svg
v-if="loader === 'Fabric'"
xmlns="http://www.w3.org/2000/svg"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z" />
<path
fill="none"
stroke="currentColor"
stroke-width="23"
d="m820 761-85.6-87.6c-4.6-4.7-10.4-9.6-25.9 1-19.9 13.6-8.4 21.9-5.2 25.4 8.2 9 84.1 89 97.2 104 2.5 2.8-20.3-22.5-6.5-39.7 5.4-7 18-12 26-3 6.5 7.3 10.7 18-3.4 29.7-24.7 20.4-102 82.4-127 103-12.5 10.3-28.5 2.3-35.8-6-7.5-8.9-30.6-34.6-51.3-58.2-5.5-6.3-4.1-19.6 2.3-25 35-30.3 91.9-73.8 111.9-90.8"
transform="matrix(.08671 0 0 .0867 -49.8 -56)"
/>
</svg>
<svg
v-else-if="loader === 'Quilt'"
xmlns:xlink="http://www.w3.org/1999/xlink"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="2"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<defs>
<path
id="quilt"
fill="none"
stroke="currentColor"
stroke-width="65.6"
d="M442.5 233.9c0-6.4-5.2-11.6-11.6-11.6h-197c-6.4 0-11.6 5.2-11.6 11.6v197c0 6.4 5.2 11.6 11.6 11.6h197c6.4 0 11.6-5.2 11.6-11.7v-197Z"
></path>
</defs>
<path fill="none" d="M0 0h24v24H0z"></path>
<use
xlink:href="#quilt"
stroke-width="65.6"
transform="matrix(.03053 0 0 .03046 -3.2 -3.2)"
></use>
<use xlink:href="#quilt" stroke-width="65.6" transform="matrix(.03053 0 0 .03046 -3.2 7)"></use>
<use
xlink:href="#quilt"
stroke-width="65.6"
transform="matrix(.03053 0 0 .03046 6.9 -3.2)"
></use>
<path
fill="none"
stroke="currentColor"
stroke-width="70.4"
d="M442.5 234.8c0-7-5.6-12.5-12.5-12.5H234.7c-6.8 0-12.4 5.6-12.4 12.5V430c0 6.9 5.6 12.5 12.4 12.5H430c6.9 0 12.5-5.6 12.5-12.5V234.8Z"
transform="rotate(45 3.5 24) scale(.02843 .02835)"
></path>
</svg>
<svg
v-else-if="loader === 'Forge'"
ml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z"></path>
<path
fill="none"
stroke="currentColor"
stroke-width="2"
d="M2 7.5h8v-2h12v2s-7 3.4-7 6 3.1 3.1 3.1 3.1l.9 3.9H5l1-4.1s3.8.1 4-2.9c.2-2.7-6.5-.7-8-6Z"
></path>
</svg>
<svg
v-else-if="loader === 'NeoForge'"
enable-background="new 0 0 24 24"
version="1.1"
viewBox="0 0 24 24"
xml:space="preserve"
xmlns="http://www.w3.org/2000/svg"
>
<g
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
>
<path d="m12 19.2v2m0-2v2" />
<path
d="m8.4 1.3c0.5 1.5 0.7 3 0.1 4.6-0.2 0.5-0.9 1.5-1.6 1.5m8.7-6.1c-0.5 1.5-0.7 3-0.1 4.6 0.2 0.6 0.9 1.5 1.6 1.5"
/>
<path d="m3.6 15.8h-1.7m18.5 0h1.7" />
<path d="m3.2 12.1h-1.7m19.3 0h1.8" />
<path d="m8.1 12.7v1.6m7.8-1.6v1.6" />
<path d="m10.8 18h1.2m0 1.2-1.2-1.2m2.4 0h-1.2m0 1.2 1.2-1.2" />
<path
d="m4 9.7c-0.5 1.2-0.8 2.4-0.8 3.7 0 3.1 2.9 6.3 5.3 8.2 0.9 0.7 2.2 1.1 3.4 1.1m0.1-17.8c-1.1 0-2.1 0.2-3.2 0.7m11.2 4.1c0.5 1.2 0.8 2.4 0.8 3.7 0 3.1-2.9 6.3-5.3 8.2-0.9 0.7-2.2 1.1-3.4 1.1m-0.1-17.8c1.1 0 2.1 0.2 3.2 0.7"
/>
<path
d="m4 9.7c-0.2-1.8-0.3-3.7 0.5-5.5s2.2-2.6 3.9-3m11.6 8.5c0.2-1.9 0.3-3.7-0.5-5.5s-2.2-2.6-3.9-3"
/>
<path d="m12 21.2-2.4 0.4m2.4-0.4 2.4 0.4" />
</g>
</svg>
<svg
v-else-if="loader === 'Paper'"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<path fill="none" d="M0 0h24v24H0z" />
<path fill="none" stroke="currentColor" stroke-width="2" d="m12 18 6 2 3-17L2 14l6 2" />
<path stroke="currentColor" stroke-width="2" d="m9 21-1-5 4 2-3 3Z" />
<path fill="currentColor" d="m12 18-4-2 10-9-6 11Z" />
</svg>
<svg
v-else-if="loader === 'Spigot'"
viewBox="0 0 332 284"
style="
fill-rule: evenodd;
clip-rule: evenodd;
stroke-linejoin: round;
fill: none;
fill-rule: nonzero;
stroke-width: 24px;
"
stroke="currentColor"
>
<path
d="M147.5,27l27,-15l27.5,15l66.5,0l0,33.5l-73,-0.912l0,45.5l26,-0.088l0,31.5l-12.5,0l0,15.5l16,21.5l35,0l0,-21.5l35.5,0l0,21.5l24.5,0l0,55.5l-24.5,0l0,17l-35.5,0l0,-27l-35,0l-55.5,14.5l-67.5,-14.5l-15,14.5l18,12.5l-3,24.5l-41.5,1.5l-48.5,-19.5l6,-19l24.5,-4.5l16,-41l79,-36l-7,-15.5l0,-31.5l23.5,0l0,-45.5l-73.5,0l0,-32.5l67,0Z"
/>
</svg>
<svg
v-else-if="loader === 'Bukkit'"
viewBox="0 0 292 319"
style="fill-rule: evenodd; clip-rule: evenodd; stroke-linecap: round; stroke-linejoin: round"
stroke="currentColor"
>
<g transform="matrix(1,0,0,1,0,-5)">
<path
d="M12,109.5L12,155L34.5,224L57.5,224L57.5,271L81,294L160,294L160,172L259.087,172L265,155L265,109.5M12,109.5L12,64L34.5,64L34.5,41L81,17L195.5,17L241,41L241,64L265,64L265,109.5M12,109.5L81,109.5L81,132L195.5,132L195.5,109.5L265,109.5M264.087,204L264.087,244M207.5,272L207.5,312M250,272L250,312L280,312L280,272L250,272ZM192.5,204L192.5,244L222.5,244L222.5,204L192.5,204Z"
style="fill: none; fill-rule: nonzero; stroke-width: 24px"
/>
</g>
</svg>
<svg
v-else-if="loader === 'Purpur'"
xml:space="preserve"
fill-rule="evenodd"
stroke-linecap="round"
stroke-linejoin="round"
stroke-miterlimit="1.5"
clip-rule="evenodd"
viewBox="0 0 24 24"
>
<defs>
<path
id="purpur"
fill="none"
stroke="currentColor"
stroke-width="1.68"
d="m264 41.95 8-4v8l-8 4v-8Z"
></path>
</defs>
<path fill="none" d="M0 0h24v24H0z"></path>
<path
fill="none"
stroke="currentColor"
stroke-width="1.77"
d="m264 29.95-8 4 8 4.42 8-4.42-8-4Z"
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
></path>
<path
fill="none"
stroke="currentColor"
stroke-width="1.77"
d="m272 38.37-8 4.42-8-4.42"
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
></path>
<path
fill="none"
stroke="currentColor"
stroke-width="1.77"
d="m260 31.95 8 4.21V45"
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
></path>
<path
fill="none"
stroke="currentColor"
stroke-width="1.77"
d="M260 45v-8.84l8-4.21"
transform="matrix(1.125 0 0 1.1372 -285 -31.69)"
></path>
<use
xlink:href="#purpur"
stroke-width="1.68"
transform="matrix(1.125 0 0 1.2569 -285 -40.78)"
></use>
<use
xlink:href="#purpur"
stroke-width="1.68"
transform="matrix(-1.125 0 0 1.2569 309 -40.78)"
></use>
</svg>
<svg v-else-if="loader === 'Vanilla'" viewBox="0 0 20 20" fill="currentColor">
<path
fill-rule="evenodd"
d="M9.504 1.132a1 1 0 01.992 0l1.75 1a1 1 0 11-.992 1.736L10 3.152l-1.254.716a1 1 0 11-.992-1.736l1.75-1zM5.618 4.504a1 1 0 01-.372 1.364L5.016 6l.23.132a1 1 0 11-.992 1.736L4 7.723V8a1 1 0 01-2 0V6a.996.996 0 01.52-.878l1.734-.99a1 1 0 011.364.372zm8.764 0a1 1 0 011.364-.372l1.733.99A1.002 1.002 0 0118 6v2a1 1 0 11-2 0v-.277l-.254.145a1 1 0 11-.992-1.736l.23-.132-.23-.132a1 1 0 01-.372-1.364zm-7 4a1 1 0 011.364-.372L10 8.848l1.254-.716a1 1 0 11.992 1.736L11 10.58V12a1 1 0 11-2 0v-1.42l-1.246-.712a1 1 0 01-.372-1.364zM3 11a1 1 0 011 1v1.42l1.246.712a1 1 0 11-.992 1.736l-1.75-1A1 1 0 012 14v-2a1 1 0 011-1zm14 0a1 1 0 011 1v2a1 1 0 01-.504.868l-1.75 1a1 1 0 11-.992-1.736L16 13.42V12a1 1 0 011-1zm-9.618 5.504a1 1 0 011.364-.372l.254.145V16a1 1 0 112 0v.277l.254-.145a1 1 0 11.992 1.736l-1.735.992a.995.995 0 01-1.022 0l-1.735-.992a1 1 0 01-.372-1.364z"
clip-rule="evenodd"
></path>
</svg>
<div v-if="loaderData?.icon" v-html="loaderData.icon" />
<LoaderIcon v-else />
</template>
<script setup lang="ts">
import { LoaderIcon } from '@modrinth/assets'
import type { Loaders } from '@modrinth/utils'
import { computed } from 'vue'
defineProps<{
loader: Loaders
import { useGeneratedState } from '~/composables/generated'
const props = defineProps<{
loader: string
}>()
const tags = useGeneratedState()
// Find the loader by name (case-insensitive comparison)
const loaderData = computed(() =>
tags.value.loaders.find((l) => l.name.toLowerCase() === props.loader.toLowerCase()),
)
</script>

View File

@@ -141,7 +141,7 @@ const billingMonths = computed(() => {
:ram="ram"
:storage="storage"
:cpus="cpus"
:bursting-link="'/servers#cpu-burst'"
:bursting-link="'/hosting#cpu-burst'"
@click-bursting-link="() => emit('scroll-to-faq')"
/>
</div>

View File

@@ -217,6 +217,14 @@
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'send-to-review-reply',
action: () => {
sendReply('processing', true)
},
hoverFilled: true,
disabled: project.status === 'processing',
},
]
: [
{
@@ -228,6 +236,14 @@
hoverFilled: true,
disabled: project.status === 'withheld',
},
{
id: 'send-to-review',
action: () => {
setStatus('processing')
},
hoverFilled: true,
disabled: project.status === 'processing',
},
]
"
>
@@ -240,6 +256,14 @@
<EyeOffIcon aria-hidden="true" />
Withhold
</template>
<template #send-to-review-reply>
<ScaleIcon aria-hidden="true" />
Send to review with reply
</template>
<template #send-to-review>
<ScaleIcon aria-hidden="true" />
Send to review
</template>
</OverflowMenu>
</div>
</template>

View File

@@ -1,286 +0,0 @@
<template>
<div>
<div v-if="flags.developerMode" class="mb-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div
v-if="sortedMessages.length > 0"
class="bg-raised flex flex-col space-y-4 rounded-xl p-3 sm:p-4"
>
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:report="report"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<template v-if="reportClosed">
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<ButtonStyled v-if="isStaff(auth.user)" color="green" class="mt-2 w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="reopenReport()"
>
<CheckCircleIcon class="size-4" />
Reopen Thread
</button>
</ButtonStyled>
</template>
<template v-else>
<div class="mt-4">
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<ButtonStyled v-if="sortedMessages.length > 0" color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<ReplyIcon class="size-4" />
Reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply()"
>
<SendIcon class="size-4" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)" class="w-full sm:w-auto">
<button
:disabled="!replyBody"
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="sendReply(true)"
>
<ScaleIcon class="size-4" />
<span class="hidden sm:inline">Add private note</span>
<span class="sm:hidden">Private note</span>
</button>
</ButtonStyled>
</div>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<template v-if="isStaff(auth.user)">
<ButtonStyled v-if="replyBody" color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport(true)"
>
<CheckCircleIcon class="size-4" />
<span class="hidden sm:inline">Close with reply</span>
<span class="sm:hidden">Close & reply</span>
</button>
</ButtonStyled>
<ButtonStyled v-else color="red" class="w-full sm:w-auto">
<button
class="flex w-full items-center justify-center gap-2 sm:w-auto"
@click="closeReport()"
>
<CheckCircleIcon class="size-4" />
Close report
</button>
</ButtonStyled>
</template>
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, ReplyIcon, ScaleIcon, SendIcon } from '@modrinth/assets'
import { ButtonStyled, CopyCode, injectNotificationManager, MarkdownEditor } from '@modrinth/ui'
import type { Report, Thread, ThreadMessage as TypeThreadMessage, User } from '@modrinth/utils'
import dayjs from 'dayjs'
import { useImageUpload } from '~/composables/image-upload.ts'
import { isStaff } from '~/helpers/users.js'
import ThreadMessage from './ThreadMessage.vue'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
thread: Thread
reporter: User
report: Report
}>()
defineExpose({
setReplyContent,
})
const auth = await useAuth()
const emit = defineEmits<{
updateThread: [thread: Thread]
}>()
const flags = useFeatureFlags()
const members = computed(() => {
const membersMap: Record<string, User> = {
[props.reporter.id]: props.reporter,
}
for (const member of props.thread.members) {
membersMap[member.id] = member
}
return membersMap
})
const replyBody = ref('')
function setReplyContent(content: string) {
replyBody.value = content
}
const sortedMessages = computed(() => {
const messages: TypeThreadMessage[] = [
{
id: null,
author_id: props.reporter.id,
body: {
type: 'text',
body: props.report.body || 'Report opened.',
private: false,
replying_to: null,
associated_images: [],
},
created: props.report.created,
hide_identity: false,
},
]
if (props.thread) {
messages.push(
...[...props.thread.messages].sort(
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
),
)
}
return messages
})
async function updateThreadLocal() {
const threadId = props.report.thread_id
if (threadId) {
try {
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread
emit('updateThread', thread)
} catch (error) {
console.error('Failed to update thread:', error)
}
}
}
const imageIDs = ref<string[]>([])
async function onUploadImage(file: File) {
const response = await useImageUpload(file, { context: 'thread_message' })
imageIDs.value.push(response.id)
imageIDs.value = imageIDs.value.slice(-10)
return response.url
}
async function sendReply(privateMessage = false) {
try {
const body: any = {
body: {
type: 'text',
body: replyBody.value,
private: privateMessage,
},
}
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
}
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body,
})
replyBody.value = ''
await updateThreadLocal()
} catch (err: any) {
addNotification({
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
const didCloseReport = ref(false)
const reportClosed = computed(() => {
return didCloseReport.value || (props.report && props.report.closed)
})
async function closeReport(reply = false) {
if (reply) {
await sendReply()
}
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: true,
},
})
await updateThreadLocal()
didCloseReport.value = true
} catch (err: any) {
addNotification({
title: 'Error closing report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
async function reopenReport() {
try {
await useBaseFetch(`report/${props.report.id}`, {
method: 'PATCH',
body: {
closed: false,
},
})
await updateThreadLocal()
} catch (err: any) {
addNotification({
title: 'Error reopening report',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
</script>

View File

@@ -4,7 +4,7 @@
:class="{
'has-body': message.body.type === 'text' && !forceCompact,
'no-actions': noLinks,
private: message.body.private,
private: isPrivateMessage,
}"
>
<template v-if="members[message.author_id]">
@@ -23,7 +23,7 @@
</AutoLink>
<span :class="`message__author role-${members[message.author_id].role}`">
<LockIcon
v-if="message.body.private"
v-if="isPrivateMessage"
v-tooltip="'Only visible to moderators'"
class="private-icon"
/>
@@ -40,13 +40,30 @@
v-tooltip="'Reporter'"
class="reporter-icon"
/>
<span
v-if="message.preview"
class="border-blue/60 rounded-full border border-solid bg-highlight-blue px-2 py-0.5 text-xs font-semibold text-blue"
>
Preview
</span>
</span>
</template>
<template v-else>
<div class="message__icon backed-svg circle moderation-color" :class="{ raised: raised }">
<div
class="message__icon backed-svg circle moderation-color"
:class="{
raised: raised,
'system-message-icon': ['tech_review_entered', 'tech_review_exit_file_deleted'].includes(
message.body.type,
),
}"
>
<ScaleIcon />
</div>
<span class="message__author moderation-color">
<span
v-if="!['tech_review_entered', 'tech_review_exit_file_deleted'].includes(message.body.type)"
class="message__author moderation-color"
>
Moderator
<ScaleIcon v-tooltip="'Moderator'" />
</span>
@@ -69,6 +86,17 @@
</template>
<span v-else-if="message.body.type === 'thread_closure'">closed the thread.</span>
<span v-else-if="message.body.type === 'thread_reopen'">reopened the thread.</span>
<span v-else-if="message.body.type === 'tech_review'">
completed technical review and marked project as
<Badge :type="message.body.verdict" />.
</span>
<span v-else-if="message.body.type === 'tech_review_entered'">
The project has entered the technical review queue.
</span>
<span v-else-if="message.body.type === 'tech_review_exit_file_deleted'">
The project has left the technical review queue as all files pending review were deleted by
the user.
</span>
</div>
<span class="message__date">
<span v-tooltip="$dayjs(message.created).format('MMMM D, YYYY [at] h:mm A')">
@@ -160,6 +188,15 @@ const formattedMessage = computed(() => {
const formatRelativeTime = useRelativeTime()
const timeSincePosted = ref(formatRelativeTime(props.message.created))
const isPrivateMessage = computed(() => {
return (
props.message.body.private ||
['tech_review', 'tech_review_entered', 'tech_review_exit_file_deleted'].includes(
props.message.body.type,
)
)
})
async function deleteMessage() {
await useBaseFetch(`message/${props.message.id}`, {
method: 'DELETE',
@@ -333,4 +370,8 @@ a:active + .message__author a,
.private {
color: var(--color-icon);
}
.system-message-icon {
--size: 2rem !important;
}
</style>

View File

@@ -0,0 +1,227 @@
<template>
<div>
<div v-if="flags.developerMode" class="mt-4 font-bold text-heading">
Thread ID:
<CopyCode :text="thread.id" />
</div>
<div v-if="sortedMessages.length > 0" class="flex flex-col space-y-4 rounded-xl p-3 sm:p-4">
<ThreadMessage
v-for="message in sortedMessages"
:key="'message-' + message.id"
:thread="thread"
:message="message"
:members="members"
:auth="auth"
raised
@update-thread="() => updateThreadLocal()"
/>
</div>
<div v-else class="flex flex-col items-center justify-center space-y-3 py-12">
<MessageIcon class="size-12 text-secondary" />
<p class="text-lg text-secondary">No messages yet</p>
</div>
<template v-if="closed">
<p class="text-secondary">This thread is closed and new messages cannot be sent to it.</p>
<slot name="closedActions" />
</template>
<template v-else>
<div>
<MarkdownEditor
v-model="replyBody"
:placeholder="sortedMessages.length > 0 ? 'Reply to thread...' : 'Send a message...'"
:on-image-upload="onUploadImage"
/>
</div>
<div
class="mt-4 flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center sm:gap-2"
>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<ButtonStyled v-if="sortedMessages.length > 0" color="brand">
<button :disabled="!replyBody" class="w-full gap-2 sm:w-auto" @click="sendReply()">
<ReplyIcon class="size-4" />
Reply
</button>
</ButtonStyled>
<ButtonStyled v-else color="brand">
<button :disabled="!replyBody" class="w-full gap-2 sm:w-auto" @click="sendReply()">
<SendIcon class="size-4" />
Send
</button>
</ButtonStyled>
<ButtonStyled v-if="isStaff(auth.user)">
<button :disabled="!replyBody" class="w-full sm:w-auto" @click="sendReply(true)">
Add note
</button>
</ButtonStyled>
<ButtonStyled v-if="visibleQuickReplies.length > 0">
<OverflowMenu :options="visibleQuickReplies">
Quick Reply
<ChevronDownIcon />
</OverflowMenu>
</ButtonStyled>
</div>
<div class="flex flex-col items-stretch gap-2 sm:flex-row sm:items-center">
<slot name="additionalActions" :has-reply="!!replyBody" />
</div>
</div>
</template>
</div>
</template>
<script setup lang="ts" generic="T">
import { MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
import type { QuickReply } from '@modrinth/moderation'
import {
ButtonStyled,
CopyCode,
injectNotificationManager,
MarkdownEditor,
OverflowMenu,
type OverflowMenuOption,
} from '@modrinth/ui'
import type { Thread, User } from '@modrinth/utils'
import dayjs from 'dayjs'
import { useImageUpload } from '~/composables/image-upload.ts'
import { isStaff } from '~/helpers/users.js'
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
import ThreadMessage from './ThreadMessage.vue'
const { addNotification } = injectNotificationManager()
const visibleQuickReplies = computed<OverflowMenuOption[]>(() => {
const replies = props.quickReplies
const context = props.quickReplyContext
if (!replies || !context) return []
return replies
.filter((reply) => {
if (reply.shouldShow === undefined) return true
return reply.shouldShow(context)
})
.map(
(reply) =>
({
id: reply.label,
action: () => handleQuickReply(reply, context),
}) as OverflowMenuOption,
)
})
const props = defineProps<{
thread: Thread
quickReplies?: ReadonlyArray<QuickReply<T>>
quickReplyContext?: T
closed?: boolean
}>()
async function handleQuickReply(reply: QuickReply<T>, context: T) {
const message = typeof reply.message === 'function' ? await reply.message(context) : reply.message
await nextTick()
setReplyContent(message)
}
defineExpose({
setReplyContent,
getReplyContent,
sendReply,
})
const auth = await useAuth()
const emit = defineEmits<{
updateThread: [thread: Thread]
}>()
const flags = useFeatureFlags()
const members = computed(() => {
const membersMap: Record<string, User> = {}
for (const member of props.thread.members) {
membersMap[member.id] = member
}
return membersMap
})
const replyBody = ref('')
function setReplyContent(content: string) {
replyBody.value = content
}
function getReplyContent(): string {
return replyBody.value
}
const sortedMessages = computed(() => {
if (!props.thread) return []
return [...props.thread.messages].sort(
(a, b) => dayjs(a.created).toDate().getTime() - dayjs(b.created).toDate().getTime(),
)
})
async function updateThreadLocal() {
const threadId = props.thread.id
if (threadId) {
try {
const thread = (await useBaseFetch(`thread/${threadId}`)) as Thread
emit('updateThread', thread)
} catch (error) {
console.error('Failed to update thread:', error)
}
}
}
const imageIDs = ref<string[]>([])
async function onUploadImage(file: File) {
const response = await useImageUpload(file, { context: 'thread_message' })
imageIDs.value.push(response.id)
imageIDs.value = imageIDs.value.slice(-10)
return response.url
}
async function sendReply(privateMessage = false) {
try {
const body: any = {
body: {
type: 'text',
body: replyBody.value,
private: privateMessage,
},
}
if (imageIDs.value.length > 0) {
body.body = {
...body.body,
uploaded_images: imageIDs.value,
}
}
await useBaseFetch(`thread/${props.thread.id}`, {
method: 'POST',
body,
})
replyBody.value = ''
await updateThreadLocal()
} catch (err: any) {
addNotification({
title: 'Error sending message',
text: err.data ? err.data.description : err,
type: 'error',
})
}
}
</script>

View File

@@ -40,6 +40,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
newProjectGeneralSettings: false,
newProjectEnvironmentSettings: true,
hideRussiaCensorshipBanner: false,
serverDiscovery: false,
// advancedRendering: true,
// externalLinksNewTab: true,
// notUsingBlockers: false,

View File

@@ -1,10 +1,12 @@
import type { ISO3166, Labrinth } from '@modrinth/api-client'
import type { DisplayProjectType } from '@modrinth/utils'
import generatedState from '~/generated/state.json'
import type { DisplayMode } from '~/plugins/cosmetics'
export interface ProjectType {
actual: string
id: string
id: DisplayProjectType
display: string
}
@@ -25,7 +27,7 @@ export interface GeneratedState extends Labrinth.State.GeneratedState {
// Additional runtime-defined fields not from the API
projectTypes: ProjectType[]
loaderData: LoaderData
projectViewModes: string[]
projectViewModes: DisplayMode[]
approvedStatuses: string[]
rejectedStatuses: string[]
staffRoles: string[]

View File

@@ -54,12 +54,12 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
const motd = await this.getMotd()
if (motd === 'A Minecraft Server') {
await this.setMotd(
`§b${data.project?.title || data.loader + ' ' + data.mc_version} §f♦ §aModrinth Servers`,
`§b${data.project?.title || data.loader + ' ' + data.mc_version} §f♦ §aModrinth Hosting`,
)
}
data.motd = motd
} catch {
console.error('[Modrinth Servers] [General] Failed to fetch MOTD.')
console.error('[Modrinth Hosting] [General] Failed to fetch MOTD.')
data.motd = undefined
}
@@ -224,7 +224,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
}
} catch {
console.error(
'[Modrinth Servers] [General] Failed to set MOTD due to lack of server properties file.',
'[Modrinth Hosting] [General] Failed to set MOTD due to lack of server properties file.',
)
}
}

View File

@@ -1,3 +1,4 @@
import { PANEL_VERSION } from '@modrinth/api-client'
import type { V1ErrorInfo } from '@modrinth/utils'
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
import { $fetch, FetchError } from 'ofetch'
@@ -27,7 +28,7 @@ export async function useServersFetch<T>(
if (!authToken && !options.bypassAuth) {
const error = new ModrinthServersFetchError(
'[Modrinth Servers] Cannot fetch without auth',
'[Modrinth Hosting] Cannot fetch without auth',
10000,
)
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
@@ -49,7 +50,7 @@ export async function useServersFetch<T>(
const now = Date.now()
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
const error = new ModrinthServersFetchError(
'[Modrinth Servers] Circuit breaker open - too many recent failures',
'[Modrinth Hosting] Circuit breaker open - too many recent failures',
503,
)
throw new ModrinthServerError(
@@ -73,7 +74,7 @@ export async function useServersFetch<T>(
if (!base) {
const error = new ModrinthServersFetchError(
'[Modrinth Servers] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
'[Modrinth Hosting] Cannot fetch without base url. Make sure to set a PYRO_BASE_URL in environment variables',
10001,
)
throw new ModrinthServerError(
@@ -103,6 +104,7 @@ export async function useServersFetch<T>(
const headers: Record<string, string> = {
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
'X-Archon-Request': 'true',
'X-Panel-Version': String(PANEL_VERSION),
Vary: 'Accept, Origin',
}
@@ -183,12 +185,12 @@ export async function useServersFetch<T>(
console.error('Fetch error:', error)
const fetchError = new ModrinthServersFetchError(
`[Modrinth Servers] ${error.message}`,
`[Modrinth Hosting] ${error.message}`,
statusCode,
error,
)
throw new ModrinthServerError(
`[Modrinth Servers] ${message}`,
`[Modrinth Hosting] ${message}`,
statusCode,
fetchError,
module,
@@ -206,7 +208,7 @@ export async function useServersFetch<T>(
console.error('Unexpected fetch error:', error)
const fetchError = new ModrinthServersFetchError(
'[Modrinth Servers] An unexpected error occurred during the fetch operation.',
'[Modrinth Hosting] An unexpected error occurred during the fetch operation.',
undefined,
error as Error,
)

View File

@@ -52,7 +52,12 @@
<script setup>
import { SadRinthbot } from '@modrinth/assets'
import { NotificationPanel, provideModrinthClient, provideNotificationManager } from '@modrinth/ui'
import {
NotificationPanel,
provideModrinthClient,
provideNotificationManager,
providePageContext,
} from '@modrinth/ui'
import { defineMessage, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
@@ -73,6 +78,10 @@ const client = createModrinthClient(auth.value, {
rateLimitKey: config.rateLimitKey,
})
provideModrinthClient(client)
providePageContext({
hierarchicalSidebarAvailable: ref(false),
showAds: ref(false),
})
const { formatMessage } = useVIntl()

View File

@@ -6,6 +6,7 @@ import {
NuxtCircuitBreakerStorage,
type NuxtClientConfig,
NuxtModrinthClient,
PanelVersionFeature,
VerboseLoggingFeature,
} from '@modrinth/api-client'
import type { Ref } from 'vue'
@@ -31,6 +32,7 @@ export function createModrinthClient(
maxFailures: 3,
resetTimeout: 30000,
}),
new PanelVersionFeature(),
...optionalFeatures,
],
}

View File

@@ -204,7 +204,7 @@
<template #description>
{{
formatMessage(failedToBuildBannerMessages.description, {
errors: generatedStateErrors,
errors: JSON.stringify(generatedStateErrors),
url: config.public.apiBaseUrl,
})
}}
@@ -237,12 +237,12 @@
<template v-if="flags.projectTypesPrimaryNav">
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-mods' || route.path.startsWith('/mod/')"
:highlighted="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
:highlighted-style="
route.name === 'search-mods' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-mods' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/mods">
<nuxt-link to="/discover/mods">
<BoxIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.mod) }}
</nuxt-link>
@@ -250,61 +250,63 @@
<ButtonStyled
type="transparent"
:highlighted="
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
"
:highlighted-style="
route.name === 'search-resourcepacks' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-resourcepacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/resourcepacks">
<nuxt-link to="/discover/resourcepacks">
<PaintbrushIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.resourcepack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
:highlighted="
route.name === 'discover-datapacks' || route.path.startsWith('/datapack/')
"
:highlighted-style="
route.name === 'search-datapacks' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-datapacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/datapacks">
<nuxt-link to="/discover/datapacks">
<BracesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
:highlighted="route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')"
:highlighted-style="
route.name === 'search-modpacks' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-modpacks' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/modpacks">
<nuxt-link to="/discover/modpacks">
<PackageOpenIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
:highlighted="route.name === 'discover-shaders' || route.path.startsWith('/shader/')"
:highlighted-style="
route.name === 'search-shaders' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-shaders' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/shaders">
<nuxt-link to="/discover/shaders">
<GlassesIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
</nuxt-link>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
:highlighted="route.name === 'discover-plugins' || route.path.startsWith('/plugin/')"
:highlighted-style="
route.name === 'search-plugins' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'discover-plugins' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/plugins">
<nuxt-link to="/discover/plugins">
<PlugIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.plugin) }}
</nuxt-link>
@@ -320,55 +322,66 @@
:options="[
{
id: 'mods',
action: '/mods',
action: '/discover/mods',
},
{
id: 'resourcepacks',
action: '/resourcepacks',
action: '/discover/resourcepacks',
},
{
id: 'datapacks',
action: '/datapacks',
action: '/discover/datapacks',
},
{
id: 'shaders',
action: '/shaders',
action: '/discover/shaders',
},
{
id: 'modpacks',
action: '/modpacks',
action: '/discover/modpacks',
},
{
id: 'plugins',
action: '/plugins',
action: '/discover/plugins',
},
{
id: 'servers',
action: '/discover/servers',
shown: flags.serverDiscovery,
},
]"
hoverable
>
<BoxIcon
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
v-if="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
aria-hidden="true"
/>
<PaintbrushIcon
v-else-if="
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
"
aria-hidden="true"
/>
<BracesIcon
v-else-if="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
v-else-if="
route.name === 'discover-datapacks' || route.path.startsWith('/datapack/')
"
aria-hidden="true"
/>
<PackageOpenIcon
v-else-if="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
v-else-if="route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')"
aria-hidden="true"
/>
<GlassesIcon
v-else-if="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
v-else-if="route.name === 'discover-shaders' || route.path.startsWith('/shader/')"
aria-hidden="true"
/>
<PlugIcon
v-else-if="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
v-else-if="route.name === 'discover-plugins' || route.path.startsWith('/plugin/')"
aria-hidden="true"
/>
<ServerIcon
v-else-if="route.name === 'discover-servers' || route.path.startsWith('/server/')"
aria-hidden="true"
/>
<CompassIcon v-else aria-hidden="true" />
@@ -402,19 +415,23 @@
<PackageOpenIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
</template>
<template #servers>
<ServerIcon aria-hidden="true" />
{{ formatMessage(commonProjectTypeCategoryMessages.server) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
<ButtonStyled
type="transparent"
:highlighted="
route.name?.startsWith('servers') ||
(route.name?.startsWith('search-') && route.query.sid)
route.name?.startsWith('hosting') ||
(route.name?.startsWith('discover-') && !!route.query.sid)
"
:highlighted-style="
route.name === 'servers' ? 'main-nav-primary' : 'main-nav-secondary'
route.name === 'hosting' ? 'main-nav-primary' : 'main-nav-secondary'
"
>
<nuxt-link to="/servers">
<nuxt-link to="/hosting">
<ServerIcon aria-hidden="true" />
{{ formatMessage(navMenuMessages.hostAServer) }}
</nuxt-link>
@@ -447,6 +464,11 @@
color: 'orange',
link: '/moderation/',
},
{
id: 'tech-review',
color: 'orange',
link: '/moderation/technical-review',
},
{
id: 'review-reports',
color: 'orange',
@@ -494,6 +516,9 @@
<template #review-projects>
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProjects) }}
</template>
<template #tech-review>
<ShieldAlertIcon aria-hidden="true" /> {{ formatMessage(messages.techReview) }}
</template>
<template #review-reports>
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
</template>
@@ -683,7 +708,7 @@
<LibraryIcon class="icon" />
{{ formatMessage(commonMessages.collectionsLabel) }}
</NuxtLink>
<NuxtLink class="iconified-button" to="/servers/manage">
<NuxtLink class="iconified-button" to="/hosting/manage">
<ServerIcon class="icon" />
{{ formatMessage(commonMessages.serversLabel) }}
</NuxtLink>
@@ -925,6 +950,7 @@ import {
SearchIcon,
ServerIcon,
SettingsIcon,
ShieldAlertIcon,
SunIcon,
TwitterIcon,
UserIcon,
@@ -1180,11 +1206,15 @@ const messages = defineMessages({
},
reviewProjects: {
id: 'layout.action.review-projects',
defaultMessage: 'Review projects',
defaultMessage: 'Project review',
},
techReview: {
id: 'layout.action.tech-review',
defaultMessage: 'Tech review',
},
reports: {
id: 'layout.action.reports',
defaultMessage: 'Reports',
defaultMessage: 'Review reports',
},
lookupByEmail: {
id: 'layout.action.lookup-by-email',
@@ -1328,27 +1358,27 @@ const navRoutes = computed(() => [
{
id: 'mods',
label: formatMessage(getProjectTypeMessage('mod', true)),
href: '/mods',
href: '/discover/mods',
},
{
label: formatMessage(getProjectTypeMessage('plugin', true)),
href: '/plugins',
href: '/discover/plugins',
},
{
label: formatMessage(getProjectTypeMessage('datapack', true)),
href: '/datapacks',
href: '/discover/datapacks',
},
{
label: formatMessage(getProjectTypeMessage('shader', true)),
href: '/shaders',
href: '/discover/shaders',
},
{
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
href: '/resourcepacks',
href: '/discover/resourcepacks',
},
{
label: formatMessage(getProjectTypeMessage('modpack', true)),
href: '/modpacks',
href: '/discover/modpacks',
},
])
@@ -1366,7 +1396,7 @@ const userMenuOptions = computed(() => {
},
{
id: 'servers',
link: '/servers/manage',
link: '/hosting/manage',
},
{
id: 'flags',
@@ -1439,7 +1469,7 @@ const userMenuOptions = computed(() => {
})
const isDiscovering = computed(
() => route.name && route.name.startsWith('search-') && !route.query.sid,
() => route.name && route.name.startsWith('discover-') && !route.query.sid,
)
const isDiscoveringSubpage = computed(
@@ -1455,7 +1485,7 @@ const disableRandomProjects = ref(false)
const disableRandomProjectsForRoute = computed(
() =>
route.name.startsWith('servers') ||
route.name.startsWith('hosting') ||
route.name.includes('settings') ||
route.name.includes('admin'),
)
@@ -1685,11 +1715,11 @@ const footerLinks = [
),
},
{
href: '/servers',
href: '/hosting',
label: formatMessage(
defineMessage({
id: 'layout.footer.products.servers',
defaultMessage: 'Modrinth Servers',
defaultMessage: 'Modrinth Hosting',
}),
),
},

View File

@@ -365,20 +365,26 @@
"auth.welcome.title": {
"message": "Welcome"
},
"collection.button.delete-icon": {
"message": "Delete icon"
},
"collection.button.edit-icon": {
"message": "Edit icon"
},
"collection.button.remove-icon": {
"message": "Remove icon"
},
"collection.button.remove-project": {
"message": "Remove project"
},
"collection.button.replace-icon": {
"message": "Replace icon"
},
"collection.button.select-icon": {
"message": "Select icon"
},
"collection.button.unfollow-project": {
"message": "Unfollow project"
},
"collection.delete-modal.description": {
"message": "This will remove this collection forever. This action cannot be undone."
"message": "This will permanently delete this collection. This action cannot be undone."
},
"collection.delete-modal.title": {
"message": "Are you sure you want to delete this collection?"
@@ -389,33 +395,39 @@
"collection.description.following": {
"message": "Auto-generated collection of all the projects you're following."
},
"collection.editing": {
"message": "Editing collection"
},
"collection.error.not-found": {
"message": "Collection not found"
},
"collection.label.collection": {
"message": "Collection"
},
"collection.label.created-at": {
"message": "Created {ago}"
},
"collection.label.curated-by": {
"message": "Curated by"
},
"collection.label.description": {
"message": "Description"
},
"collection.label.details": {
"message": "Details"
},
"collection.label.no-projects": {
"message": "This collection has no projects!"
},
"collection.label.no-projects-auth": {
"message": "You don't have any projects.\nWould you like to <create-link>add one</create-link>?"
},
"collection.label.owner": {
"message": "Owner"
"message": "No projects in collection yet"
},
"collection.label.projects-count": {
"message": "{count, plural, one {<stat>{count}</stat> project} other {<stat>{count}</stat> projects}}"
"message": "{count, plural, =0 {No projects yet} one {<stat>{count}</stat> project} other {<stat>{count}</stat> {type}}}"
},
"collection.label.updated-at": {
"message": "Updated {ago}"
},
"collection.return-link.dashboard-collections": {
"message": "Your collections"
},
"collection.return-link.user": {
"message": "{user}'s profile"
},
"collection.title": {
"message": "{name} - Collection"
},
@@ -425,6 +437,9 @@
"common.yes": {
"message": "Yes"
},
"create-project-version.create-modal.stage.add-files.admonition": {
"message": "Supplementary files are for supporting resources like source code, not for alternative versions or variants."
},
"create.collection.cancel": {
"message": "Cancel"
},
@@ -653,9 +668,15 @@
"dashboard.creator-withdraw-modal.fee-breakdown-fee": {
"message": "Fee"
},
"dashboard.creator-withdraw-modal.fee-breakdown-gift-card-value": {
"message": "Gift card value"
},
"dashboard.creator-withdraw-modal.fee-breakdown-net-amount": {
"message": "Net amount"
},
"dashboard.creator-withdraw-modal.fee-breakdown-usd-equivalent": {
"message": "USD equivalent"
},
"dashboard.creator-withdraw-modal.kyc.business-entity": {
"message": "Business entity"
},
@@ -800,6 +821,18 @@
"dashboard.creator-withdraw-modal.tax-form-required.header": {
"message": "Tax form required"
},
"dashboard.creator-withdraw-modal.tremendous-details.available-denominations-label": {
"message": "Available denominations"
},
"dashboard.creator-withdraw-modal.tremendous-details.balance-worth-hint": {
"message": "Your balance of {usdBalance} is currently worth {localBalance}."
},
"dashboard.creator-withdraw-modal.tremendous-details.enter-amount-hint": {
"message": "Find gift cards near this value."
},
"dashboard.creator-withdraw-modal.tremendous-details.enter-denomination-placeholder": {
"message": "Enter amount"
},
"dashboard.creator-withdraw-modal.tremendous-details.payment-method": {
"message": "Payment method"
},
@@ -812,6 +845,15 @@
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
"message": "Rewards"
},
"dashboard.creator-withdraw-modal.tremendous-details.search-amount-label": {
"message": "Search amount"
},
"dashboard.creator-withdraw-modal.tremendous-details.select-denomination-hint": {
"message": "Select a denomination:"
},
"dashboard.creator-withdraw-modal.tremendous-details.select-denomination-required": {
"message": "Please select a denomination to continue"
},
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
"message": "Unverified email"
},
@@ -1239,10 +1281,13 @@
"message": "New project"
},
"layout.action.reports": {
"message": "Reports"
"message": "Review reports"
},
"layout.action.review-projects": {
"message": "Review projects"
"message": "Project review"
},
"layout.action.tech-review": {
"message": "Tech review"
},
"layout.avatar.alt": {
"message": "Your avatar"
@@ -1359,7 +1404,7 @@
"message": "Modrinth+"
},
"layout.footer.products.servers": {
"message": "Modrinth Servers"
"message": "Modrinth Hosting"
},
"layout.footer.resources": {
"message": "Resources"
@@ -1481,9 +1526,6 @@
"moderation.sort.by": {
"message": "Sort by"
},
"moderation.technical.search.placeholder": {
"message": "Search tech reviews..."
},
"muralpay.account-type.checking": {
"message": "Checking"
},
@@ -1781,6 +1823,9 @@
"profile.details.label.email": {
"message": "Email"
},
"profile.details.label.email-verified": {
"message": "Email verified"
},
"profile.details.label.has-password": {
"message": "Has password"
},
@@ -2001,7 +2046,7 @@
"message": "Review project"
},
"project.actions.servers-promo.description": {
"message": "Modrinth Servers is the easiest way to play with your friends without hassle!"
"message": "Modrinth Hosting is the easiest way to play with your friends without hassle!"
},
"project.actions.servers-promo.pricing": {
"message": "Starting at {price}<small> / month</small>"
@@ -2219,12 +2264,6 @@
"project.status.archived.message": {
"message": "{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project."
},
"project.version.all-versions": {
"message": "All versions"
},
"project.version.back-to-versions": {
"message": "Back to versions"
},
"project.versions.title": {
"message": "Versions"
},
@@ -2558,48 +2597,9 @@
"search.filter.locked.server.sync": {
"message": "Sync with server"
},
"servers.backup.create.in-progress.tooltip": {
"message": "Backup creation in progress"
},
"servers.backup.restore.in-progress.tooltip": {
"message": "Backup restore in progress"
},
"servers.backups.item.automated": {
"message": "Automated"
},
"servers.backups.item.creating-backup": {
"message": "Creating backup..."
},
"servers.backups.item.failed-to-create-backup": {
"message": "Failed to create backup"
},
"servers.backups.item.failed-to-restore-backup": {
"message": "Failed to restore from backup"
},
"servers.backups.item.lock": {
"message": "Lock"
},
"servers.backups.item.locked": {
"message": "Locked"
},
"servers.backups.item.queued-for-backup": {
"message": "Queued for backup"
},
"servers.backups.item.rename": {
"message": "Rename"
},
"servers.backups.item.restore": {
"message": "Restore"
},
"servers.backups.item.restoring-backup": {
"message": "Restoring from backup..."
},
"servers.backups.item.retry": {
"message": "Retry"
},
"servers.backups.item.unlock": {
"message": "Unlock"
},
"servers.notice.actions": {
"message": "Actions"
},

View File

@@ -0,0 +1,6 @@
export default defineNuxtRouteMiddleware((to) => {
if (to.path.startsWith('/servers')) {
const target = to.fullPath.replace('/servers', '/hosting')
return navigateTo(target, { redirectCode: 301 })
}
})

View File

@@ -0,0 +1,13 @@
export default defineNuxtRouteMiddleware((to) => {
if (
to.path.startsWith('/mods') ||
to.path.startsWith('/modpacks') ||
to.path.startsWith('/plugins') ||
to.path.startsWith('/datapacks') ||
to.path.startsWith('/resourcepacks') ||
to.path.startsWith('/shaders')
) {
const target = '/discover' + to.fullPath
return navigateTo(target, { redirectCode: 301 })
}
})

View File

@@ -32,10 +32,7 @@
:versions="versions"
:current-member="currentMember"
:is-settings="route.name.startsWith('type-id-settings')"
:route-name="route.name"
:set-processing="setProcessing"
:collapsed="collapsedChecklist"
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
:all-members="allMembers"
:update-members="updateMembers"
:auth="auth"
@@ -55,6 +52,7 @@
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
:route="route"
@@ -418,7 +416,7 @@
</AutomaticAccordion>
<ServersPromo
v-if="flags.showProjectPageDownloadModalServersPromo"
:link="`/servers#plan`"
:link="`/hosting#plan`"
@close="
() => {
flags.showProjectPageDownloadModalServersPromo = false
@@ -447,14 +445,34 @@
<div class="normal-page__header relative my-4">
<ProjectHeader :project="project" :member="!!currentMember">
<template #actions>
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
<nuxt-link
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
class="!font-bold"
>
<SettingsIcon aria-hidden="true" />
Edit project
</nuxt-link>
</ButtonStyled>
<div class="hidden sm:contents">
<ButtonStyled
v-tooltip="
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
"
size="large"
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
:color="
(auth.user && currentMember) || route.name === 'type-id-version-version'
? `standard`
: `brand`
"
:circular="auth.user && currentMember"
>
<button @click="(event) => downloadModal.show(event)">
<DownloadIcon aria-hidden="true" />
{{ formatMessage(commonMessages.downloadButton) }}
{{
auth.user && currentMember ? '' : formatMessage(commonMessages.downloadButton)
}}
</button>
</ButtonStyled>
</div>
@@ -485,7 +503,7 @@
<ButtonStyled size="large" circular>
<nuxt-link
v-tooltip="formatMessage(messages.createServerTooltip)"
:to="`/servers?project=${project.id}#plan`"
:to="`/hosting?project=${project.id}#plan`"
@click="
() => {
flags.showProjectPageCreateServersTooltip = false
@@ -641,14 +659,7 @@
<BookmarkIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled v-if="auth.user && currentMember" size="large" circular>
<nuxt-link
v-tooltip="formatMessage(commonMessages.settingsLabel)"
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
>
<SettingsIcon aria-hidden="true" />
</nuxt-link>
</ButtonStyled>
<ButtonStyled size="large" circular type="transparent">
<OverflowMenu
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
@@ -903,6 +914,7 @@
v-model:organization="organization"
:current-member="currentMember"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
:route="route"
@@ -1314,7 +1326,7 @@ const messages = defineMessages({
},
serversPromoDescription: {
id: 'project.actions.servers-promo.description',
defaultMessage: 'Modrinth Servers is the easiest way to play with your friends without hassle!',
defaultMessage: 'Modrinth Hosting is the easiest way to play with your friends without hassle!',
},
serversPromoPricing: {
id: 'project.actions.servers-promo.pricing',
@@ -1446,6 +1458,7 @@ let project,
resetMembers,
dependencies,
versions,
resetVersions,
organization,
resetOrganization,
projectV2Error,
@@ -1459,7 +1472,7 @@ try {
{ data: projectV3, error: projectV3Error, refresh: resetProjectV3 },
{ data: allMembers, error: membersError, refresh: resetMembers },
{ data: dependencies, error: dependenciesError },
{ data: versions, error: versionsError },
{ data: versions, error: versionsError, refresh: resetVersions },
{ data: organization, refresh: resetOrganization },
] = await Promise.all([
useAsyncData(`project/${projectId.value}`, () => useBaseFetch(`project/${projectId.value}`), {
@@ -1746,10 +1759,10 @@ async function patchProject(resData, quiet = false) {
await updateProjectRoute()
if (resData.license_id) {
if ('license_id' in resData) {
project.value.license.id = resData.license_id
}
if (resData.license_url) {
if ('license_url' in resData) {
project.value.license.url = resData.license_url
}
@@ -1917,6 +1930,7 @@ provideProjectPageContext({
projectV2: project,
projectV3,
refreshProject: resetProject,
refreshVersions: resetVersions,
currentMember,
})
</script>

View File

@@ -47,7 +47,7 @@
>
</div>
<a
:href="version.primaryFile.url"
:href="version.primaryFile?.url"
class="iconified-button download"
:title="`Download ${version.name}`"
>

View File

@@ -195,7 +195,36 @@
</div>
</div>
</div>
<div v-if="currentMember" class="card header-buttons">
<Admonition v-if="!hideGalleryAdmonition && currentMember" type="info" class="mb-4">
Creating and editing gallery images can now be done directly from the
<NuxtLink to="settings/gallery" class="font-medium text-blue hover:underline"
>project settings</NuxtLink
>.
<template #actions>
<div class="flex gap-2">
<ButtonStyled color="blue">
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => $router.push('settings/gallery')"
>
<SettingsIcon />
Edit gallery
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
aria-label="Dismiss"
class="!shadow-none"
@click="() => (hideGalleryAdmonition = true)"
>
Dismiss
</button>
</ButtonStyled>
</div>
</template>
</Admonition>
<div v-if="currentMember && project.gallery.length" class="card header-buttons">
<FileInput
:max-size="5242880"
:accept="acceptFileTypes"
@@ -216,7 +245,7 @@
@change="handleFiles"
/>
</div>
<div class="items">
<div v-if="project.gallery.length" class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
@@ -273,6 +302,15 @@
</div>
</div>
</div>
<template v-else>
<p class="ml-2">
No images in gallery. Visit
<NuxtLink to="settings/gallery">
<span class="font-medium text-green hover:underline">project settings</span> to
</NuxtLink>
upload images.
</p>
</template>
</div>
</template>
@@ -289,6 +327,7 @@ import {
PlusIcon,
RightArrowIcon,
SaveIcon,
SettingsIcon,
StarIcon,
TransferIcon,
TrashIcon,
@@ -296,12 +335,15 @@ import {
XIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
ConfirmModal,
DropArea,
FileInput,
injectNotificationManager,
NewModal as Modal,
} from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core'
import { isPermission } from '~/utils/permissions.ts'
@@ -334,6 +376,11 @@ useSeoMeta({
ogTitle: title,
ogDescription: description,
})
const hideGalleryAdmonition = useLocalStorage(
'hideGalleryHasMovedAdmonition',
!props.project.gallery.length,
)
</script>
<script>

View File

@@ -3,12 +3,21 @@
<div v-if="project.body" class="card">
<ProjectPageDescription :description="project.body" />
</div>
<p v-else class="ml-2">
No description provided. Visit
<NuxtLink :to="`${route.fullPath}/settings/description`">
<span class="font-medium text-green hover:underline">project settings</span> to
</NuxtLink>
add your description.
</p>
</section>
</template>
<script setup>
import { ProjectPageDescription } from '@modrinth/ui'
const route = useRoute()
defineProps({
project: {
type: Object,

View File

@@ -3,7 +3,6 @@ import {
AlignLeftIcon,
BookTextIcon,
ChartIcon,
GlobeIcon,
ImageIcon,
InfoIcon,
LinkIcon,
@@ -11,11 +10,17 @@ import {
UsersIcon,
VersionIcon,
} from '@modrinth/assets'
import { commonMessages, commonProjectSettingsMessages } from '@modrinth/ui'
import {
commonMessages,
commonProjectSettingsMessages,
injectNotificationManager,
} from '@modrinth/ui'
import type { Project, ProjectV3Partial } from '@modrinth/utils'
import { useVIntl } from '@vintl/vintl'
import { useLocalStorage, useScroll } from '@vueuse/core'
import { computed } from 'vue'
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
import NavStack from '~/components/ui/NavStack.vue'
const { formatMessage } = useVIntl()
@@ -25,6 +30,7 @@ defineProps<{
patchProject: any
patchIcon: any
resetProject: any
resetVersions: any
resetOrganization: any
resetMembers: any
}>()
@@ -55,15 +61,6 @@ const navItems = computed(() => {
icon: InfoIcon,
}
: null,
flags.value.newProjectEnvironmentSettings &&
projectV3.value.project_types.some((type: string) => ['mod', 'modpack'].includes(type))
? {
link: `/${base}/settings/environment`,
label: formatMessage(commonProjectSettingsMessages.environment),
badge: formatMessage(commonMessages.newBadge),
icon: GlobeIcon,
}
: null,
{
link: `/${base}/settings/tags`,
label: formatMessage(commonProjectSettingsMessages.tags),
@@ -74,11 +71,21 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.description),
icon: AlignLeftIcon,
},
{
link: `/${base}/settings/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
},
{
link: `/${base}/settings/license`,
label: formatMessage(commonProjectSettingsMessages.license),
icon: BookTextIcon,
},
{
link: `/${base}/settings/gallery`,
label: formatMessage(commonProjectSettingsMessages.gallery),
icon: ImageIcon,
},
{
link: `/${base}/settings/links`,
label: formatMessage(commonProjectSettingsMessages.links),
@@ -89,51 +96,91 @@ const navItems = computed(() => {
label: formatMessage(commonProjectSettingsMessages.members),
icon: UsersIcon,
},
{ type: 'heading', label: formatMessage(commonProjectSettingsMessages.view) },
{
link: `/${base}/settings/analytics`,
label: formatMessage(commonProjectSettingsMessages.analytics),
icon: ChartIcon,
chevron: true,
},
{ type: 'heading', label: formatMessage(commonProjectSettingsMessages.upload) },
{
link: `/${base}/gallery`,
label: formatMessage(commonProjectSettingsMessages.gallery),
icon: ImageIcon,
chevron: true,
},
{
link: `/${base}/versions`,
label: formatMessage(commonProjectSettingsMessages.versions),
icon: VersionIcon,
chevron: true,
},
]
return items.filter(Boolean) as any[]
})
const { addNotification } = injectNotificationManager()
const tags = useGeneratedState()
const route = useRoute()
const collapsedChecklist = useLocalStorage(`project-checklist-collapsed-${project.value.id}`, false)
async function setProcessing() {
startLoading()
try {
await useBaseFetch(`project/${project.value.id}`, {
method: 'PATCH',
body: {
status: 'processing',
},
})
project.value.status = 'processing'
} catch (err: any) {
addNotification({
title: formatMessage(commonMessages.errorNotificationTitle),
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
// To persist scroll position through settings pages
// This scroll code is jank asf, if anyone has a better way please do suggest it
const scroll = useScroll(window)
watch(route, () => {
const scrollY = scroll.y.value
setTimeout(() => window.scrollTo(0, scrollY), 10)
})
</script>
<template>
<div class="experimental-styles-within grid gap-4 lg:grid-cols-[1fr_3fr]">
<div>
<NavStack :items="navItems" />
</div>
<div class="min-w-0">
<NuxtPage
v-model:project="project"
v-model:project-v3="projectV3"
v-model:versions="versions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
/>
<div class="mb-8 flex w-full flex-col gap-4">
<ModerationProjectNags
v-if="
(currentMember && project.status === 'draft') ||
tags.rejectedStatuses.includes(project.status)
"
:project="project"
:versions="versions"
:current-member="currentMember"
:collapsed="collapsedChecklist"
:route-name="route.name as string"
:tags="tags"
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
@set-processing="setProcessing"
/>
<div class="experimental-styles-within grid gap-4 lg:grid-cols-[1fr_3fr]">
<div>
<NavStack :items="navItems" />
</div>
<div class="min-w-0">
<NuxtPage
v-model:project="project"
v-model:project-v3="projectV3"
v-model:versions="versions"
v-model:members="members"
v-model:all-members="allMembers"
v-model:dependencies="dependencies"
v-model:organization="organization"
:current-member="currentMember"
:patch-project="patchProject"
:patch-icon="patchIcon"
:reset-project="resetProject"
:reset-versions="resetVersions"
:reset-organization="resetOrganization"
:reset-members="resetMembers"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,810 @@
<template>
<div>
<Modal
v-if="currentMember"
ref="modal_edit_item"
:header="editIndex === -1 ? 'Upload gallery image' : 'Edit gallery item'"
>
<div class="modal-gallery universal-labels">
<div class="gallery-file-input">
<div class="file-header">
<ImageIcon aria-hidden="true" />
<strong>{{ editFile ? editFile.name : 'Current image' }}</strong>
<FileInput
v-if="editIndex === -1"
class="iconified-button raised-button"
prompt="Replace"
:accept="acceptFileTypes"
:max-size="5242880"
should-always-reset
aria-label="Replace image"
@change="
(x) => {
editFile = x[0]
showPreviewImage()
}
"
>
<TransferIcon aria-hidden="true" />
</FileInput>
</div>
<img
:src="
previewImage
? previewImage
: project.gallery[editIndex] && project.gallery[editIndex].url
? project.gallery[editIndex].url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
alt="gallery-preview"
/>
</div>
<label for="gallery-image-title">
<span class="label__title">Title</span>
</label>
<input
id="gallery-image-title"
v-model="editTitle"
type="text"
maxlength="64"
placeholder="Enter title..."
/>
<label for="gallery-image-desc">
<span class="label__title">Description</span>
</label>
<div class="textarea-wrapper">
<textarea
id="gallery-image-desc"
v-model="editDescription"
maxlength="255"
placeholder="Enter description..."
/>
</div>
<label for="gallery-image-ordering">
<span class="label__title">Order Index</span>
</label>
<input
id="gallery-image-ordering"
v-model="editOrder"
type="number"
placeholder="Enter order index..."
/>
<label for="gallery-image-featured">
<span class="label__title">Featured</span>
<span class="label__description">
A featured gallery image shows up in search and your project card. Only one gallery
image can be featured.
</span>
</label>
<button
v-if="!editFeatured"
id="gallery-image-featured"
class="iconified-button"
@click="editFeatured = true"
>
<StarIcon aria-hidden="true" />
Feature image
</button>
<button
v-else
id="gallery-image-featured"
class="iconified-button"
@click="editFeatured = false"
>
<StarIcon fill="currentColor" aria-hidden="true" />
Unfeature image
</button>
<div class="button-group">
<button class="iconified-button" @click="$refs.modal_edit_item.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
<button
v-if="editIndex === -1"
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="createGalleryItem"
>
<PlusIcon aria-hidden="true" />
Add gallery image
</button>
<button
v-else
class="iconified-button brand-button"
:disabled="shouldPreventActions"
@click="editGalleryItem"
>
<SaveIcon aria-hidden="true" />
Save changes
</button>
</div>
</div>
</Modal>
<ConfirmModal
v-if="currentMember"
ref="modal_confirm"
title="Are you sure you want to delete this gallery image?"
description="This will remove this gallery image forever (like really forever)."
:has-to-type="false"
proceed-label="Delete"
@proceed="deleteGalleryImage"
/>
<div
v-if="expandedGalleryItem != null"
class="expanded-image-modal"
@click="expandedGalleryItem = null"
>
<div class="content">
<img
class="image"
:class="{ 'zoomed-in': zoomedIn }"
:src="
expandedGalleryItem.raw_url
? expandedGalleryItem.raw_url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
:alt="expandedGalleryItem.title ? expandedGalleryItem.title : 'gallery-image'"
@click.stop
/>
<div class="floating" @click.stop>
<div class="text">
<h2 v-if="expandedGalleryItem.title">
{{ expandedGalleryItem.title }}
</h2>
<p v-if="expandedGalleryItem.description">
{{ expandedGalleryItem.description }}
</p>
</div>
<div class="controls">
<div class="buttons">
<button class="close circle-button" @click="expandedGalleryItem = null">
<XIcon aria-hidden="true" />
</button>
<a
class="open circle-button"
target="_blank"
:href="
expandedGalleryItem.raw_url
? expandedGalleryItem.raw_url
: 'https://cdn.modrinth.com/placeholder-banner.svg'
"
>
<ExternalIcon aria-hidden="true" />
</a>
<button class="circle-button" @click="zoomedIn = !zoomedIn">
<ExpandIcon v-if="!zoomedIn" aria-hidden="true" />
<ContractIcon v-else aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
class="previous circle-button"
@click="previousImage()"
>
<LeftArrowIcon aria-hidden="true" />
</button>
<button
v-if="project.gallery.length > 1"
class="next circle-button"
@click="nextImage()"
>
<RightArrowIcon aria-hidden="true" />
</button>
</div>
</div>
</div>
</div>
</div>
<div v-if="currentMember" class="card header-buttons">
<FileInput
:max-size="5242880"
:accept="acceptFileTypes"
prompt="Upload an image"
aria-label="Upload an image"
class="iconified-button brand-button"
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
@change="handleFiles"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<span class="indicator">
<InfoIcon aria-hidden="true" /> Click to choose an image or drag one onto this page
</span>
<DropArea
:accept="acceptFileTypes"
:disabled="!isPermission(currentMember?.permissions, 1 << 2)"
@change="handleFiles"
/>
</div>
<div class="items">
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
<a class="gallery-thumbnail" @click="expandImage(item, index)">
<img
:src="item.url ? item.url : 'https://cdn.modrinth.com/placeholder-banner.svg'"
:alt="item.title ? item.title : 'gallery-image'"
/>
</a>
<div class="gallery-body">
<div class="gallery-info">
<h2 v-if="item.title">
{{ item.title }}
</h2>
<p v-if="item.description">
{{ item.description }}
</p>
</div>
</div>
<div class="gallery-bottom">
<div class="gallery-created">
<CalendarIcon aria-hidden="true" aria-label="Date created" />
{{ $dayjs(item.created).format('MMMM D, YYYY') }}
</div>
<div v-if="currentMember" class="gallery-buttons input-group">
<button
class="iconified-button"
@click="
() => {
resetEdit()
editIndex = index
editTitle = item.title
editDescription = item.description
editFeatured = item.featured
editOrder = item.ordering
$refs.modal_edit_item.show()
}
"
>
<EditIcon aria-hidden="true" />
Edit
</button>
<button
class="iconified-button"
@click="
() => {
deleteIndex = index
$refs.modal_confirm.show()
}
"
>
<TrashIcon aria-hidden="true" />
Remove
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import {
CalendarIcon,
ContractIcon,
EditIcon,
ExpandIcon,
ExternalIcon,
ImageIcon,
InfoIcon,
LeftArrowIcon,
PlusIcon,
RightArrowIcon,
SaveIcon,
StarIcon,
TransferIcon,
TrashIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import {
ConfirmModal,
DropArea,
FileInput,
injectNotificationManager,
NewModal as Modal,
} from '@modrinth/ui'
import { isPermission } from '~/utils/permissions.ts'
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
currentMember: {
type: Object,
default() {
return null
},
},
resetProject: {
type: Function,
required: true,
default: () => {},
},
})
const title = `${props.project.title} - Gallery`
const description = `View ${props.project.gallery.length} images of ${props.project.title} on Modrinth.`
useSeoMeta({
title,
description,
ogTitle: title,
ogDescription: description,
})
</script>
<script>
export default defineNuxtComponent({
setup() {
const { addNotification } = injectNotificationManager()
return {
addNotification,
}
},
data() {
return {
expandedGalleryItem: null,
expandedGalleryIndex: 0,
zoomedIn: false,
deleteIndex: -1,
editIndex: -1,
editTitle: '',
editDescription: '',
editFeatured: false,
editOrder: null,
editFile: null,
previewImage: null,
shouldPreventActions: false,
}
},
computed: {
acceptFileTypes() {
return 'image/png,image/jpeg,image/gif,image/webp,.png,.jpeg,.gif,.webp'
},
},
mounted() {
this._keyListener = function (e) {
if (this.expandedGalleryItem) {
e.preventDefault()
if (e.key === 'Escape') {
this.expandedGalleryItem = null
} else if (e.key === 'ArrowLeft') {
e.stopPropagation()
this.previousImage()
} else if (e.key === 'ArrowRight') {
e.stopPropagation()
this.nextImage()
}
}
}
document.addEventListener('keydown', this._keyListener.bind(this))
},
methods: {
nextImage() {
this.expandedGalleryIndex++
if (this.expandedGalleryIndex >= this.project.gallery.length) {
this.expandedGalleryIndex = 0
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
previousImage() {
this.expandedGalleryIndex--
if (this.expandedGalleryIndex < 0) {
this.expandedGalleryIndex = this.project.gallery.length - 1
}
this.expandedGalleryItem = this.project.gallery[this.expandedGalleryIndex]
},
expandImage(item, index) {
this.expandedGalleryItem = item
this.expandedGalleryIndex = index
this.zoomedIn = false
},
resetEdit() {
this.editIndex = -1
this.editTitle = ''
this.editDescription = ''
this.editFeatured = false
this.editOrder = null
this.editFile = null
this.previewImage = null
},
handleFiles(files) {
this.resetEdit()
this.editFile = files[0]
this.showPreviewImage()
this.$refs.modal_edit_item.show()
},
showPreviewImage() {
const reader = new FileReader()
if (this.editFile instanceof Blob) {
reader.readAsDataURL(this.editFile)
reader.onload = (event) => {
this.previewImage = event.target.result
}
}
},
async createGalleryItem() {
this.shouldPreventActions = true
startLoading()
try {
let url = `project/${this.project.id}/gallery?ext=${
this.editFile
? this.editFile.type.split('/')[this.editFile.type.split('/').length - 1]
: null
}&featured=${this.editFeatured}`
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, {
method: 'POST',
body: this.editFile,
})
await this.resetProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
this.shouldPreventActions = false
},
async editGalleryItem() {
this.shouldPreventActions = true
startLoading()
try {
let url = `project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.editIndex].url,
)}&featured=${this.editFeatured}`
if (this.editTitle) {
url += `&title=${encodeURIComponent(this.editTitle)}`
}
if (this.editDescription) {
url += `&description=${encodeURIComponent(this.editDescription)}`
}
if (this.editOrder) {
url += `&ordering=${this.editOrder}`
}
await useBaseFetch(url, {
method: 'PATCH',
})
await this.resetProject()
this.$refs.modal_edit_item.hide()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
this.shouldPreventActions = false
},
async deleteGalleryImage() {
startLoading()
try {
await useBaseFetch(
`project/${this.project.id}/gallery?url=${encodeURIComponent(
this.project.gallery[this.deleteIndex].url,
)}`,
{
method: 'DELETE',
},
)
await this.resetProject()
} catch (err) {
this.addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
},
},
})
</script>
<style lang="scss" scoped>
.header-buttons {
display: flex;
align-items: center;
gap: 1rem;
.indicator {
display: flex;
gap: 0.5ch;
align-items: center;
color: var(--color-text-inactive);
}
}
.expanded-image-modal {
position: fixed;
z-index: 20;
overflow: auto;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: #000000;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
justify-content: center;
align-items: center;
.content {
position: relative;
width: calc(100vw - 2 * var(--spacing-card-lg));
height: calc(100vh - 2 * var(--spacing-card-lg));
.circle-button {
padding: 0.5rem;
line-height: 1;
display: flex;
max-width: 2rem;
color: var(--color-button-text);
background-color: var(--color-button-bg);
border-radius: var(--size-rounded-max);
margin: 0;
box-shadow: inset 0px -1px 1px rgb(17 24 39 / 10%);
&:not(:last-child) {
margin-right: 0.5rem;
}
&:hover {
background-color: var(--color-button-bg-hover) !important;
svg {
color: var(--color-button-text-hover) !important;
}
}
&:active {
background-color: var(--color-button-bg-active) !important;
svg {
color: var(--color-button-text-active) !important;
}
}
svg {
height: 1rem;
width: 1rem;
}
}
.image {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
max-width: calc(100vw - 2 * var(--spacing-card-lg));
max-height: calc(100vh - 2 * var(--spacing-card-lg));
border-radius: var(--size-rounded-card);
&.zoomed-in {
object-fit: cover;
width: auto;
height: calc(100vh - 2 * var(--spacing-card-lg));
max-width: calc(100vw - 2 * var(--spacing-card-lg));
}
}
.floating {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: var(--spacing-card-md);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing-card-sm);
transition: opacity 0.25s ease-in-out;
opacity: 1;
padding: 2rem 2rem 0 2rem;
&:not(&:hover) {
opacity: 0.4;
.text {
transform: translateY(2.5rem) scale(0.8);
opacity: 0;
}
.controls {
transform: translateY(0.25rem) scale(0.9);
}
}
.text {
display: flex;
flex-direction: column;
max-width: 40rem;
transition:
opacity 0.25s ease-in-out,
transform 0.25s ease-in-out;
text-shadow: 1px 1px 10px #000000d4;
margin-bottom: 0.25rem;
gap: 0.5rem;
h2 {
color: var(--dark-color-text-dark);
font-size: 1.25rem;
text-align: center;
margin: 0;
}
p {
color: var(--dark-color-text);
margin: 0;
}
}
.controls {
background-color: var(--color-raised-bg);
padding: var(--spacing-card-md);
border-radius: var(--size-rounded-card);
transition:
opacity 0.25s ease-in-out,
transform 0.25s ease-in-out;
}
}
}
}
.buttons {
display: flex;
button {
margin-right: 0.5rem;
}
}
.items {
display: grid;
grid-template-rows: 1fr;
grid-template-columns: 1fr;
grid-gap: var(--spacing-card-md);
@media screen and (min-width: 1024px) {
grid-template-columns: 1fr 1fr 1fr;
}
}
.gallery-item {
display: flex;
flex-direction: column;
padding: 0;
img {
width: 100%;
margin-top: 0;
margin-bottom: 0;
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
min-height: 10rem;
object-fit: cover;
}
.gallery-body {
width: calc(100% - 2 * var(--spacing-card-md));
padding: var(--spacing-card-sm) var(--spacing-card-md);
overflow-wrap: anywhere;
.gallery-info {
h2 {
margin-bottom: 0.5rem;
}
p {
margin: 0 0 0.5rem 0;
}
}
}
.gallery-thumbnail {
cursor: pointer;
img {
transition: filter 0.25s ease-in-out;
&:hover {
filter: brightness(0.7);
}
}
}
.gallery-bottom {
width: calc(100% - 2 * var(--spacing-card-md));
padding: 0 var(--spacing-card-md) var(--spacing-card-sm) var(--spacing-card-md);
.gallery-created {
display: flex;
align-items: center;
margin-bottom: 0.5rem;
color: var(--color-icon);
svg {
width: 1rem;
height: 1rem;
margin-right: 0.25rem;
}
}
.gallery-buttons {
display: flex;
}
.columns {
margin-bottom: 0.5rem;
}
}
}
.modal-gallery {
display: flex;
flex-direction: column;
.gallery-file-input {
.file-header {
border-radius: var(--size-rounded-card) var(--size-rounded-card) 0 0;
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--color-button-bg);
padding: var(--spacing-card-md);
svg {
min-width: 1rem;
}
strong {
word-wrap: anywhere;
}
.iconified-button {
margin-left: auto;
}
}
img {
border-radius: 0 0 var(--size-rounded-card) var(--size-rounded-card);
width: 100%;
height: auto;
max-height: 15rem;
object-fit: contain;
background-color: #000000;
}
}
}
.brand-button {
color: var(--color-accent-contrast);
}
</style>

View File

@@ -0,0 +1,414 @@
<template>
<div>
<CreateProjectVersionModal
v-if="currentMember"
ref="create-project-version-modal"
></CreateProjectVersionModal>
<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()"
/>
<ProjectPageVersions
v-if="versions.length > 0"
:project="project"
:versions="versionsWithDisplayUrl"
:show-files="flags.showVersionFilesInTable"
:current-member="!!currentMember"
:loaders="tags.loaders"
:game-versions="tags.gameVersions"
:base-id="baseDropdownId"
:version-link="
(version: any) =>
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
"
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<OverflowMenu
v-tooltip="'Edit version'"
class="hover:!bg-button-bg [&>svg]:!text-green"
:dropdown-id="`${baseDropdownId}-edit-${version.id}`"
:options="[
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
},
{
id: 'edit-changelog',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
},
{
id: 'edit-dependencies',
action: () =>
handleOpenEditVersionModal(version.id, project.id, 'add-dependencies'),
shown: project.project_type !== 'modpack',
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
},
]"
aria-label="Edit version"
>
<EditIcon aria-hidden="true" />
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-dependencies>
<BoxIcon aria-hidden="true" />
Edit dependencies
</template>
<template #edit-changelog>
<AlignLeftIcon aria-hidden="true" />
Edit changelog
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
v-tooltip="'More options'"
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-${version.id}`"
:options="[
{
id: 'download',
color: 'primary',
hoverFilled: true,
link: getPrimaryFile(version).url,
action: () => {
emit('onDownload')
},
},
{
id: 'new-tab',
action: () => {},
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
external: true,
},
{
id: 'copy-link',
action: () =>
copyToClipboard(
`https://modrinth.com/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`,
),
},
{
id: 'share',
action: () => {},
shown: false,
},
{
id: 'report',
color: 'red',
hoverFilled: true,
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,
},
{
id: 'copy-maven',
action: () => {
copyToClipboard(`maven.modrinth:${project.slug}:${version.id}`)
},
shown: flags.developerMode,
},
{ divider: true, shown: !!currentMember },
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
shown: !!currentMember,
},
{
id: 'edit-changelog',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
shown: !!currentMember,
},
{
id: 'edit-dependencies',
action: () =>
handleOpenEditVersionModal(version.id, project.id, 'add-dependencies'),
shown: !!currentMember && project.project_type !== 'modpack',
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
shown: !!currentMember,
},
{
id: 'delete',
color: 'red',
hoverFilled: true,
action: () => {
selectedVersion = version.id
deleteVersionModal?.show()
},
shown: !!currentMember,
},
]"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #download>
<DownloadIcon aria-hidden="true" />
Download
</template>
<template #new-tab>
<ExternalIcon aria-hidden="true" />
Open in new tab
</template>
<template #copy-link>
<LinkIcon aria-hidden="true" />
Copy link
</template>
<template #share>
<ShareIcon aria-hidden="true" />
Share
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-dependencies>
<BoxIcon aria-hidden="true" />
Edit dependencies
</template>
<template #edit-changelog>
<AlignLeftIcon aria-hidden="true" />
Edit changelog
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
Delete
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</template>
<template #copy-maven>
<ClipboardCopyIcon aria-hidden="true" />
Copy Maven coordinates
</template>
</OverflowMenu>
</ButtonStyled>
</template>
</ProjectPageVersions>
<template v-if="!versions.length">
<div class="grid place-content-center py-10">
<svg
width="250"
height="200"
viewBox="0 0 250 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
class="h-[200px] w-[250px]"
>
<path
d="M136 64C139.866 64 143 67.134 143 71C143 74.866 139.866 78 136 78H200C203.866 78 207 81.134 207 85C207 88.866 203.866 92 200 92H222C225.866 92 229 95.134 229 99C229 102.866 225.866 106 222 106H203C199.134 106 196 109.134 196 113C196 116.866 199.134 120 203 120H209C212.866 120 216 123.134 216 127C216 130.866 212.866 134 209 134H157C156.485 134 155.983 133.944 155.5 133.839C155.017 133.944 154.515 134 154 134H63C59.134 134 56 130.866 56 127C56 123.134 59.134 120 63 120H24C20.134 120 17 116.866 17 113C17 109.134 20.134 106 24 106H64C67.866 106 71 102.866 71 99C71 95.134 67.866 92 64 92H39C35.134 92 32 88.866 32 85C32 81.134 35.134 78 39 78H79C75.134 78 72 74.866 72 71C72 67.134 75.134 64 79 64H136ZM226 120C229.866 120 233 123.134 233 127C233 130.866 229.866 134 226 134C222.134 134 219 130.866 219 127C219 123.134 222.134 120 226 120Z"
class="fill-surface-2"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M113.119 112.307C113.04 112.86 113 113.425 113 114C113 120.627 118.373 126 125 126C131.627 126 137 120.627 137 114C137 113.425 136.96 112.86 136.881 112.307H166V139C166 140.657 164.657 142 163 142H87C85.3431 142 84 140.657 84 139V112.307H113.119Z"
class="fill-surface-1"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M138 112C138 119.18 132.18 125 125 125C117.82 125 112 119.18 112 112C112 111.767 112.006 111.536 112.018 111.307H84L93.5604 83.0389C93.9726 81.8202 95.1159 81 96.4023 81H153.598C154.884 81 156.027 81.8202 156.44 83.0389L166 111.307H137.982C137.994 111.536 138 111.767 138 112Z"
class="fill-surface-1"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M136.098 112.955C136.098 118.502 131.129 124 125 124C118.871 124 113.902 118.502 113.902 112.955C113.902 112.775 113.908 111.596 113.918 111.419H93L101.161 91.5755C101.513 90.6338 102.489 90 103.587 90H146.413C147.511 90 148.487 90.6338 148.839 91.5755L157 111.419H136.082C136.092 111.596 136.098 112.775 136.098 112.955Z"
fill="#27292E"
class="fill-surface-3"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M85.25 111.512V138C85.25 138.966 86.0335 139.75 87 139.75H163C163.966 139.75 164.75 138.966 164.75 138V111.512L155.255 83.4393C155.015 82.7285 154.348 82.25 153.598 82.25H96.4023C95.6519 82.25 94.985 82.7285 94.7446 83.4393L85.25 111.512Z"
stroke-width="2.5"
class="stroke-surface-4"
/>
<path
d="M98 111C101.937 111 106.185 111 110.745 111C112.621 111 112.621 112.319 112.621 113C112.621 119.627 118.117 125 124.897 125C131.677 125 137.173 119.627 137.173 113C137.173 112.319 137.173 111 139.05 111H164M90.5737 111H93H90.5737Z"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="stroke-surface-4"
/>
<path
d="M150.1 58.3027L139 70.7559M124.1 54V70.7559V54ZM98 58.3027L109.1 70.7559L98 58.3027Z"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
class="stroke-surface-4"
/>
</svg>
<div class="flex flex-col items-center gap-2 text-center">
<div class="text-2xl font-semibold text-contrast">No versions created</div>
<div>Create your first project version.</div>
<br />
<ButtonStyled color="green">
<button @click="() => createProjectVersionModal?.openCreateVersionModal()">
<PlusIcon /> Create version
</button>
</ButtonStyled>
</div>
</div>
</template>
</div>
</template>
<script lang="ts" setup>
import type { Labrinth } from '@modrinth/api-client'
import {
AlignLeftIcon,
BoxIcon,
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
ExternalIcon,
FileIcon,
InfoIcon,
LinkIcon,
MoreVerticalIcon,
PlusIcon,
ReportIcon,
ShareIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
ConfirmModal,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
OverflowMenu,
ProjectPageVersions,
} from '@modrinth/ui'
import { useTemplateRef } from 'vue'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { reportVersion } from '~/utils/report-helpers.ts'
interface Props {
project: Labrinth.Projects.v2.Project
currentMember?: object
}
const { project, currentMember } = defineProps<Props>()
const versions = defineModel<Labrinth.Versions.v3.Version[]>('versions', { required: true })
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { refreshVersions } = injectProjectPageContext()
const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
const createProjectVersionModal = useTemplateRef('create-project-version-modal')
const deleteVersionModal = ref<InstanceType<typeof ConfirmModal>>()
const selectedVersion = ref<string | null>(null)
const handleOpenCreateVersionModal = () => {
if (!currentMember) return
createProjectVersionModal.value?.openCreateVersionModal()
}
const handleOpenEditVersionModal = (
versionId: string,
projectId: string,
stageId?: string | null,
) => {
if (!currentMember) return
createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId)
}
const versionsWithDisplayUrl = computed(() =>
versions.value.map((v) => ({
...v,
displayUrlEnding: v.id,
})),
)
const emit = defineEmits(['onDownload'])
const baseDropdownId = useId()
function getPrimaryFile(version: Labrinth.Versions.v3.Version) {
return version.files.find((x) => x.primary) || version.files[0]
}
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
}
async function deleteVersion() {
const id = selectedVersion.value
if (!id) return
startLoading()
try {
await client.labrinth.versions_v3.deleteVersion(id)
addNotification({
title: 'Version deleted',
text: 'The version has been successfully deleted.',
type: 'success',
})
} catch (err: any) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
refreshVersions()
selectedVersion.value = null
stopLoading()
}
</script>

View File

@@ -1,163 +0,0 @@
<template>
<div class="normal-page__content flex flex-col gap-4">
<nuxt-link
:to="versionsListLink"
class="flex w-fit items-center gap-1 text-brand-blue hover:underline"
>
<ChevronLeftIcon />
{{
hasBackLink ? formatMessage(messages.backToVersions) : formatMessage(messages.allVersions)
}}
</nuxt-link>
<div class="flex gap-3">
<VersionChannelIndicator :channel="version.version_type" large />
<div class="flex flex-col gap-1">
<h1 class="m-0 text-xl font-extrabold leading-none text-contrast">
{{ version.version_number }}
</h1>
<span class="text-sm font-semibold text-secondary"> {{ version.name }} </span>
</div>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button><DownloadIcon /> Download</button>
</ButtonStyled>
<ButtonStyled>
<button><ShareIcon /> Share</button>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button>
<MoreVerticalIcon />
</button>
</ButtonStyled>
</div>
<div>
<h2 class="text-lg font-extrabold text-contrast">Files</h2>
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div
v-for="(file, index) in version.files"
:key="index"
class="flex gap-2 rounded-2xl bg-bg-raised p-4"
>
<div
:class="`flex h-9 w-9 items-center justify-center rounded-full ${file.primary ? 'bg-brand-highlight text-brand' : 'bg-button-bg text-secondary'}`"
>
<FileIcon />
</div>
<div class="flex flex-grow flex-col">
<span class="font-extrabold text-contrast">{{
file.primary ? 'Primary file' : 'Supplementary resource'
}}</span>
<span class="text-sm font-semibold text-secondary"
>{{ file.filename }} {{ formatBytes(file.size) }}</span
>
</div>
<div>
<ButtonStyled circular type="transparent">
<button>
<DownloadIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
<h2 class="text-lg font-extrabold text-contrast">Dependencies</h2>
<h2 class="text-lg font-extrabold text-contrast">Changes</h2>
<div class="rounded-2xl bg-bg-raised px-6 py-4">
<div
class="markdown-body"
v-html="renderHighlightedString(version.changelog ?? 'No changelog provided')"
/>
</div>
</div>
</div>
<div class="normal-page__sidebar">
<div class="padding-lg h-[250px] rounded-2xl bg-bg-raised"></div>
</div>
</template>
<script setup lang="ts">
import {
ChevronLeftIcon,
DownloadIcon,
FileIcon,
MoreVerticalIcon,
ShareIcon,
} from '@modrinth/assets'
import { ButtonStyled, VersionChannelIndicator } from '@modrinth/ui'
import { formatBytes, renderHighlightedString } from '@modrinth/utils'
const router = useRouter()
const props = defineProps<{
project: Project
versions: Version[]
members: User[]
currentMember: User
dependencies: Dependency[]
resetProject: (opts?: { dedupe?: 'cancel' | 'defer' }) => Promise<void>
}>()
const version = computed(() => {
let version: Version | undefined
if (route.params.version === 'latest') {
let versionList = props.versions
if (route.query.loader) {
versionList = versionList.filter((x) => x.loaders.includes(route.query.loader))
}
if (route.query.version) {
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version))
}
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b))
} else {
version = props.versions.find(
(x) => x.id === route.params.version || x.displayUrlEnding === route.params.version,
)
}
if (!version) {
throw createError({
fatal: true,
statusCode: 404,
message: 'Version not found',
})
}
return version
})
// const data = useNuxtApp();
const route = useNativeRoute()
// const auth = await useAuth();
// const tags = useGeneratedState();
const versionsListLink = computed(() => {
if (router.options.history.state.back) {
if (router.options.history.state.back.includes('/versions')) {
return router.options.history.state.back
}
}
return `/${props.project.project_type}/${
props.project.slug ? props.project.slug : props.project.id
}/versions`
})
const hasBackLink = computed(
() =>
router.options.history.state.back && router.options.history.state.back.endsWith('/versions'),
)
const { formatMessage } = useVIntl()
const messages = defineMessages({
backToVersions: {
id: 'project.version.back-to-versions',
defaultMessage: 'Back to versions',
},
allVersions: {
id: 'project.version.all-versions',
defaultMessage: 'All versions',
},
})
</script>
<style lang="scss"></style>

View File

@@ -141,7 +141,7 @@
</ButtonStyled>
</div>
<div v-else class="input-group">
<ButtonStyled v-if="primaryFile" color="brand">
<ButtonStyled v-if="primaryFile && !currentMember" color="brand">
<a
v-tooltip="primaryFile.filename + ' (' + formatBytes(primaryFile.size) + ')'"
:href="primaryFile.url"
@@ -163,18 +163,6 @@
Report
</button>
</ButtonStyled>
<ButtonStyled>
<nuxt-link
v-if="currentMember"
class="action"
:to="`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`"
>
<EditIcon aria-hidden="true" />
Edit
</nuxt-link>
</ButtonStyled>
<ButtonStyled>
<button
v-if="
@@ -187,12 +175,6 @@
Package as mod
</button>
</ButtonStyled>
<ButtonStyled>
<button v-if="currentMember" @click="$refs.modal_confirm.show()">
<TrashIcon aria-hidden="true" />
Delete
</button>
</ButtonStyled>
</div>
</div>
<div class="version-page__changelog universal-card">
@@ -1353,7 +1335,6 @@ export default defineNuxtComponent({
display: flex;
flex-wrap: wrap;
align-items: center;
margin-bottom: 1rem;
gap: var(--spacing-card-md);
h2,

View File

@@ -1,8 +0,0 @@
<template>
<div />
</template>
<script setup>
definePageMeta({
middleware: 'auth',
})
</script>

View File

@@ -1,34 +1,52 @@
<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)"
class="card flex items-center gap-4"
>
<FileInput
:max-size="524288000"
:accept="acceptFileFromProjectType(project.project_type)"
prompt="Upload a version"
class="btn btn-primary"
aria-label="Upload a version"
@change="handleFiles"
>
<UploadIcon aria-hidden="true" />
</FileInput>
<span class="flex items-center gap-2">
<InfoIcon aria-hidden="true" /> Click to choose a file or drag one onto this page
</span>
<DropArea :accept="acceptFileFromProjectType(project.project_type)" @change="handleFiles" />
</div>
<CreateProjectVersionModal
v-if="currentMember"
ref="create-project-version-modal"
></CreateProjectVersionModal>
<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()"
/>
<Admonition v-if="!hideVersionsAdmonition && currentMember" type="info" class="mb-4">
Creating and editing project versions can now be done directly from the
<NuxtLink to="settings/versions" class="font-medium text-blue hover:underline"
>project settings</NuxtLink
>.
<template #actions>
<div class="flex gap-2">
<ButtonStyled color="blue">
<button
aria-label="Project Settings"
class="!shadow-none"
@click="() => router.push('settings/versions')"
>
<SettingsIcon />
Edit versions
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button
aria-label="Dismiss"
class="!shadow-none"
@click="() => (hideVersionsAdmonition = true)"
>
Dismiss
</button>
</ButtonStyled>
</div>
</template>
</Admonition>
<ProjectPageVersions
v-if="versions.length"
:project="project"
:versions="versions"
:show-files="flags.showVersionFilesInTable"
@@ -40,24 +58,72 @@
(version) =>
`/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}`
}/version/${encodeURI(version.displayUrlEnding ? version.displayUrlEnding : version.id)}`
"
:open-modal="currentMember ? () => handleOpenCreateVersionModal() : undefined"
>
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<a
v-tooltip="`Download`"
:href="getPrimaryFile(version).url"
class="group-hover:!bg-brand group-hover:[&>svg]:!text-brand-inverted"
class="hover:!bg-button-bg [&>svg]:!text-green"
aria-label="Download"
@click="emit('onDownload')"
>
<DownloadIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled v-if="currentMember" circular type="transparent">
<OverflowMenu
v-tooltip="'Edit version'"
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-edit-${version.id}`"
:options="[
{
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
},
{
id: 'edit-changelog',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
},
{
id: 'edit-dependencies',
action: () =>
handleOpenEditVersionModal(version.id, project.id, 'add-dependencies'),
shown: project.project_type !== 'modpack',
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
},
]"
aria-label="Edit version"
>
<EditIcon aria-hidden="true" />
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-dependencies>
<BoxIcon aria-hidden="true" />
Edit dependencies
</template>
<template #edit-changelog>
<AlignLeftIcon aria-hidden="true" />
Edit changelog
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<OverflowMenu
class="group-hover:!bg-button-bg"
v-tooltip="'More options'"
class="hover:!bg-button-bg"
:dropdown-id="`${baseDropdownId}-${version.id}`"
:options="[
{
@@ -113,13 +179,27 @@
},
shown: flags.developerMode,
},
{ divider: true, shown: currentMember },
{ divider: true, shown: !!currentMember },
{
id: 'edit',
link: `/${project.project_type}/${
project.slug ? project.slug : project.id
}/version/${encodeURI(version.displayUrlEnding)}/edit`,
shown: currentMember,
id: 'edit-details',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-details'),
shown: !!currentMember,
},
{
id: 'edit-changelog',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-changelog'),
shown: !!currentMember,
},
{
id: 'edit-dependencies',
action: () =>
handleOpenEditVersionModal(version.id, project.id, 'add-dependencies'),
shown: !!currentMember && project.project_type !== 'modpack',
},
{
id: 'edit-files',
action: () => handleOpenEditVersionModal(version.id, project.id, 'add-files'),
shown: !!currentMember,
},
{
id: 'delete',
@@ -127,9 +207,9 @@
hoverFilled: true,
action: () => {
selectedVersion = version.id
deleteVersionModal.show()
deleteVersionModal?.show()
},
shown: currentMember,
shown: !!currentMember,
},
]"
aria-label="More options"
@@ -155,9 +235,21 @@
<ReportIcon aria-hidden="true" />
Report
</template>
<template #edit>
<EditIcon aria-hidden="true" />
Edit
<template #edit-files>
<FileIcon aria-hidden="true" />
Edit files
</template>
<template #edit-details>
<InfoIcon aria-hidden="true" />
Edit details
</template>
<template #edit-dependencies>
<BoxIcon aria-hidden="true" />
Edit dependencies
</template>
<template #edit-changelog>
<AlignLeftIcon aria-hidden="true" />
Edit changelog
</template>
<template #delete>
<TrashIcon aria-hidden="true" />
@@ -175,34 +267,49 @@
</ButtonStyled>
</template>
</ProjectPageVersions>
<template v-else>
<p class="ml-2">
No versions in project. Visit
<NuxtLink to="settings/versions">
<span class="font-medium text-green hover:underline">project settings</span> to
</NuxtLink>
upload your first version.
</p>
</template>
</section>
</template>
<script setup>
import {
AlignLeftIcon,
BoxIcon,
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
ExternalIcon,
FileIcon,
InfoIcon,
LinkIcon,
MoreVerticalIcon,
ReportIcon,
SettingsIcon,
ShareIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
ConfirmModal,
DropArea,
FileInput,
injectModrinthClient,
injectNotificationManager,
injectProjectPageContext,
OverflowMenu,
ProjectPageVersions,
} from '@modrinth/ui'
import { useLocalStorage } from '@vueuse/core'
import { useTemplateRef } from 'vue'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
import { isPermission } from '~/utils/permissions.ts'
import CreateProjectVersionModal from '~/components/ui/create-project-version/CreateProjectVersionModal.vue'
import { reportVersion } from '~/utils/report-helpers.ts'
const props = defineProps({
@@ -230,8 +337,28 @@ const tags = useGeneratedState()
const flags = useFeatureFlags()
const auth = await useAuth()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { refreshVersions } = injectProjectPageContext()
const deleteVersionModal = ref()
const selectedVersion = ref(null)
const createProjectVersionModal = useTemplateRef('create-project-version-modal')
const handleOpenCreateVersionModal = () => {
if (!props.currentMember) return
createProjectVersionModal.value?.openCreateVersionModal()
}
const handleOpenEditVersionModal = (versionId, projectId, stageId) => {
if (!props.currentMember) return
createProjectVersionModal.value?.openEditVersionModal(versionId, projectId, stageId)
}
const hideVersionsAdmonition = useLocalStorage(
'hideVersionsHasMovedAdmonition',
!props.versions.length,
)
const emit = defineEmits(['onDownload', 'deleteVersion'])
@@ -243,26 +370,35 @@ function getPrimaryFile(version) {
return version.files.find((x) => x.primary) || version.files[0]
}
async function handleFiles(files) {
await router.push({
name: 'type-id-version-version',
params: {
type: props.project.project_type,
id: props.project.slug ? props.project.slug : props.project.id,
version: 'create',
},
state: {
newPrimaryFile: files[0],
},
})
}
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
}
function deleteVersion() {
emit('deleteVersion', selectedVersion.value)
async function deleteVersion() {
const id = selectedVersion.value
if (!id) return
startLoading()
try {
await client.labrinth.versions_v3.deleteVersion(id)
addNotification({
title: 'Version deleted',
text: 'The version has been successfully deleted.',
type: 'success',
})
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
refreshVersions()
selectedVersion.value = null
stopLoading()
}
</script>

View File

@@ -182,7 +182,7 @@
"
>
<nuxt-link
:to="`/servers/manage/${subscription.metadata.id}`"
:to="`/hosting/manage/${subscription.metadata.id}`"
target="_blank"
class="w-fit"
>

View File

@@ -122,7 +122,7 @@
<div
class="mb-6 flex items-end justify-between border-0 border-b border-solid border-divider pb-4"
>
<h1 class="m-0 text-2xl">Servers notices</h1>
<h1 class="m-0 text-2xl">Server notices</h1>
<ButtonStyled color="brand">
<button @click="openNewNoticeModal">
<PlusIcon />

Some files were not shown because too many files have changed in this diff Show More