You've already forked AstralRinth
forked from didirus/AstralRinth
Merge tag 'v0.10.24' into beta
This commit is contained in:
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -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"
|
||||
|
||||
@@ -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
745
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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" }
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,7 +29,7 @@ export default new createRouter({
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/servers/manage/',
|
||||
path: '/hosting/manage/',
|
||||
name: 'Servers',
|
||||
component: ServersManagePageIndex,
|
||||
meta: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">•</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">•</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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
1138
apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue
Normal file
1138
apps/frontend/src/components/ui/moderation/ModerationTechRevCard.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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) }}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'])
|
||||
|
||||
|
||||
@@ -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'"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
227
apps/frontend/src/components/ui/thread/ThreadView.vue
Normal file
227
apps/frontend/src/components/ui/thread/ThreadView.vue
Normal 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>
|
||||
@@ -40,6 +40,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
newProjectGeneralSettings: false,
|
||||
newProjectEnvironmentSettings: true,
|
||||
hideRussiaCensorshipBanner: false,
|
||||
serverDiscovery: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
6
apps/frontend/src/middleware/hosting-redirect.global.ts
Normal file
6
apps/frontend/src/middleware/hosting-redirect.global.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
13
apps/frontend/src/middleware/search-redirect.global.ts
Normal file
13
apps/frontend/src/middleware/search-redirect.global.ts
Normal 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 })
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
>
|
||||
</div>
|
||||
<a
|
||||
:href="version.primaryFile.url"
|
||||
:href="version.primaryFile?.url"
|
||||
class="iconified-button download"
|
||||
:title="`Download ${version.name}`"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
810
apps/frontend/src/pages/[type]/[id]/settings/gallery.vue
Normal file
810
apps/frontend/src/pages/[type]/[id]/settings/gallery.vue
Normal 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>
|
||||
414
apps/frontend/src/pages/[type]/[id]/settings/versions.vue
Normal file
414
apps/frontend/src/pages/[type]/[id]/settings/versions.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
</script>
|
||||
@@ -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>
|
||||
|
||||
@@ -182,7 +182,7 @@
|
||||
"
|
||||
>
|
||||
<nuxt-link
|
||||
:to="`/servers/manage/${subscription.metadata.id}`"
|
||||
:to="`/hosting/manage/${subscription.metadata.id}`"
|
||||
target="_blank"
|
||||
class="w-fit"
|
||||
>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user