You've already forked AstralRinth
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75754230a9 | |||
| e9bc01b0c7 | |||
| 572800d9ca | |||
| 45519f5dbb | |||
| 3843ed6690 | |||
| 53ec2c5306 | |||
| cace1a54cd | |||
| 803c17de31 | |||
| 537eadef0c | |||
| 39f2b0ecb6 | |||
| 1e9e13aebb | |||
| 67835b04a8 | |||
| 3f93041ca2 | |||
| 0663b8adb0 | |||
| 1f48f5b5af | |||
| 0268600044 | |||
| 8fb38ba0f2 | |||
| 85c65e697d | |||
| 563997e060 | |||
| 2d5568ecec | |||
| a64c4201bb | |||
| 51d5ed771c | |||
| 539132a527 | |||
| 9958600121 | |||
| 9ad01723a2 | |||
| 8448bacae7 | |||
| c21e98a2a8 | |||
| 5bbc3872f3 | |||
| 8d894541e8 | |||
| dc16a65b62 | |||
| 514c6f6e34 | |||
| 609e3896eb | |||
| fd08dff1e7 | |||
| 6425ab8c57 | |||
| e123e51c66 | |||
| 21fad12a21 | |||
| 924a77eb3f | |||
| 7aaf99a0c8 | |||
| 91accd5578 | |||
| 147f19f11e | |||
| 73ff6df73c | |||
| 0de780b7c9 | |||
| f49f889536 | |||
| b3f598aa1d | |||
| cd1b5dcd3d | |||
| 79b7d269b0 | |||
| 40ac726930 | |||
| ddcc14d99f | |||
| 3dd2de5f18 | |||
| 0a8f489234 | |||
| 1d64b2e22a | |||
| 251e89fe5a | |||
| 4fbbc2b1cf | |||
| d5b7ac3542 | |||
| fec395a4cf | |||
| 16c0dadc4a | |||
| 779092c0b7 | |||
| 9aa06fbc26 | |||
| cfd2977c21 | |||
| 27fc0796a4 | |||
| b1438bd460 | |||
| 267e0cb636 | |||
| d471ef6763 | |||
| cea5cfa4ab | |||
| 56356e8260 | |||
| 41e4086973 | |||
| 0f1f27d450 | |||
| a558064f9d | |||
| c421249767 | |||
| 8eff939039 | |||
| e3444a3456 | |||
| 16a6f7b352 | |||
| 79c2633011 | |||
| 783aaa6553 | |||
| 60e0953616 | |||
| f7c86f9fc9 |
Vendored
+1
-1
@@ -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
|
||||
|
||||
|
||||
Generated
+369
-376
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'
|
||||
@@ -72,7 +73,7 @@ import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
||||
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { check_reachable } from '@/helpers/auth.js'
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { command_listener, warning_listener, info_listener } from '@/helpers/events.js'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||
import { list } from '@/helpers/profile.js'
|
||||
@@ -94,7 +95,7 @@ import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { AppNotificationManager } from './providers/app-notifications'
|
||||
|
||||
// [AR] Imports
|
||||
// This code is modified by AstralRinth
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { getRemote, updateState } from '@/helpers/update.js'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -159,9 +164,10 @@ const authUnreachable = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
// This code is modified by AstralRinth
|
||||
onMounted(async () => {
|
||||
await useCheckDisableMouseover()
|
||||
await getRemote(false) // [AR] Check for updates
|
||||
await getRemote(false)
|
||||
|
||||
document.querySelector('body').addEventListener('click', handleClick)
|
||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||
@@ -205,8 +211,8 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
// This code is modified by AstralRinth
|
||||
async function setupApp() {
|
||||
// [AR] Patched
|
||||
const settings = await get()
|
||||
settings.personalized_ads = false
|
||||
settings.telemetry = false
|
||||
@@ -253,7 +259,7 @@ async function setupApp() {
|
||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||
})
|
||||
|
||||
// [AR] Patched
|
||||
// This code is modified by AstralRinth
|
||||
if (!telemetry) {
|
||||
console.info("[AR] • Telemetry disabled by default (Hard patched).")
|
||||
optOutAnalytics()
|
||||
@@ -281,6 +287,15 @@ async function setupApp() {
|
||||
}),
|
||||
)
|
||||
|
||||
// This code is modified by AstralRinth
|
||||
await info_listener((e) =>
|
||||
addNotification({
|
||||
title: 'Info',
|
||||
text: e.message,
|
||||
type: 'info',
|
||||
}),
|
||||
)
|
||||
|
||||
useFetch(
|
||||
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
'criticalAnnouncements',
|
||||
@@ -647,7 +662,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;
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
<template>
|
||||
<div v-if="mode !== 'isolated'" ref="button"
|
||||
<div
|
||||
v-if="mode !== 'isolated'"
|
||||
ref="button"
|
||||
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
|
||||
:class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
|
||||
<Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
" />
|
||||
:class="{ expanded: mode === 'expanded' }"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<Avatar
|
||||
size="36px"
|
||||
:src="
|
||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
<div class="flex flex-col w-full">
|
||||
<span>
|
||||
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
|
||||
<component
|
||||
:is="getAccountType(selectedAccount)"
|
||||
v-if="selectedAccount"
|
||||
class="vector-icon"
|
||||
/>
|
||||
{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}
|
||||
</span>
|
||||
<span class="text-secondary text-xs">Minecraft account</span>
|
||||
@@ -14,32 +26,46 @@
|
||||
<DropdownIcon class="w-5 h-5 shrink-0" />
|
||||
</div>
|
||||
<transition name="fade">
|
||||
<Card v-if="showCard || mode === 'isolated'" ref="card" class="account-card"
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }">
|
||||
<Card
|
||||
v-if="showCard || mode === 'isolated'"
|
||||
ref="card"
|
||||
class="account-card"
|
||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||
>
|
||||
<div v-if="selectedAccount" class="selected account">
|
||||
<Avatar size="xs" :src="avatarUrl" />
|
||||
<div>
|
||||
<h4>
|
||||
<component :is="getAccountType(selectedAccount)" class="vector-icon" /> {{
|
||||
selectedAccount.profile.name }}
|
||||
<component :is="getAccountType(selectedAccount)" class="vector-icon" />
|
||||
{{ selectedAccount.profile.name }}
|
||||
</h4>
|
||||
<p>Selected</p>
|
||||
</div>
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
|
||||
<Button
|
||||
v-tooltip="'Log out'"
|
||||
icon-only
|
||||
color="raised"
|
||||
@click="logout(selectedAccount.profile.id)"
|
||||
>
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="login-section account">
|
||||
<h4>Not signed in</h4>
|
||||
<Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
|
||||
<Button
|
||||
v-tooltip="'Log via Microsoft'"
|
||||
:disabled="microsoftLoginDisabled"
|
||||
icon-only
|
||||
@click="login()"
|
||||
>
|
||||
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||
<PirateIcon />
|
||||
<OfflineIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
||||
<ElyByIcon v-if="!elybyLoginDisabled" />
|
||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElyByLoginModal()">
|
||||
<ElyByIcon v-if="!elyByLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -63,23 +89,37 @@
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||
<PirateIcon />
|
||||
<OfflineIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
||||
<ElyByIcon v-if="!elybyLoginDisabled" />
|
||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElyByLoginModal()">
|
||||
<ElyByIcon v-if="!elyByLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</transition>
|
||||
<ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
|
||||
<ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
|
||||
header="Ely.by requested 2FA code for authentication">
|
||||
<ModalWrapper ref="addElyByModal" class="modal" header="Authenticate with Ely.by">
|
||||
<ModalWrapper
|
||||
ref="requestElyByTwoFactorCodeModal"
|
||||
class="modal"
|
||||
header="Ely.by requested 2FA code for authentication"
|
||||
>
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your 2FA code</label>
|
||||
<input v-model="elybyTwoFactorCode" type="text" placeholder="Your 2FA code here..." class="input" />
|
||||
<input
|
||||
v-model="elyByTwoFactorCode"
|
||||
type="text"
|
||||
placeholder="Your 2FA code here..."
|
||||
class="input"
|
||||
/>
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
|
||||
<Button
|
||||
:disabled="elyByLoginDisabled"
|
||||
icon-only
|
||||
color="primary"
|
||||
class="continue-button"
|
||||
@click="addElyByProfile()"
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
@@ -87,11 +127,27 @@
|
||||
</ModalWrapper>
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your player name or email (preferred)</label>
|
||||
<input v-model="elybyLogin" type="text" placeholder="Your player name or email here..." class="input" />
|
||||
<input
|
||||
v-model="elyByLogin"
|
||||
type="text"
|
||||
placeholder="Your player name or email here..."
|
||||
class="input"
|
||||
/>
|
||||
<label class="label">Enter your password</label>
|
||||
<input v-model="elybyPassword" type="password" placeholder="Your password here..." class="input" />
|
||||
<input
|
||||
v-model="elyByPassword"
|
||||
type="password"
|
||||
placeholder="Your password here..."
|
||||
class="input"
|
||||
/>
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
|
||||
<Button
|
||||
:disabled="elyByLoginDisabled"
|
||||
icon-only
|
||||
color="primary"
|
||||
class="continue-button"
|
||||
@click="addElyByProfile()"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
@@ -100,7 +156,12 @@
|
||||
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your player name</label>
|
||||
<input v-model="offlinePlayerName" type="text" placeholder="Your player name here..." class="input" />
|
||||
<input
|
||||
v-model="offlinePlayerName"
|
||||
type="text"
|
||||
placeholder="Your player name here..."
|
||||
class="input"
|
||||
/>
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
|
||||
Login
|
||||
@@ -108,21 +169,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="authenticationElybyErrorModal" class="modal"
|
||||
header="Error while proceeding authentication event with Ely.by">
|
||||
<ModalWrapper
|
||||
ref="authenticationElyByErrorModal"
|
||||
class="modal"
|
||||
header="Error while proceeding authentication event with Ely.by"
|
||||
>
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while logging in.
|
||||
</label>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
||||
<Button color="primary" class="retry-button" @click="retryAddElyByProfile">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="inputElybyErrorModal" class="modal" header="Error while proceeding input event with Ely.by">
|
||||
<ModalWrapper
|
||||
ref="inputElyByErrorModal"
|
||||
class="modal"
|
||||
header="Error while proceeding input event with Ely.by"
|
||||
>
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while adding the Ely.by account. Please follow the instructions below.
|
||||
@@ -134,13 +202,17 @@
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
||||
<Button color="primary" class="retry-button" @click="retryAddElyByProfile">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="inputErrorModal" class="modal" header="Error while proceeding input event with offline account">
|
||||
<ModalWrapper
|
||||
ref="inputOfflineErrorModal"
|
||||
class="modal"
|
||||
header="Error while proceeding input event with offline account"
|
||||
>
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while adding the offline account. Please follow the instructions below.
|
||||
@@ -149,9 +221,10 @@
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li>Check that you have entered the correct player name.</li>
|
||||
<li>
|
||||
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
|
||||
{{ maxOfflinePlayerNameLength }} characters.
|
||||
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more
|
||||
than {{ maxOfflinePlayerNameLength }} characters.
|
||||
</li>
|
||||
<li>Make sure your name meets the format requirement `{{ nameExp }}`</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
@@ -161,7 +234,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
|
||||
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Unexpected error occurred">
|
||||
<div class="modal-body">
|
||||
<label class="label">An unexpected error has occurred. Please try again later.</label>
|
||||
</div>
|
||||
@@ -169,35 +242,32 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
DropdownIcon,
|
||||
TrashIcon,
|
||||
PirateIcon as Offline,
|
||||
MicrosoftIcon as License,
|
||||
ElyByIcon as Elyby,
|
||||
MicrosoftIcon,
|
||||
PirateIcon,
|
||||
ElyByIcon,
|
||||
SpinnerIcon
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import {
|
||||
elyby_auth_authenticate,
|
||||
elyby_login,
|
||||
get_default_user,
|
||||
login as login_flow,
|
||||
offline_login,
|
||||
users,
|
||||
remove_user,
|
||||
set_default_user,
|
||||
login as login_flow,
|
||||
get_default_user,
|
||||
users,
|
||||
} from '@/helpers/auth'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import {
|
||||
DropdownIcon,
|
||||
ElyByIcon,
|
||||
MicrosoftIcon,
|
||||
OfflineIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, Card, injectNotificationManager } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
@@ -213,82 +283,88 @@ const emit = defineEmits(['change'])
|
||||
|
||||
const accounts = ref({})
|
||||
const microsoftLoginDisabled = ref(false)
|
||||
const elybyLoginDisabled = ref(false)
|
||||
const elyByLoginDisabled = ref(false)
|
||||
const defaultUser = ref()
|
||||
|
||||
// [AR] • Feature
|
||||
const clientToken = "astralrinth"
|
||||
// This code is modified by AstralRinth
|
||||
const clientToken = 'astralrinth'
|
||||
const addOfflineModal = ref(null)
|
||||
const addElybyModal = ref(null)
|
||||
const requestElybyTwoFactorCodeModal = ref(null)
|
||||
const authenticationElybyErrorModal = ref(null)
|
||||
const inputElybyErrorModal = ref(null)
|
||||
const inputErrorModal = ref(null)
|
||||
const exceptionErrorModal = ref(null)
|
||||
const addElyByModal = ref(null)
|
||||
const requestElyByTwoFactorCodeModal = ref(null)
|
||||
const authenticationElyByErrorModal = ref(null)
|
||||
const inputElyByErrorModal = ref(null)
|
||||
const inputOfflineErrorModal = ref(null)
|
||||
const unexpectedErrorModal = ref(null)
|
||||
const offlinePlayerName = ref('')
|
||||
const elybyLogin = ref('')
|
||||
const elybyPassword = ref('')
|
||||
const elybyTwoFactorCode = ref('')
|
||||
const minOfflinePlayerNameLength = 2
|
||||
const elyByLogin = ref('')
|
||||
const elyByPassword = ref('')
|
||||
const elyByTwoFactorCode = ref('')
|
||||
const minOfflinePlayerNameLength = 3
|
||||
const maxOfflinePlayerNameLength = 20
|
||||
const nameExp = 'a-zA-Z0-9_'
|
||||
const nameRegex = new RegExp(`^[${nameExp}]+$`)
|
||||
|
||||
// [AR] • Feature
|
||||
// This code is modified by AstralRinth
|
||||
function getAccountType(account) {
|
||||
switch (account.account_type) {
|
||||
case 'microsoft':
|
||||
return License
|
||||
return MicrosoftIcon
|
||||
case 'pirate':
|
||||
return Offline
|
||||
return OfflineIcon
|
||||
case 'elyby':
|
||||
return Elyby
|
||||
return ElyByIcon
|
||||
}
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
// This code is modified by AstralRinth
|
||||
function showOfflineLoginModal() {
|
||||
addOfflineModal.value?.show()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function showElybyLoginModal() {
|
||||
addElybyModal.value?.show()
|
||||
// This code is modified by AstralRinth
|
||||
function showElyByLoginModal() {
|
||||
addElyByModal.value?.show()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
// This code is modified by AstralRinth
|
||||
function retryAddOfflineProfile() {
|
||||
inputErrorModal.value?.hide()
|
||||
inputOfflineErrorModal.value?.hide()
|
||||
clearOfflineFields()
|
||||
showOfflineLoginModal()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function retryAddElybyProfile() {
|
||||
authenticationElybyErrorModal.value?.hide()
|
||||
inputElybyErrorModal.value?.hide()
|
||||
clearElybyFields()
|
||||
showElybyLoginModal()
|
||||
// This code is modified by AstralRinth
|
||||
function retryAddElyByProfile() {
|
||||
authenticationElyByErrorModal.value?.hide()
|
||||
inputElyByErrorModal.value?.hide()
|
||||
elyByLoginDisabled.value = false
|
||||
clearElyByFields()
|
||||
showElyByLoginModal()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function clearElybyFields() {
|
||||
elybyLogin.value = ''
|
||||
elybyPassword.value = ''
|
||||
elybyTwoFactorCode.value = ''
|
||||
// This code is modified by AstralRinth
|
||||
function clearElyByFields() {
|
||||
elyByLogin.value = ''
|
||||
elyByPassword.value = ''
|
||||
elyByTwoFactorCode.value = ''
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
// This code is modified by AstralRinth
|
||||
function clearOfflineFields() {
|
||||
offlinePlayerName.value = ''
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
// This code is modified by AstralRinth
|
||||
async function addOfflineProfile() {
|
||||
const name = offlinePlayerName.value.trim()
|
||||
const isValidName = name.length >= minOfflinePlayerNameLength && name.length <= maxOfflinePlayerNameLength
|
||||
const isValidName =
|
||||
nameRegex.test(name) &&
|
||||
name.length >= minOfflinePlayerNameLength &&
|
||||
name.length <= maxOfflinePlayerNameLength
|
||||
|
||||
if (!isValidName) {
|
||||
addOfflineModal.value?.hide()
|
||||
inputErrorModal.value?.show()
|
||||
inputOfflineErrorModal.value?.show()
|
||||
clearOfflineFields()
|
||||
return
|
||||
}
|
||||
@@ -302,39 +378,36 @@ async function addOfflineProfile() {
|
||||
await setAccount(result)
|
||||
await refreshValues()
|
||||
} else {
|
||||
exceptionErrorModal.value?.show()
|
||||
unexpectedErrorModal.value?.show()
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
exceptionErrorModal.value?.show()
|
||||
unexpectedErrorModal.value?.show()
|
||||
} finally {
|
||||
clearOfflineFields()
|
||||
}
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
async function addElybyProfile() {
|
||||
if (!elybyLogin.value || !elybyPassword.value) {
|
||||
addElybyModal.value?.hide()
|
||||
inputElybyErrorModal.value?.show()
|
||||
clearElybyFields()
|
||||
// This code is modified by AstralRinth
|
||||
async function addElyByProfile() {
|
||||
elyByLoginDisabled.value = true
|
||||
if (!elyByLogin.value || !elyByPassword.value) {
|
||||
addElyByModal.value?.hide()
|
||||
inputElyByErrorModal.value?.show()
|
||||
clearElyByFields()
|
||||
return
|
||||
}
|
||||
elybyLoginDisabled.value = true
|
||||
|
||||
const login = elybyLogin.value.trim()
|
||||
let password = elybyPassword.value.trim()
|
||||
const twoFactorCode = elybyTwoFactorCode.value.trim()
|
||||
// Parse ely.by credential fields
|
||||
const login = elyByLogin.value.trim()
|
||||
let password = elyByPassword.value.trim()
|
||||
const twoFactorCode = elyByTwoFactorCode.value.trim()
|
||||
if (password && twoFactorCode) {
|
||||
password = `${password}:${twoFactorCode}`
|
||||
}
|
||||
|
||||
try {
|
||||
const raw_result = await elyby_auth_authenticate(
|
||||
login,
|
||||
password,
|
||||
clientToken
|
||||
)
|
||||
const raw_result = await elyby_auth_authenticate(login, password, clientToken)
|
||||
|
||||
const json_data = JSON.parse(raw_result)
|
||||
|
||||
@@ -346,13 +419,13 @@ async function addElybyProfile() {
|
||||
json_data.error === 'ForbiddenOperationException' &&
|
||||
json_data.errorMessage?.includes('two factor')
|
||||
) {
|
||||
requestElybyTwoFactorCodeModal.value?.show()
|
||||
requestElyByTwoFactorCodeModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
addElybyModal.value?.hide()
|
||||
requestElybyTwoFactorCodeModal.value?.hide()
|
||||
authenticationElybyErrorModal.value?.show()
|
||||
addElyByModal.value?.hide()
|
||||
requestElyByTwoFactorCodeModal.value?.hide()
|
||||
authenticationElyByErrorModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -362,22 +435,22 @@ async function addElybyProfile() {
|
||||
|
||||
const result = await elyby_login(selectedProfileId, selectedProfileName, accessToken)
|
||||
|
||||
addElybyModal.value?.hide()
|
||||
requestElybyTwoFactorCodeModal.value?.hide()
|
||||
addElyByModal.value?.hide()
|
||||
requestElyByTwoFactorCodeModal.value?.hide()
|
||||
|
||||
clearElybyFields()
|
||||
clearElyByFields()
|
||||
|
||||
await setAccount(result)
|
||||
await refreshValues()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
exceptionErrorModal.value?.show()
|
||||
unexpectedErrorModal.value?.show()
|
||||
} finally {
|
||||
elybyLoginDisabled.value = false
|
||||
elyByLoginDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
// This code is modified by AstralRinth
|
||||
function convertRawStringToUUIDv4(rawId) {
|
||||
if (rawId.length !== 32) {
|
||||
console.warn('Invalid UUID string:', rawId)
|
||||
@@ -543,7 +616,6 @@ onUnmounted(() => {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.vector-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { install } from '@/helpers/profile.js'
|
||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
// [AR] Imports
|
||||
// This code is modified by AstralRinth
|
||||
import { applyMigrationFix } from '@/helpers/utils.js'
|
||||
import { restartApp } from '@/helpers/utils.js'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -26,7 +26,7 @@ import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
|
||||
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
|
||||
// [AR] Imports
|
||||
// This code is modified by AstralRinth
|
||||
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
||||
|
||||
const updateModalView = ref(null)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -17,7 +17,7 @@ export async function offline_login(name) {
|
||||
return await invoke('plugin:auth|offline_login', { name: name })
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
// This code is modified by AstralRinth
|
||||
export async function elyby_login(uuid, login, accessToken) {
|
||||
return await invoke('plugin:auth|elyby_login', {
|
||||
uuid,
|
||||
@@ -26,7 +26,7 @@ export async function elyby_login(uuid, login, accessToken) {
|
||||
})
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
// This code is modified by AstralRinth
|
||||
export async function elyby_auth_authenticate(login, password, clientToken) {
|
||||
return await invoke('plugin:auth|elyby_auth_authenticate', {
|
||||
login,
|
||||
|
||||
@@ -97,3 +97,8 @@ export async function warning_listener(callback) {
|
||||
export async function friend_listener(callback) {
|
||||
return await listen('friend', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
// This code is modified by AstralRinth
|
||||
export async function info_listener(callback) {
|
||||
return await listen('info', (event) => callback(event.payload))
|
||||
}
|
||||
|
||||
@@ -27,18 +27,18 @@ export async function getOS() {
|
||||
return await invoke('plugin:utils|get_os')
|
||||
}
|
||||
|
||||
// [AR] Feature. Updater
|
||||
// This code is modified by AstralRinth
|
||||
export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
|
||||
console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
|
||||
return await invoke('plugin:utils|init_update_launcher', { downloadUrl, filename, osType, autoUpdateSupported })
|
||||
}
|
||||
|
||||
// [AR] Migration. Patch
|
||||
// This code is modified by AstralRinth
|
||||
export async function applyMigrationFix(eol) {
|
||||
return await invoke('plugin:utils|apply_migration_fix', { eol })
|
||||
}
|
||||
|
||||
// [AR] Feature. Ely.by
|
||||
// This code is modified by AstralRinth
|
||||
export async function initAuthlibPatching(minecraftVersion, isMojang) {
|
||||
return await invoke('plugin:utils|init_authlib_patching', { minecraftVersion, isMojang })
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -31,7 +31,7 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// [AR] Feature. Ely.by
|
||||
// This code is modified by AstralRinth
|
||||
#[tauri::command]
|
||||
pub async fn init_authlib_patching(
|
||||
minecraft_version: &str,
|
||||
@@ -42,14 +42,14 @@ pub async fn init_authlib_patching(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// [AR] Migration. Patch
|
||||
// This code is modified by AstralRinth
|
||||
#[tauri::command]
|
||||
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
|
||||
let result = utils::apply_migration_fix(eol).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// [AR] Feature. Updater
|
||||
// This code is modified by AstralRinth
|
||||
#[tauri::command]
|
||||
pub async fn init_update_launcher(
|
||||
download_url: &str,
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "AstralRinth App",
|
||||
"version": "0.10.2101",
|
||||
"version": "0.10.2401",
|
||||
"mainBinaryName": "AstralRinth App",
|
||||
"identifier": "AstralRinthApp",
|
||||
"plugins": {
|
||||
|
||||
@@ -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>
|
||||
+56
@@ -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>
|
||||
+86
@@ -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>
|
||||
+208
@@ -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>
|
||||
+65
@@ -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>
|
||||
+54
@@ -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>
|
||||
+283
@@ -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()
|
||||
|
||||
+7
-2
@@ -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
|
||||
|
||||
+444
-26
@@ -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>
|
||||
|
||||
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>
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user