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",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"[vue]": {
|
"[vue]": {
|
||||||
"editor.defaultFormatter": "rvest.vs-code-prettier-eslint"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"[typescript]": {
|
"[typescript]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ Use `docker exec labrinth-clickhouse clickhouse-client` to access the Clickhouse
|
|||||||
|
|
||||||
### Postgres
|
### 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
|
# Guidelines
|
||||||
|
|
||||||
|
|||||||
Generated
+369
-376
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ members = [
|
|||||||
"packages/app-lib",
|
"packages/app-lib",
|
||||||
"packages/ariadne",
|
"packages/ariadne",
|
||||||
"packages/daedalus",
|
"packages/daedalus",
|
||||||
|
"packages/modrinth-log",
|
||||||
"packages/modrinth-maxmind",
|
"packages/modrinth-maxmind",
|
||||||
"packages/modrinth-util",
|
"packages/modrinth-util",
|
||||||
"packages/path-util",
|
"packages/path-util",
|
||||||
@@ -107,6 +108,7 @@ lettre = { version = "0.11.19", default-features = false, features = [
|
|||||||
] }
|
] }
|
||||||
maxminddb = "0.26.0"
|
maxminddb = "0.26.0"
|
||||||
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
meilisearch-sdk = { version = "0.30.0", default-features = false }
|
||||||
|
modrinth-log = { path = "packages/modrinth-log" }
|
||||||
modrinth-maxmind = { path = "packages/modrinth-maxmind" }
|
modrinth-maxmind = { path = "packages/modrinth-maxmind" }
|
||||||
modrinth-util = { path = "packages/modrinth-util" }
|
modrinth-util = { path = "packages/modrinth-util" }
|
||||||
muralpay = { path = "packages/muralpay" }
|
muralpay = { path = "packages/muralpay" }
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.5.4",
|
||||||
"vite": "^5.4.6",
|
"vite": "^5.4.6",
|
||||||
|
"vue-component-type-helpers": "^3.1.8",
|
||||||
"vue-tsc": "^2.1.6"
|
"vue-tsc": "^2.1.6"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@9.4.0",
|
"packageManager": "pnpm@9.4.0",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client'
|
import { AuthFeature, PanelVersionFeature, TauriModrinthClient } from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
ArrowBigUpDashIcon,
|
ArrowBigUpDashIcon,
|
||||||
ChangeSkinIcon,
|
ChangeSkinIcon,
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
ProgressSpinner,
|
ProgressSpinner,
|
||||||
provideModrinthClient,
|
provideModrinthClient,
|
||||||
provideNotificationManager,
|
provideNotificationManager,
|
||||||
|
providePageContext,
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { renderString } from '@modrinth/utils'
|
import { renderString } from '@modrinth/utils'
|
||||||
@@ -72,7 +73,7 @@ import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
|
|||||||
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||||
import { check_reachable } from '@/helpers/auth.js'
|
import { check_reachable } from '@/helpers/auth.js'
|
||||||
import { get_user } from '@/helpers/cache.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 { useFetch } from '@/helpers/fetch.js'
|
||||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.ts'
|
||||||
import { list } from '@/helpers/profile.js'
|
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 { get_available_capes, get_available_skins } from './helpers/skins'
|
||||||
import { AppNotificationManager } from './providers/app-notifications'
|
import { AppNotificationManager } from './providers/app-notifications'
|
||||||
|
|
||||||
// [AR] Imports
|
// This code is modified by AstralRinth
|
||||||
import { get, set } from '@/helpers/settings.ts'
|
import { get, set } from '@/helpers/settings.ts'
|
||||||
import { getRemote, updateState } from '@/helpers/update.js'
|
import { getRemote, updateState } from '@/helpers/update.js'
|
||||||
|
|
||||||
@@ -110,10 +111,14 @@ const tauriApiClient = new TauriModrinthClient({
|
|||||||
new AuthFeature({
|
new AuthFeature({
|
||||||
token: async () => (await getCreds()).session,
|
token: async () => (await getCreds()).session,
|
||||||
}),
|
}),
|
||||||
|
new PanelVersionFeature(),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
provideModrinthClient(tauriApiClient)
|
provideModrinthClient(tauriApiClient)
|
||||||
|
providePageContext({
|
||||||
|
hierarchicalSidebarAvailable: ref(true),
|
||||||
|
showAds: ref(false),
|
||||||
|
})
|
||||||
const news = ref([])
|
const news = ref([])
|
||||||
const availableSurvey = ref(false)
|
const availableSurvey = ref(false)
|
||||||
|
|
||||||
@@ -159,9 +164,10 @@ const authUnreachable = computed(() => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// This code is modified by AstralRinth
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await useCheckDisableMouseover()
|
await useCheckDisableMouseover()
|
||||||
await getRemote(false) // [AR] Check for updates
|
await getRemote(false)
|
||||||
|
|
||||||
document.querySelector('body').addEventListener('click', handleClick)
|
document.querySelector('body').addEventListener('click', handleClick)
|
||||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||||
@@ -205,8 +211,8 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// This code is modified by AstralRinth
|
||||||
async function setupApp() {
|
async function setupApp() {
|
||||||
// [AR] Patched
|
|
||||||
const settings = await get()
|
const settings = await get()
|
||||||
settings.personalized_ads = false
|
settings.personalized_ads = false
|
||||||
settings.telemetry = false
|
settings.telemetry = false
|
||||||
@@ -253,7 +259,7 @@ async function setupApp() {
|
|||||||
isMaximized.value = await getCurrentWindow().isMaximized()
|
isMaximized.value = await getCurrentWindow().isMaximized()
|
||||||
})
|
})
|
||||||
|
|
||||||
// [AR] Patched
|
// This code is modified by AstralRinth
|
||||||
if (!telemetry) {
|
if (!telemetry) {
|
||||||
console.info("[AR] • Telemetry disabled by default (Hard patched).")
|
console.info("[AR] • Telemetry disabled by default (Hard patched).")
|
||||||
optOutAnalytics()
|
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(
|
useFetch(
|
||||||
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
`https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||||
'criticalAnnouncements',
|
'criticalAnnouncements',
|
||||||
@@ -647,7 +662,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload) // [AR Note] If delete this
|
|||||||
<NavButton
|
<NavButton
|
||||||
v-if="themeStore.featureFlags.servers_in_app"
|
v-if="themeStore.featureFlags.servers_in_app"
|
||||||
v-tooltip.right="'Servers'"
|
v-tooltip.right="'Servers'"
|
||||||
to="/servers/manage"
|
to="/hosting/manage"
|
||||||
>
|
>
|
||||||
<ServerIcon />
|
<ServerIcon />
|
||||||
</NavButton>
|
</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 components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'bundled-minecraft-font-mrapp';
|
font-family: 'bundled-minecraft-font-mrapp';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|||||||
@@ -1,12 +1,24 @@
|
|||||||
<template>
|
<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="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
|
||||||
:class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
|
:class="{ expanded: mode === 'expanded' }"
|
||||||
<Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
@click="toggleMenu"
|
||||||
" />
|
>
|
||||||
|
<Avatar
|
||||||
|
size="36px"
|
||||||
|
:src="
|
||||||
|
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||||
|
"
|
||||||
|
/>
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<span>
|
<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' }}
|
{{ selectedAccount ? selectedAccount.profile.name : 'Select account' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="text-secondary text-xs">Minecraft account</span>
|
<span class="text-secondary text-xs">Minecraft account</span>
|
||||||
@@ -14,32 +26,46 @@
|
|||||||
<DropdownIcon class="w-5 h-5 shrink-0" />
|
<DropdownIcon class="w-5 h-5 shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
<transition name="fade">
|
<transition name="fade">
|
||||||
<Card v-if="showCard || mode === 'isolated'" ref="card" class="account-card"
|
<Card
|
||||||
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }">
|
v-if="showCard || mode === 'isolated'"
|
||||||
|
ref="card"
|
||||||
|
class="account-card"
|
||||||
|
:class="{ expanded: mode === 'expanded', isolated: mode === 'isolated' }"
|
||||||
|
>
|
||||||
<div v-if="selectedAccount" class="selected account">
|
<div v-if="selectedAccount" class="selected account">
|
||||||
<Avatar size="xs" :src="avatarUrl" />
|
<Avatar size="xs" :src="avatarUrl" />
|
||||||
<div>
|
<div>
|
||||||
<h4>
|
<h4>
|
||||||
<component :is="getAccountType(selectedAccount)" class="vector-icon" /> {{
|
<component :is="getAccountType(selectedAccount)" class="vector-icon" />
|
||||||
selectedAccount.profile.name }}
|
{{ selectedAccount.profile.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<p>Selected</p>
|
<p>Selected</p>
|
||||||
</div>
|
</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 />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="login-section account">
|
<div v-else class="login-section account">
|
||||||
<h4>Not signed in</h4>
|
<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" />
|
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||||
<PirateIcon />
|
<OfflineIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElyByLoginModal()">
|
||||||
<ElyByIcon v-if="!elybyLoginDisabled" />
|
<ElyByIcon v-if="!elyByLoginDisabled" />
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -63,23 +89,37 @@
|
|||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||||
<PirateIcon />
|
<OfflineIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElyByLoginModal()">
|
||||||
<ElyByIcon v-if="!elybyLoginDisabled" />
|
<ElyByIcon v-if="!elyByLoginDisabled" />
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</transition>
|
</transition>
|
||||||
<ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
|
<ModalWrapper ref="addElyByModal" class="modal" header="Authenticate with Ely.by">
|
||||||
<ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
|
<ModalWrapper
|
||||||
header="Ely.by requested 2FA code for authentication">
|
ref="requestElyByTwoFactorCodeModal"
|
||||||
|
class="modal"
|
||||||
|
header="Ely.by requested 2FA code for authentication"
|
||||||
|
>
|
||||||
<div class="flex flex-col gap-4 px-6 py-5">
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<label class="label">Enter your 2FA code</label>
|
<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">
|
<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
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -87,11 +127,27 @@
|
|||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
<div class="flex flex-col gap-4 px-6 py-5">
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<label class="label">Enter your player name or email (preferred)</label>
|
<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>
|
<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">
|
<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
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +156,12 @@
|
|||||||
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
|
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
|
||||||
<div class="flex flex-col gap-4 px-6 py-5">
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<label class="label">Enter your player name</label>
|
<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">
|
<div class="mt-6 ml-auto">
|
||||||
<Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
|
<Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
|
||||||
Login
|
Login
|
||||||
@@ -108,21 +169,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
<ModalWrapper ref="authenticationElybyErrorModal" class="modal"
|
<ModalWrapper
|
||||||
header="Error while proceeding authentication event with Ely.by">
|
ref="authenticationElyByErrorModal"
|
||||||
|
class="modal"
|
||||||
|
header="Error while proceeding authentication event with Ely.by"
|
||||||
|
>
|
||||||
<div class="flex flex-col gap-4 px-6 py-5">
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<label class="text-base font-medium text-red-700">
|
<label class="text-base font-medium text-red-700">
|
||||||
An error occurred while logging in.
|
An error occurred while logging in.
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-6 ml-auto">
|
<div class="mt-6 ml-auto">
|
||||||
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
<Button color="primary" class="retry-button" @click="retryAddElyByProfile">
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</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">
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<label class="text-base font-medium text-red-700">
|
<label class="text-base font-medium text-red-700">
|
||||||
An error occurred while adding the Ely.by account. Please follow the instructions below.
|
An error occurred while adding the Ely.by account. Please follow the instructions below.
|
||||||
@@ -134,13 +202,17 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="mt-6 ml-auto">
|
<div class="mt-6 ml-auto">
|
||||||
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
<Button color="primary" class="retry-button" @click="retryAddElyByProfile">
|
||||||
Try again
|
Try again
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</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">
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<label class="text-base font-medium text-red-700">
|
<label class="text-base font-medium text-red-700">
|
||||||
An error occurred while adding the offline account. Please follow the instructions below.
|
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">
|
<ul class="list-disc list-inside text-sm space-y-1">
|
||||||
<li>Check that you have entered the correct player name.</li>
|
<li>Check that you have entered the correct player name.</li>
|
||||||
<li>
|
<li>
|
||||||
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
|
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more
|
||||||
{{ maxOfflinePlayerNameLength }} characters.
|
than {{ maxOfflinePlayerNameLength }} characters.
|
||||||
</li>
|
</li>
|
||||||
|
<li>Make sure your name meets the format requirement `{{ nameExp }}`</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="mt-6 ml-auto">
|
<div class="mt-6 ml-auto">
|
||||||
@@ -161,7 +234,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
<ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
|
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Unexpected error occurred">
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<label class="label">An unexpected error has occurred. Please try again later.</label>
|
<label class="label">An unexpected error has occurred. Please try again later.</label>
|
||||||
</div>
|
</div>
|
||||||
@@ -169,35 +242,32 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
DropdownIcon,
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
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 {
|
import {
|
||||||
elyby_auth_authenticate,
|
elyby_auth_authenticate,
|
||||||
elyby_login,
|
elyby_login,
|
||||||
|
get_default_user,
|
||||||
|
login as login_flow,
|
||||||
offline_login,
|
offline_login,
|
||||||
users,
|
|
||||||
remove_user,
|
remove_user,
|
||||||
set_default_user,
|
set_default_user,
|
||||||
login as login_flow,
|
users,
|
||||||
get_default_user,
|
|
||||||
} from '@/helpers/auth'
|
} from '@/helpers/auth'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
|
||||||
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||||
import { get_available_skins } from '@/helpers/skins'
|
import { get_available_skins } from '@/helpers/skins'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
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()
|
const { handleError } = injectNotificationManager()
|
||||||
|
|
||||||
@@ -213,82 +283,88 @@ const emit = defineEmits(['change'])
|
|||||||
|
|
||||||
const accounts = ref({})
|
const accounts = ref({})
|
||||||
const microsoftLoginDisabled = ref(false)
|
const microsoftLoginDisabled = ref(false)
|
||||||
const elybyLoginDisabled = ref(false)
|
const elyByLoginDisabled = ref(false)
|
||||||
const defaultUser = ref()
|
const defaultUser = ref()
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
const clientToken = "astralrinth"
|
const clientToken = 'astralrinth'
|
||||||
const addOfflineModal = ref(null)
|
const addOfflineModal = ref(null)
|
||||||
const addElybyModal = ref(null)
|
const addElyByModal = ref(null)
|
||||||
const requestElybyTwoFactorCodeModal = ref(null)
|
const requestElyByTwoFactorCodeModal = ref(null)
|
||||||
const authenticationElybyErrorModal = ref(null)
|
const authenticationElyByErrorModal = ref(null)
|
||||||
const inputElybyErrorModal = ref(null)
|
const inputElyByErrorModal = ref(null)
|
||||||
const inputErrorModal = ref(null)
|
const inputOfflineErrorModal = ref(null)
|
||||||
const exceptionErrorModal = ref(null)
|
const unexpectedErrorModal = ref(null)
|
||||||
const offlinePlayerName = ref('')
|
const offlinePlayerName = ref('')
|
||||||
const elybyLogin = ref('')
|
const elyByLogin = ref('')
|
||||||
const elybyPassword = ref('')
|
const elyByPassword = ref('')
|
||||||
const elybyTwoFactorCode = ref('')
|
const elyByTwoFactorCode = ref('')
|
||||||
const minOfflinePlayerNameLength = 2
|
const minOfflinePlayerNameLength = 3
|
||||||
const maxOfflinePlayerNameLength = 20
|
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) {
|
function getAccountType(account) {
|
||||||
switch (account.account_type) {
|
switch (account.account_type) {
|
||||||
case 'microsoft':
|
case 'microsoft':
|
||||||
return License
|
return MicrosoftIcon
|
||||||
case 'pirate':
|
case 'pirate':
|
||||||
return Offline
|
return OfflineIcon
|
||||||
case 'elyby':
|
case 'elyby':
|
||||||
return Elyby
|
return ElyByIcon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
function showOfflineLoginModal() {
|
function showOfflineLoginModal() {
|
||||||
addOfflineModal.value?.show()
|
addOfflineModal.value?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
function showElybyLoginModal() {
|
function showElyByLoginModal() {
|
||||||
addElybyModal.value?.show()
|
addElyByModal.value?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
function retryAddOfflineProfile() {
|
function retryAddOfflineProfile() {
|
||||||
inputErrorModal.value?.hide()
|
inputOfflineErrorModal.value?.hide()
|
||||||
clearOfflineFields()
|
clearOfflineFields()
|
||||||
showOfflineLoginModal()
|
showOfflineLoginModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
function retryAddElybyProfile() {
|
function retryAddElyByProfile() {
|
||||||
authenticationElybyErrorModal.value?.hide()
|
authenticationElyByErrorModal.value?.hide()
|
||||||
inputElybyErrorModal.value?.hide()
|
inputElyByErrorModal.value?.hide()
|
||||||
clearElybyFields()
|
elyByLoginDisabled.value = false
|
||||||
showElybyLoginModal()
|
clearElyByFields()
|
||||||
|
showElyByLoginModal()
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
function clearElybyFields() {
|
function clearElyByFields() {
|
||||||
elybyLogin.value = ''
|
elyByLogin.value = ''
|
||||||
elybyPassword.value = ''
|
elyByPassword.value = ''
|
||||||
elybyTwoFactorCode.value = ''
|
elyByTwoFactorCode.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
function clearOfflineFields() {
|
function clearOfflineFields() {
|
||||||
offlinePlayerName.value = ''
|
offlinePlayerName.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
async function addOfflineProfile() {
|
async function addOfflineProfile() {
|
||||||
const name = offlinePlayerName.value.trim()
|
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) {
|
if (!isValidName) {
|
||||||
addOfflineModal.value?.hide()
|
addOfflineModal.value?.hide()
|
||||||
inputErrorModal.value?.show()
|
inputOfflineErrorModal.value?.show()
|
||||||
clearOfflineFields()
|
clearOfflineFields()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -302,39 +378,36 @@ async function addOfflineProfile() {
|
|||||||
await setAccount(result)
|
await setAccount(result)
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
} else {
|
} else {
|
||||||
exceptionErrorModal.value?.show()
|
unexpectedErrorModal.value?.show()
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
exceptionErrorModal.value?.show()
|
unexpectedErrorModal.value?.show()
|
||||||
} finally {
|
} finally {
|
||||||
clearOfflineFields()
|
clearOfflineFields()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
async function addElybyProfile() {
|
async function addElyByProfile() {
|
||||||
if (!elybyLogin.value || !elybyPassword.value) {
|
elyByLoginDisabled.value = true
|
||||||
addElybyModal.value?.hide()
|
if (!elyByLogin.value || !elyByPassword.value) {
|
||||||
inputElybyErrorModal.value?.show()
|
addElyByModal.value?.hide()
|
||||||
clearElybyFields()
|
inputElyByErrorModal.value?.show()
|
||||||
|
clearElyByFields()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
elybyLoginDisabled.value = true
|
|
||||||
|
|
||||||
const login = elybyLogin.value.trim()
|
// Parse ely.by credential fields
|
||||||
let password = elybyPassword.value.trim()
|
const login = elyByLogin.value.trim()
|
||||||
const twoFactorCode = elybyTwoFactorCode.value.trim()
|
let password = elyByPassword.value.trim()
|
||||||
|
const twoFactorCode = elyByTwoFactorCode.value.trim()
|
||||||
if (password && twoFactorCode) {
|
if (password && twoFactorCode) {
|
||||||
password = `${password}:${twoFactorCode}`
|
password = `${password}:${twoFactorCode}`
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const raw_result = await elyby_auth_authenticate(
|
const raw_result = await elyby_auth_authenticate(login, password, clientToken)
|
||||||
login,
|
|
||||||
password,
|
|
||||||
clientToken
|
|
||||||
)
|
|
||||||
|
|
||||||
const json_data = JSON.parse(raw_result)
|
const json_data = JSON.parse(raw_result)
|
||||||
|
|
||||||
@@ -346,13 +419,13 @@ async function addElybyProfile() {
|
|||||||
json_data.error === 'ForbiddenOperationException' &&
|
json_data.error === 'ForbiddenOperationException' &&
|
||||||
json_data.errorMessage?.includes('two factor')
|
json_data.errorMessage?.includes('two factor')
|
||||||
) {
|
) {
|
||||||
requestElybyTwoFactorCodeModal.value?.show()
|
requestElyByTwoFactorCodeModal.value?.show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
addElybyModal.value?.hide()
|
addElyByModal.value?.hide()
|
||||||
requestElybyTwoFactorCodeModal.value?.hide()
|
requestElyByTwoFactorCodeModal.value?.hide()
|
||||||
authenticationElybyErrorModal.value?.show()
|
authenticationElyByErrorModal.value?.show()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,22 +435,22 @@ async function addElybyProfile() {
|
|||||||
|
|
||||||
const result = await elyby_login(selectedProfileId, selectedProfileName, accessToken)
|
const result = await elyby_login(selectedProfileId, selectedProfileName, accessToken)
|
||||||
|
|
||||||
addElybyModal.value?.hide()
|
addElyByModal.value?.hide()
|
||||||
requestElybyTwoFactorCodeModal.value?.hide()
|
requestElyByTwoFactorCodeModal.value?.hide()
|
||||||
|
|
||||||
clearElybyFields()
|
clearElyByFields()
|
||||||
|
|
||||||
await setAccount(result)
|
await setAccount(result)
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err)
|
handleError(err)
|
||||||
exceptionErrorModal.value?.show()
|
unexpectedErrorModal.value?.show()
|
||||||
} finally {
|
} finally {
|
||||||
elybyLoginDisabled.value = false
|
elyByLoginDisabled.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] • Feature
|
// This code is modified by AstralRinth
|
||||||
function convertRawStringToUUIDv4(rawId) {
|
function convertRawStringToUUIDv4(rawId) {
|
||||||
if (rawId.length !== 32) {
|
if (rawId.length !== 32) {
|
||||||
console.warn('Invalid UUID string:', rawId)
|
console.warn('Invalid UUID string:', rawId)
|
||||||
@@ -543,7 +616,6 @@ onUnmounted(() => {
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.vector-icon {
|
.vector-icon {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import { install } from '@/helpers/profile.js'
|
|||||||
import { cancel_directory_change } from '@/helpers/settings.ts'
|
import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
|
|
||||||
// [AR] Imports
|
// This code is modified by AstralRinth
|
||||||
import { applyMigrationFix } from '@/helpers/utils.js'
|
import { applyMigrationFix } from '@/helpers/utils.js'
|
||||||
import { restartApp } 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 { edit, get_optimal_jre_key } from '@/helpers/profile'
|
||||||
import { get } from '@/helpers/settings.ts'
|
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 { handleError } = injectNotificationManager()
|
||||||
const { formatMessage } = useVIntl()
|
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 optimalJava = readonly(await get_optimal_jre_key(props.instance.path).catch(handleError))
|
||||||
const javaInstall = ref({ path: optimalJava.path ?? props.instance.java_path })
|
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(
|
const javaArgs = ref(
|
||||||
(props.instance.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
|
(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(
|
const envVars = ref(
|
||||||
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
|
(props.instance.custom_env_vars ?? globalSettings.custom_env_vars)
|
||||||
.map((x) => x.join('='))
|
.map((x) => x.join('='))
|
||||||
@@ -42,36 +42,23 @@ const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const editProfileObject = computed(() => {
|
const editProfileObject = computed(() => {
|
||||||
const editProfile: {
|
return {
|
||||||
java_path?: string
|
java_path:
|
||||||
extra_launch_args?: string[]
|
overrideJavaInstall.value && javaInstall.value.path !== ''
|
||||||
custom_env_vars?: string[][]
|
? javaInstall.value.path.replace('java.exe', 'javaw.exe')
|
||||||
memory?: MemorySettings
|
: null,
|
||||||
} = {}
|
extra_launch_args: overrideJavaArgs.value
|
||||||
|
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
|
||||||
if (overrideJavaInstall.value) {
|
: null,
|
||||||
if (javaInstall.value.path !== '') {
|
custom_env_vars: overrideEnvVars.value
|
||||||
editProfile.java_path = javaInstall.value.path.replace('java.exe', 'javaw.exe')
|
? 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(
|
watch(
|
||||||
|
|||||||
@@ -26,20 +26,16 @@ const fullscreenSetting: Ref<boolean> = ref(
|
|||||||
)
|
)
|
||||||
|
|
||||||
const editProfileObject = computed(() => {
|
const editProfileObject = computed(() => {
|
||||||
const editProfile: {
|
if (!overrideWindowSettings.value) {
|
||||||
force_fullscreen?: boolean
|
return {
|
||||||
game_resolution?: [number, number]
|
force_fullscreen: null,
|
||||||
} = {}
|
game_resolution: null,
|
||||||
|
|
||||||
if (overrideWindowSettings.value) {
|
|
||||||
editProfile.force_fullscreen = fullscreenSetting.value
|
|
||||||
|
|
||||||
if (!fullscreenSetting.value) {
|
|
||||||
editProfile.game_resolution = resolution.value
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
return editProfile
|
force_fullscreen: fullscreenSetting.value,
|
||||||
|
game_resolution: fullscreenSetting.value ? null : resolution.value,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -95,14 +91,6 @@ const messages = defineMessages({
|
|||||||
<Checkbox
|
<Checkbox
|
||||||
v-model="overrideWindowSettings"
|
v-model="overrideWindowSettings"
|
||||||
:label="formatMessage(messages.customWindowSettings)"
|
: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 class="mt-2 flex items-center gap-4 justify-between">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import PrivacySettings from '@/components/ui/settings/PrivacySettings.vue'
|
|||||||
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
|
import ResourceManagementSettings from '@/components/ui/settings/ResourceManagementSettings.vue'
|
||||||
import { get, set } from '@/helpers/settings.ts'
|
import { get, set } from '@/helpers/settings.ts'
|
||||||
|
|
||||||
// [AR] Imports
|
// This code is modified by AstralRinth
|
||||||
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
||||||
|
|
||||||
const updateModalView = ref(null)
|
const updateModalView = ref(null)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ async function updateJavaVersion(version) {
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<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 }">
|
<h2 class="m-0 text-lg font-extrabold text-contrast" :class="{ 'mt-4': index !== 0 }">
|
||||||
Java {{ javaVersion }} location
|
Java {{ javaVersion }} location
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { LoaderCircleIcon } from '@modrinth/assets'
|
||||||
import type { GameVersion } from '@modrinth/ui'
|
import type { GameVersion } from '@modrinth/ui'
|
||||||
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
|
import { GAME_MODES, HeadingLink, injectNotificationManager } from '@modrinth/ui'
|
||||||
import type { Dayjs } from 'dayjs'
|
import type { Dayjs } from 'dayjs'
|
||||||
@@ -39,6 +40,7 @@ const props = defineProps<{
|
|||||||
const theme = useTheming()
|
const theme = useTheming()
|
||||||
|
|
||||||
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
const jumpBackInItems = ref<JumpBackInItem[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
const serverData = ref<Record<string, ServerData>>({})
|
const serverData = ref<Record<string, ServerData>>({})
|
||||||
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
|
const protocolVersions = ref<Record<string, ProtocolVersion | null>>({})
|
||||||
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []))
|
||||||
@@ -71,9 +73,13 @@ watch([() => props.recentInstances, () => showWorlds.value], async () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
await populateJumpBackIn().catch(() => {
|
populateJumpBackIn()
|
||||||
console.error('Failed to populate jump back in')
|
.catch(() => {
|
||||||
})
|
console.error('Failed to populate jump back in')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
async function populateJumpBackIn() {
|
async function populateJumpBackIn() {
|
||||||
console.info('Repopulating jump back in...')
|
console.info('Repopulating jump back in...')
|
||||||
@@ -233,7 +239,15 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<HeadingLink v-if="theme.getFeatureFlag('worlds_tab')" to="/worlds" class="mt-1">
|
||||||
Jump back in
|
Jump back in
|
||||||
</HeadingLink>
|
</HeadingLink>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export async function offline_login(name) {
|
|||||||
return await invoke('plugin:auth|offline_login', { name: 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) {
|
export async function elyby_login(uuid, login, accessToken) {
|
||||||
return await invoke('plugin:auth|elyby_login', {
|
return await invoke('plugin:auth|elyby_login', {
|
||||||
uuid,
|
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) {
|
export async function elyby_auth_authenticate(login, password, clientToken) {
|
||||||
return await invoke('plugin:auth|elyby_auth_authenticate', {
|
return await invoke('plugin:auth|elyby_auth_authenticate', {
|
||||||
login,
|
login,
|
||||||
|
|||||||
@@ -97,3 +97,8 @@ export async function warning_listener(callback) {
|
|||||||
export async function friend_listener(callback) {
|
export async function friend_listener(callback) {
|
||||||
return await listen('friend', (event) => callback(event.payload))
|
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')
|
return await invoke('plugin:utils|get_os')
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] Feature. Updater
|
// This code is modified by AstralRinth
|
||||||
export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
|
export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
|
||||||
console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
|
console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
|
||||||
return await invoke('plugin:utils|init_update_launcher', { 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) {
|
export async function applyMigrationFix(eol) {
|
||||||
return await invoke('plugin:utils|apply_migration_fix', { 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) {
|
export async function initAuthlibPatching(minecraftVersion, isMojang) {
|
||||||
return await invoke('plugin:utils|init_authlib_patching', { 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',
|
name: 'Servers',
|
||||||
component: ServersManagePageIndex,
|
component: ServersManagePageIndex,
|
||||||
meta: {
|
meta: {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const config: Config = {
|
|||||||
'./src/error.vue',
|
'./src/error.vue',
|
||||||
// monorepo - TODO: migrate this to its own package
|
// monorepo - TODO: migrate this to its own package
|
||||||
'../../packages/**/*.{js,vue,ts}',
|
'../../packages/**/*.{js,vue,ts}',
|
||||||
|
'!../../packages/**/node_modules/**',
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ pub fn init<R: Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [AR] Feature. Ely.by
|
// This code is modified by AstralRinth
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn init_authlib_patching(
|
pub async fn init_authlib_patching(
|
||||||
minecraft_version: &str,
|
minecraft_version: &str,
|
||||||
@@ -42,14 +42,14 @@ pub async fn init_authlib_patching(
|
|||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [AR] Migration. Patch
|
// This code is modified by AstralRinth
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
|
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
|
||||||
let result = utils::apply_migration_fix(eol).await?;
|
let result = utils::apply_migration_fix(eol).await?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [AR] Feature. Updater
|
// This code is modified by AstralRinth
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn init_update_launcher(
|
pub async fn init_update_launcher(
|
||||||
download_url: &str,
|
download_url: &str,
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"productName": "AstralRinth App",
|
"productName": "AstralRinth App",
|
||||||
"version": "0.10.2101",
|
"version": "0.10.2401",
|
||||||
"mainBinaryName": "AstralRinth App",
|
"mainBinaryName": "AstralRinth App",
|
||||||
"identifier": "AstralRinthApp",
|
"identifier": "AstralRinthApp",
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|||||||
@@ -394,15 +394,26 @@ components:
|
|||||||
description: The hash of the file you're editing
|
description: The hash of the file you're editing
|
||||||
example: aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj
|
example: aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj
|
||||||
file_type:
|
file_type:
|
||||||
type: string
|
allOf:
|
||||||
enum: [required-resource-pack, optional-resource-pack]
|
- $ref: '#/components/schemas/FileTypeEnum'
|
||||||
description: The hash algorithm of the file you're editing
|
- nullable: true
|
||||||
example: required-resource-pack
|
description: The hash algorithm of the file you're editing
|
||||||
nullable: true
|
|
||||||
required:
|
required:
|
||||||
- algorithm
|
- algorithm
|
||||||
- hash
|
- hash
|
||||||
- file_type
|
- 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
|
# https://github.com/modrinth/labrinth/blob/master/src/routes/version_creation.rs#L27-L57
|
||||||
CreatableVersion:
|
CreatableVersion:
|
||||||
allOf:
|
allOf:
|
||||||
@@ -506,11 +517,10 @@ components:
|
|||||||
example: 1097270
|
example: 1097270
|
||||||
description: The size of the file in bytes
|
description: The size of the file in bytes
|
||||||
file_type:
|
file_type:
|
||||||
type: string
|
allOf:
|
||||||
enum: [required-resource-pack, optional-resource-pack]
|
- $ref: '#/components/schemas/FileTypeEnum'
|
||||||
description: The type of the additional file, used mainly for adding resource packs to datapacks
|
- nullable: true
|
||||||
example: required-resource-pack
|
description: The type of the additional file, used mainly for adding resource packs to datapacks
|
||||||
nullable: true
|
|
||||||
required:
|
required:
|
||||||
- hashes
|
- hashes
|
||||||
- url
|
- url
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { consola } from 'consola'
|
|||||||
import { promises as fs } from 'fs'
|
import { promises as fs } from 'fs'
|
||||||
import { globIterate } from 'glob'
|
import { globIterate } from 'glob'
|
||||||
import { defineNuxtConfig } from 'nuxt/config'
|
import { defineNuxtConfig } from 'nuxt/config'
|
||||||
import { basename, relative, resolve } from 'pathe'
|
import { basename, relative } from 'pathe'
|
||||||
import svgLoader from 'vite-svg-loader'
|
import svgLoader from 'vite-svg-loader'
|
||||||
|
|
||||||
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
|
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
|
* Preferably only the locales that reach a certain threshold of complete
|
||||||
* translations would be included in this array.
|
* translations would be included in this array.
|
||||||
*/
|
*/
|
||||||
const enabledLocales: string[] = []
|
// const enabledLocales: string[] = []
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overrides for the categories of the certain locales.
|
* Overrides for the categories of the certain locales.
|
||||||
@@ -154,7 +154,7 @@ export default defineNuxtConfig({
|
|||||||
(state.errors ?? []).length === 0
|
(state.errors ?? []).length === 0
|
||||||
) {
|
) {
|
||||||
console.log(
|
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
|
return
|
||||||
}
|
}
|
||||||
@@ -176,27 +176,10 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
console.log('Tags generated!')
|
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) {
|
async 'vintl:extendOptions'(opts) {
|
||||||
opts.locales ??= []
|
opts.locales ??= []
|
||||||
|
|
||||||
const isProduction = getDomain() === 'https://modrinth.com'
|
// const isProduction = getDomain() === 'https://modrinth.com'
|
||||||
|
|
||||||
const resolveCompactNumberDataImport = await (async () => {
|
const resolveCompactNumberDataImport = await (async () => {
|
||||||
const compactNumberLocales: string[] = []
|
const compactNumberLocales: string[] = []
|
||||||
@@ -251,7 +234,9 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
for await (const localeDir of globIterate('src/locales/*/', { posix: true })) {
|
for await (const localeDir of globIterate('src/locales/*/', { posix: true })) {
|
||||||
const tag = basename(localeDir)
|
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 =
|
const locale =
|
||||||
opts.locales.find((locale) => locale.tag === tag) ??
|
opts.locales.find((locale) => locale.tag === tag) ??
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"@formatjs/cli": "^6.2.12",
|
"@formatjs/cli": "^6.2.12",
|
||||||
"@nuxt/devtools": "^1.3.3",
|
"@nuxt/devtools": "^1.3.3",
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
|
"@types/iso-3166-2": "^1.0.4",
|
||||||
"@types/node": "^20.1.0",
|
"@types/node": "^20.1.0",
|
||||||
"@vintl/compact-number": "^2.0.5",
|
"@vintl/compact-number": "^2.0.5",
|
||||||
"@vintl/how-ago": "^3.0.1",
|
"@vintl/how-ago": "^3.0.1",
|
||||||
@@ -31,6 +32,7 @@
|
|||||||
"tailwindcss": "^3.4.4",
|
"tailwindcss": "^3.4.4",
|
||||||
"typescript": "^5.4.5",
|
"typescript": "^5.4.5",
|
||||||
"vite-svg-loader": "^5.1.0",
|
"vite-svg-loader": "^5.1.0",
|
||||||
|
"vue-component-type-helpers": "^3.1.8",
|
||||||
"vue-tsc": "^2.0.24"
|
"vue-tsc": "^2.0.24"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -58,6 +60,7 @@
|
|||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
"fuse.js": "^6.6.2",
|
"fuse.js": "^6.6.2",
|
||||||
"highlight.js": "^11.7.0",
|
"highlight.js": "^11.7.0",
|
||||||
|
"iso-3166-2": "1.0.0",
|
||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
</NuxtLayout>
|
</NuxtLayout>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<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 ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
|
||||||
import { createModrinthClient } from '~/helpers/api.ts'
|
import { createModrinthClient } from '~/helpers/api.ts'
|
||||||
@@ -23,4 +28,8 @@ const client = createModrinthClient(auth, {
|
|||||||
rateLimitKey: config.rateLimitKey,
|
rateLimitKey: config.rateLimitKey,
|
||||||
})
|
})
|
||||||
provideModrinthClient(client)
|
provideModrinthClient(client)
|
||||||
|
providePageContext({
|
||||||
|
hierarchicalSidebarAvailable: ref(false),
|
||||||
|
showAds: ref(false),
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -135,21 +135,6 @@
|
|||||||
'sidebar'
|
'sidebar'
|
||||||
/ 100%;
|
/ 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) {
|
@media screen and (min-width: 1024px) {
|
||||||
&.sidebar {
|
&.sidebar {
|
||||||
grid-template:
|
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 {
|
.normal-page__sidebar {
|
||||||
grid-area: sidebar;
|
grid-area: sidebar;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
@import '@modrinth/ui/src/styles/tailwind-utilities.css';
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|||||||
@@ -62,21 +62,21 @@ useHead({
|
|||||||
|
|
||||||
const AD_PRESETS = {
|
const AD_PRESETS = {
|
||||||
medal: {
|
medal: {
|
||||||
light: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-light-new.webp',
|
light: 'https://cdn-raw.modrinth.com/modrinth-hosting-medal-light.webp',
|
||||||
dark: 'https://cdn-raw.modrinth.com/medal-modrinth-servers-dark-new.webp',
|
dark: 'https://cdn-raw.modrinth.com/modrinth-hosting-medal-dark.webp',
|
||||||
description: 'Host your next server with Modrinth Servers',
|
description: 'Host your next server with Modrinth Hosting',
|
||||||
link: '/servers?plan&ref=medal',
|
link: '/hosting?plan&ref=medal',
|
||||||
},
|
},
|
||||||
'modrinth-servers': {
|
'modrinth-hosting': {
|
||||||
light: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-light.webp',
|
light: 'https://cdn-raw.modrinth.com/modrinth-hosting-light.webp',
|
||||||
dark: 'https://cdn-raw.modrinth.com/modrinth-servers-placeholder-dark.webp',
|
dark: 'https://cdn-raw.modrinth.com/modrinth-hosting-dark.webp',
|
||||||
description: 'Host your next server with Modrinth Servers',
|
description: 'Host your next server with Modrinth Hosting',
|
||||||
link: '/servers',
|
link: '/hosting',
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentAd = computed(() =>
|
const currentAd = computed(() =>
|
||||||
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-servers'],
|
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-hosting'],
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
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>
|
<template>
|
||||||
<nav
|
<nav
|
||||||
ref="scrollContainer"
|
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
|
<template v-if="mode === 'navigation'">
|
||||||
v-for="(link, index) in filteredLinks"
|
<NuxtLink
|
||||||
v-show="link.shown === undefined ? true : link.shown"
|
v-for="(link, index) in filteredLinks"
|
||||||
:key="index"
|
v-show="link.shown === undefined ? true : link.shown"
|
||||||
ref="tabLinkElements"
|
:key="link.href"
|
||||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
ref="tabLinkElements"
|
||||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||||
:class="{
|
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||||
'text-button-textSelected': activeIndex === index && !subpageSelected,
|
>
|
||||||
'text-contrast': activeIndex === index && subpageSelected,
|
<component
|
||||||
}"
|
:is="link.icon"
|
||||||
>
|
v-if="link.icon"
|
||||||
<component :is="link.icon" v-if="link.icon" class="size-5" />
|
class="size-5"
|
||||||
<span class="text-nowrap">{{ link.label }}</span>
|
:class="{
|
||||||
</NuxtLink>
|
'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
|
<div
|
||||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
|
: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'
|
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
|
||||||
@@ -27,7 +62,8 @@
|
|||||||
top: sliderTopPx,
|
top: sliderTopPx,
|
||||||
right: sliderRightPx,
|
right: sliderRightPx,
|
||||||
bottom: sliderBottomPx,
|
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"
|
aria-hidden="true"
|
||||||
></div>
|
></div>
|
||||||
@@ -35,7 +71,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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()
|
const route = useNativeRoute()
|
||||||
|
|
||||||
@@ -43,13 +80,26 @@ interface Tab {
|
|||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
shown?: boolean
|
shown?: boolean
|
||||||
icon?: string
|
icon?: Component
|
||||||
subpages?: string[]
|
subpages?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = withDefaults(
|
||||||
links: Tab[]
|
defineProps<{
|
||||||
query?: string
|
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)
|
const scrollContainer = ref<HTMLElement | null>(null)
|
||||||
@@ -58,7 +108,7 @@ const sliderLeft = ref(4)
|
|||||||
const sliderTop = ref(4)
|
const sliderTop = ref(4)
|
||||||
const sliderRight = ref(4)
|
const sliderRight = ref(4)
|
||||||
const sliderBottom = ref(4)
|
const sliderBottom = ref(4)
|
||||||
const activeIndex = ref(-1)
|
const currentActiveIndex = ref(-1)
|
||||||
const subpageSelected = ref(false)
|
const subpageSelected = ref(false)
|
||||||
|
|
||||||
const filteredLinks = computed(() =>
|
const filteredLinks = computed(() =>
|
||||||
@@ -74,30 +124,36 @@ const tabLinkElements = ref()
|
|||||||
function pickLink() {
|
function pickLink() {
|
||||||
let index = -1
|
let index = -1
|
||||||
subpageSelected.value = false
|
subpageSelected.value = false
|
||||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
|
||||||
const link = filteredLinks.value[i]
|
if (props.mode === 'local' && props.activeIndex !== undefined) {
|
||||||
if (props.query) {
|
index = Math.min(props.activeIndex, filteredLinks.value.length - 1)
|
||||||
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
|
} 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
|
index = i
|
||||||
break
|
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) {
|
currentActiveIndex.value = index
|
||||||
startAnimation()
|
|
||||||
|
if (currentActiveIndex.value !== -1) {
|
||||||
|
nextTick(() => startAnimation())
|
||||||
} else {
|
} else {
|
||||||
sliderLeft.value = 0
|
sliderLeft.value = 0
|
||||||
sliderRight.value = 0
|
sliderRight.value = 0
|
||||||
@@ -105,7 +161,12 @@ function pickLink() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function startAnimation() {
|
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
|
if (!el || !el.offsetParent) return
|
||||||
|
|
||||||
@@ -156,7 +217,29 @@ onMounted(() => {
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [route.path, route.query],
|
() => [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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -20,20 +20,6 @@
|
|||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@@ -45,8 +31,6 @@ import { computed } from 'vue'
|
|||||||
|
|
||||||
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
||||||
|
|
||||||
import ModerationProjectNags from './moderation/ModerationProjectNags.vue'
|
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
|
||||||
interface Tags {
|
interface Tags {
|
||||||
@@ -71,12 +55,9 @@ interface Props {
|
|||||||
currentMember?: Member | null
|
currentMember?: Member | null
|
||||||
allMembers?: Member[] | null
|
allMembers?: Member[] | null
|
||||||
isSettings?: boolean
|
isSettings?: boolean
|
||||||
collapsed?: boolean
|
|
||||||
routeName?: string
|
|
||||||
auth: Auth
|
auth: Auth
|
||||||
tags: Tags
|
tags: Tags
|
||||||
setProcessing?: (processing: boolean) => void
|
setProcessing?: (processing: boolean) => void
|
||||||
toggleCollapsed?: () => void
|
|
||||||
updateMembers?: () => void | Promise<void>
|
updateMembers?: () => void | Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +125,6 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
allMembers: null,
|
allMembers: null,
|
||||||
isSettings: false,
|
isSettings: false,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
routeName: '',
|
|
||||||
setProcessing: () => {},
|
setProcessing: () => {},
|
||||||
toggleCollapsed: () => {},
|
toggleCollapsed: () => {},
|
||||||
updateMembers: async () => {},
|
updateMembers: async () => {},
|
||||||
@@ -164,14 +144,6 @@ const showInvitation = computed<boolean>(() => {
|
|||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleToggleCollapsed(): void {
|
|
||||||
if (props.toggleCollapsed) {
|
|
||||||
props.toggleCollapsed()
|
|
||||||
} else {
|
|
||||||
emit('toggleCollapsed')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleUpdateMembers(): Promise<void> {
|
async function handleUpdateMembers(): Promise<void> {
|
||||||
if (props.updateMembers) {
|
if (props.updateMembers) {
|
||||||
await 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()
|
modal.value.hide()
|
||||||
await router.push({
|
await router.push(`/project/${slug.value}/settings`)
|
||||||
name: 'type-id',
|
|
||||||
params: {
|
|
||||||
type: 'project',
|
|
||||||
id: slug.value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: formatMessage(messages.errorTitle),
|
title: formatMessage(messages.errorTitle),
|
||||||
|
|||||||
@@ -158,12 +158,18 @@ import {
|
|||||||
SpinnerIcon,
|
SpinnerIcon,
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} 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 { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import { IntlFormatted } from '@vintl/vintl/components'
|
import { IntlFormatted } from '@vintl/vintl/components'
|
||||||
|
|
||||||
import { type FormRequestResponse, useAvalara1099 } from '@/composables/avalara1099'
|
import { type FormRequestResponse, useAvalara1099 } from '@/composables/avalara1099'
|
||||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
|||||||
@@ -8,10 +8,21 @@
|
|||||||
leave-to-class="opacity-0 max-h-0"
|
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 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">
|
<template v-if="isGiftCard && shouldShowExchangeRate">
|
||||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownAmount) }}</span>
|
<div class="flex items-center justify-between">
|
||||||
<span class="font-semibold text-contrast">{{ formatMoney(amount || 0) }}</span>
|
<span class="text-primary">{{ formatMessage(messages.feeBreakdownGiftCardValue) }}</span>
|
||||||
</div>
|
<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">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownFee) }}</span>
|
<span class="text-primary">{{ formatMessage(messages.feeBreakdownFee) }}</span>
|
||||||
<span class="h-4 font-semibold text-contrast">
|
<span class="h-4 font-semibold text-contrast">
|
||||||
@@ -21,6 +32,7 @@
|
|||||||
<template v-else>-{{ formatMoney(fee || 0) }}</template>
|
<template v-else>-{{ formatMoney(fee || 0) }}</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="h-px bg-surface-5" />
|
<div class="h-px bg-surface-5" />
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownNetAmount) }}</span>
|
<span class="text-primary">{{ formatMessage(messages.feeBreakdownNetAmount) }}</span>
|
||||||
@@ -31,7 +43,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="shouldShowExchangeRate">
|
<template v-if="shouldShowExchangeRate && !isGiftCard">
|
||||||
<div class="flex items-center justify-between text-sm">
|
<div class="flex items-center justify-between text-sm">
|
||||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownExchangeRate) }}</span>
|
<span class="text-primary">{{ formatMessage(messages.feeBreakdownExchangeRate) }}</span>
|
||||||
<span class="text-secondary"
|
<span class="text-secondary"
|
||||||
@@ -56,10 +68,12 @@ const props = withDefaults(
|
|||||||
feeLoading: boolean
|
feeLoading: boolean
|
||||||
exchangeRate?: number | null
|
exchangeRate?: number | null
|
||||||
localCurrency?: string
|
localCurrency?: string
|
||||||
|
isGiftCard?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
exchangeRate: null,
|
exchangeRate: null,
|
||||||
localCurrency: undefined,
|
localCurrency: undefined,
|
||||||
|
isGiftCard: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -115,5 +129,13 @@ const messages = defineMessages({
|
|||||||
id: 'dashboard.creator-withdraw-modal.fee-breakdown-exchange-rate',
|
id: 'dashboard.creator-withdraw-modal.fee-breakdown-exchange-rate',
|
||||||
defaultMessage: 'FX 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>
|
</script>
|
||||||
|
|||||||
@@ -124,6 +124,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { normalizeChildren } from '@modrinth/ui'
|
||||||
import { formatMoney } from '@modrinth/utils'
|
import { formatMoney } from '@modrinth/utils'
|
||||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import { IntlFormatted } from '@vintl/vintl/components'
|
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 { type TremendousProviderData, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||||
import { getRailConfig } from '@/utils/muralpay-rails'
|
import { getRailConfig } from '@/utils/muralpay-rails'
|
||||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
|
||||||
|
|
||||||
const { withdrawData } = useWithdrawContext()
|
const { withdrawData } = useWithdrawContext()
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
+7
-2
@@ -104,7 +104,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CheckIcon, PayPalColorIcon, SaveIcon, XIcon } from '@modrinth/assets'
|
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 { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import { IntlFormatted } from '@vintl/vintl/components'
|
import { IntlFormatted } from '@vintl/vintl/components'
|
||||||
import { useDebounceFn } from '@vueuse/core'
|
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 WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||||
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
|
import { getAuthUrl, removeAuthProvider, useAuth } from '@/composables/auth.js'
|
||||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
|
||||||
|
|
||||||
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
|
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees, saveStateToStorage } =
|
||||||
useWithdrawContext()
|
useWithdrawContext()
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ import {
|
|||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
Combobox,
|
Combobox,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
|
normalizeChildren,
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { formatMoney } from '@modrinth/utils'
|
import { formatMoney } from '@modrinth/utils'
|
||||||
@@ -93,7 +94,6 @@ import { useGeolocation } from '@vueuse/core'
|
|||||||
|
|
||||||
import { useCountries, useFormattedCountries, useUserCountry } from '@/composables/country.ts'
|
import { useCountries, useFormattedCountries, useUserCountry } from '@/composables/country.ts'
|
||||||
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
|
||||||
|
|
||||||
const debug = useDebugLogger('MethodSelectionStage')
|
const debug = useDebugLogger('MethodSelectionStage')
|
||||||
const {
|
const {
|
||||||
|
|||||||
@@ -207,6 +207,9 @@ import {
|
|||||||
financialMessages,
|
financialMessages,
|
||||||
formFieldLabels,
|
formFieldLabels,
|
||||||
formFieldPlaceholders,
|
formFieldPlaceholders,
|
||||||
|
getBlockchainColor,
|
||||||
|
getBlockchainIcon,
|
||||||
|
normalizeChildren,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import { IntlFormatted } from '@vintl/vintl/components'
|
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 WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||||
import { useGeneratedState } from '@/composables/generated'
|
import { useGeneratedState } from '@/composables/generated'
|
||||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||||
import {
|
|
||||||
getBlockchainColor,
|
|
||||||
getBlockchainIcon,
|
|
||||||
getCurrencyColor,
|
|
||||||
getCurrencyIcon,
|
|
||||||
} from '@/utils/finance-icons.ts'
|
|
||||||
import { getRailConfig } from '@/utils/muralpay-rails'
|
import { getRailConfig } from '@/utils/muralpay-rails'
|
||||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
|
||||||
|
|
||||||
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees } = useWithdrawContext()
|
const { withdrawData, maxWithdrawAmount, availableMethods, calculateFees } = useWithdrawContext()
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
@@ -220,14 +220,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Chips, Combobox, formFieldLabels, formFieldPlaceholders } from '@modrinth/ui'
|
import { Chips, Combobox, formFieldLabels, formFieldPlaceholders } from '@modrinth/ui'
|
||||||
import { useVIntl } from '@vintl/vintl'
|
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 { useFormattedCountries } from '@/composables/country.ts'
|
||||||
import { useGeneratedState } from '@/composables/generated.ts'
|
|
||||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||||
|
|
||||||
const { withdrawData } = useWithdrawContext()
|
const { withdrawData } = useWithdrawContext()
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const generatedState = useGeneratedState()
|
|
||||||
|
|
||||||
const providerData = withdrawData.value.providerData
|
const providerData = withdrawData.value.providerData
|
||||||
const existingKycData = providerData.type === 'muralpay' ? providerData.kycData : null
|
const existingKycData = providerData.type === 'muralpay' ? providerData.kycData : null
|
||||||
@@ -283,12 +283,15 @@ const subdivisionOptions = computed(() => {
|
|||||||
const selectedCountry = formData.value.physicalAddress.country
|
const selectedCountry = formData.value.physicalAddress.country
|
||||||
if (!selectedCountry) return []
|
if (!selectedCountry) return []
|
||||||
|
|
||||||
const subdivisions = generatedState.value.subdivisions?.[selectedCountry] ?? []
|
const country = iso3166.country(selectedCountry)
|
||||||
|
if (!country) return []
|
||||||
|
|
||||||
return subdivisions.map((sub) => ({
|
return Object.entries(country.sub)
|
||||||
value: sub.code.includes('-') ? sub.code.split('-')[1] : sub.code,
|
.map(([code, sub]) => ({
|
||||||
label: sub.localVariant || sub.name,
|
value: code.split('-').slice(1).join('-'),
|
||||||
}))
|
label: sub.name,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.label.localeCompare(b.label))
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
|
|||||||
@@ -74,14 +74,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { FileTextIcon } from '@modrinth/assets'
|
import { FileTextIcon } from '@modrinth/assets'
|
||||||
import { Admonition, ButtonStyled } from '@modrinth/ui'
|
import { Admonition, ButtonStyled, normalizeChildren } from '@modrinth/ui'
|
||||||
import { formatMoney } from '@modrinth/utils'
|
import { formatMoney } from '@modrinth/utils'
|
||||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import { IntlFormatted } from '@vintl/vintl/components'
|
import { IntlFormatted } from '@vintl/vintl/components'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { TAX_THRESHOLD_ACTUAL } from '@/providers/creator-withdraw.ts'
|
import { TAX_THRESHOLD_ACTUAL } from '@/providers/creator-withdraw.ts'
|
||||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
balance: any
|
balance: any
|
||||||
|
|||||||
+444
-26
@@ -111,28 +111,202 @@
|
|||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="selectedMethodDetails" class="text-secondary">
|
<span v-if="selectedMethodDetails" class="text-secondary">
|
||||||
{{ formatMoney(effectiveMinAmount) }} min,
|
{{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
|
||||||
{{ formatMoney(selectedMethodDetails.interval?.standard?.max ?? effectiveMaxAmount) }}
|
}}<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.
|
max withdrawal amount.
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2.5">
|
<div class="flex flex-col gap-2.5">
|
||||||
<label>
|
<label>
|
||||||
<span class="text-md font-semibold text-contrast"
|
<span class="text-md font-semibold text-contrast">
|
||||||
>{{ formatMessage(formFieldLabels.amount) }} <span class="text-red">*</span></span
|
<template v-if="useDenominationSuggestions">
|
||||||
>
|
{{ formatMessage(messages.searchAmountLabel) }} ({{ selectedMethodCurrencyCode }})
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ formatMessage(formFieldLabels.amount) }}
|
||||||
|
</template>
|
||||||
|
<span class="text-red">*</span>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div v-if="showGiftCardSelector && useFixedDenominations" class="flex flex-col gap-2.5">
|
<div v-if="showGiftCardSelector && useFixedDenominations" class="flex flex-col gap-2.5">
|
||||||
<Chips
|
<template v-if="useDenominationSuggestions">
|
||||||
v-model="selectedDenomination"
|
<div class="iconified-input w-full">
|
||||||
:items="denominationOptions"
|
<SearchIcon aria-hidden="true" />
|
||||||
:format-label="(amt: number) => formatMoney(amt)"
|
<input
|
||||||
:never-empty="false"
|
v-model.number="denominationSearchInput"
|
||||||
:capitalize="false"
|
type="number"
|
||||||
/>
|
step="0.01"
|
||||||
<span v-if="denominationOptions.length === 0" class="text-error text-sm">
|
: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
|
No denominations available for your current balance
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,12 +323,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WithdrawFeeBreakdown
|
<WithdrawFeeBreakdown
|
||||||
v-if="allRequiredFieldsFilled"
|
v-if="allRequiredFieldsFilled && formData.amount && formData.amount > 0"
|
||||||
:amount="formData.amount || 0"
|
:amount="formData.amount || 0"
|
||||||
:fee="calculatedFee"
|
:fee="calculatedFee"
|
||||||
:fee-loading="feeLoading"
|
:fee-loading="feeLoading"
|
||||||
:exchange-rate="exchangeRate"
|
:exchange-rate="showGiftCardSelector ? selectedMethodExchangeRate : giftCardExchangeRate"
|
||||||
:local-currency="showPayPalCurrencySelector ? selectedCurrency : undefined"
|
:local-currency="
|
||||||
|
showGiftCardSelector ? (selectedMethodCurrencyCode ?? undefined) : giftCardCurrencyCode
|
||||||
|
"
|
||||||
|
:is-gift-card="showGiftCardSelector"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Checkbox v-model="agreedTerms">
|
<Checkbox v-model="agreedTerms">
|
||||||
@@ -173,6 +350,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { SearchIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Admonition,
|
Admonition,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
@@ -181,6 +359,7 @@ import {
|
|||||||
financialMessages,
|
financialMessages,
|
||||||
formFieldLabels,
|
formFieldLabels,
|
||||||
formFieldPlaceholders,
|
formFieldPlaceholders,
|
||||||
|
normalizeChildren,
|
||||||
paymentMethodMessages,
|
paymentMethodMessages,
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
@@ -195,7 +374,6 @@ import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown
|
|||||||
import { useAuth } from '@/composables/auth.js'
|
import { useAuth } from '@/composables/auth.js'
|
||||||
import { useBaseFetch } from '@/composables/fetch.js'
|
import { useBaseFetch } from '@/composables/fetch.js'
|
||||||
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
|
||||||
|
|
||||||
const debug = useDebugLogger('TremendousDetailsStage')
|
const debug = useDebugLogger('TremendousDetailsStage')
|
||||||
const {
|
const {
|
||||||
@@ -285,6 +463,9 @@ const formData = ref<Record<string, any>>({
|
|||||||
|
|
||||||
const selectedGiftCardId = ref<string | null>(withdrawData.value.selection.methodId || null)
|
const selectedGiftCardId = ref<string | null>(withdrawData.value.selection.methodId || null)
|
||||||
|
|
||||||
|
const denominationSearchInput = ref<number | undefined>(undefined)
|
||||||
|
const hasTouchedSuggestions = ref(false)
|
||||||
|
|
||||||
const currencyOptions = [
|
const currencyOptions = [
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
{ value: 'AUD', label: 'AUD' },
|
{ value: 'AUD', label: 'AUD' },
|
||||||
@@ -373,6 +554,8 @@ const rewardOptions = ref<
|
|||||||
fixed?: { values: number[] }
|
fixed?: { values: number[] }
|
||||||
standard?: { min: number; max: number }
|
standard?: { min: number; max: number }
|
||||||
}
|
}
|
||||||
|
currencyCode?: string | null
|
||||||
|
exchangeRate?: number | null
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
>([])
|
>([])
|
||||||
@@ -390,24 +573,188 @@ const selectedMethodDetails = computed(() => {
|
|||||||
return option?.methodDetails || null
|
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 useFixedDenominations = computed(() => {
|
||||||
const hasFixed = !!selectedMethodDetails.value?.interval?.fixed?.values
|
const interval = selectedMethodDetails.value?.interval
|
||||||
debug('Use fixed denominations:', hasFixed, selectedMethodDetails.value?.interval)
|
if (!interval) return false
|
||||||
return hasFixed
|
|
||||||
|
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 denominationOptions = computed(() => {
|
||||||
const fixedValues = selectedMethodDetails.value?.interval?.fixed?.values
|
const interval = selectedMethodDetails.value?.interval
|
||||||
if (!fixedValues) return []
|
if (!interval) return []
|
||||||
|
|
||||||
const filtered = fixedValues
|
let values: number[] = []
|
||||||
.filter((amount) => amount <= roundedMaxAmount.value)
|
|
||||||
.sort((a, b) => a - b)
|
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(
|
debug(
|
||||||
'Denomination options (filtered by max):',
|
'Denomination options (filtered by max):',
|
||||||
filtered,
|
filtered,
|
||||||
'from',
|
'from',
|
||||||
fixedValues,
|
values,
|
||||||
'max:',
|
'max:',
|
||||||
roundedMaxAmount.value,
|
roundedMaxAmount.value,
|
||||||
)
|
)
|
||||||
@@ -426,6 +773,20 @@ const effectiveMaxAmount = computed(() => {
|
|||||||
return roundedMaxAmount.value
|
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({
|
const selectedDenomination = computed({
|
||||||
get: () => formData.value.amount,
|
get: () => formData.value.amount,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -542,6 +903,8 @@ onMounted(async () => {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
interval: m.interval,
|
interval: m.interval,
|
||||||
|
currencyCode: m.currency_code,
|
||||||
|
exchangeRate: m.exchange_rate,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -564,6 +927,8 @@ watch(
|
|||||||
selectedGiftCardId.value = null
|
selectedGiftCardId.value = null
|
||||||
calculatedFee.value = 0
|
calculatedFee.value = 0
|
||||||
exchangeRate.value = null
|
exchangeRate.value = null
|
||||||
|
denominationSearchInput.value = undefined
|
||||||
|
hasTouchedSuggestions.value = false
|
||||||
|
|
||||||
// Clear currency when switching away from PayPal International
|
// Clear currency when switching away from PayPal International
|
||||||
if (newMethod !== 'paypal' && withdrawData.value.providerData.type === 'tremendous') {
|
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() {
|
async function switchToDirectPaypal() {
|
||||||
withdrawData.value.selection.country = {
|
withdrawData.value.selection.country = {
|
||||||
id: 'US',
|
id: 'US',
|
||||||
@@ -649,5 +1039,33 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'You selected USD for PayPal International. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> for better fees (≈2% instead of ≈6%).',
|
'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>
|
</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>
|
<template>
|
||||||
<div
|
<div class="shadow-card rounded-2xl border border-surface-5 bg-surface-3 p-4">
|
||||||
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 items-center justify-between">
|
||||||
>
|
<div class="flex items-center gap-4">
|
||||||
<div class="flex min-w-0 flex-1 items-center gap-3">
|
<Avatar
|
||||||
<div class="flex-shrink-0 rounded-lg">
|
:src="queueEntry.project.icon_url"
|
||||||
<Avatar size="48px" :src="queueEntry.project.icon_url" />
|
size="4rem"
|
||||||
</div>
|
class="rounded-2xl border border-surface-5 bg-surface-4 !shadow-none"
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
/>
|
||||||
<h3 class="truncate text-lg font-semibold">
|
<div class="flex flex-col gap-1.5">
|
||||||
{{ queueEntry.project.name }}
|
<div class="flex items-center gap-2">
|
||||||
</h3>
|
<NuxtLink
|
||||||
<nuxt-link
|
:to="`/project/${queueEntry.project.slug}`"
|
||||||
v-if="queueEntry.owner"
|
target="_blank"
|
||||||
target="_blank"
|
class="text-lg font-semibold text-contrast hover:underline"
|
||||||
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
>
|
||||||
:to="`/user/${queueEntry.owner.user.username}`"
|
{{ queueEntry.project.name }}
|
||||||
>
|
</NuxtLink>
|
||||||
<Avatar
|
<div
|
||||||
:src="queueEntry.owner.user.avatar_url"
|
class="flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
|
||||||
circle
|
>
|
||||||
size="16px"
|
<component
|
||||||
class="inline-block flex-shrink-0"
|
:is="getProjectTypeIcon(queueEntry.project.project_types[0] as any)"
|
||||||
/>
|
aria-hidden="true"
|
||||||
<span class="truncate">{{ queueEntry.owner.user.username }}</span>
|
class="h-4 w-4"
|
||||||
</nuxt-link>
|
/>
|
||||||
<nuxt-link
|
<span class="text-sm font-medium text-secondary">
|
||||||
v-else-if="queueEntry.org"
|
{{
|
||||||
target="_blank"
|
queueEntry.project.project_types.map((t) => formatProjectType(t, true)).join(', ')
|
||||||
class="flex items-center gap-1 truncate align-middle text-sm hover:text-brand"
|
}}
|
||||||
:to="`/organization/${queueEntry.org.slug}`"
|
</span>
|
||||||
>
|
</div>
|
||||||
<Avatar
|
<div
|
||||||
:src="queueEntry.org.icon_url"
|
v-if="queueEntry.project.requested_status"
|
||||||
circle
|
class="flex items-center gap-2 rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
|
||||||
size="16px"
|
>
|
||||||
class="inline-block flex-shrink-0"
|
<span class="text-sm text-secondary">Requesting</span>
|
||||||
/>
|
<Badge :type="queueEntry.project.requested_status" class="status" />
|
||||||
<span class="truncate">{{ queueEntry.org.name }}</span>
|
</div>
|
||||||
</nuxt-link>
|
</div>
|
||||||
</div>
|
<div v-if="queueEntry.owner" class="flex items-center gap-1">
|
||||||
</div>
|
<Avatar
|
||||||
|
:src="queueEntry.owner.user.avatar_url"
|
||||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4">
|
size="1.5rem"
|
||||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-1">
|
circle
|
||||||
<span class="flex items-center gap-1 whitespace-nowrap text-sm">
|
class="border border-surface-5 bg-surface-4 !shadow-none"
|
||||||
<BoxIcon
|
/>
|
||||||
v-if="queueEntry.project.project_type === 'mod'"
|
<NuxtLink
|
||||||
class="size-4 flex-shrink-0"
|
:to="`/user/${queueEntry.owner.user.username}`"
|
||||||
aria-hidden="true"
|
target="_blank"
|
||||||
/>
|
class="text-sm font-medium text-secondary hover:underline"
|
||||||
<PaintbrushIcon
|
>
|
||||||
v-else-if="queueEntry.project.project_type === 'resourcepack'"
|
{{ queueEntry.owner.user.username }}
|
||||||
class="size-4 flex-shrink-0"
|
</NuxtLink>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
/>
|
<div v-else-if="queueEntry.org" class="flex items-center gap-1">
|
||||||
<BracesIcon
|
<Avatar
|
||||||
v-else-if="queueEntry.project.project_type === 'datapack'"
|
:src="queueEntry.org.icon_url"
|
||||||
class="size-4 flex-shrink-0"
|
size="1.5rem"
|
||||||
aria-hidden="true"
|
circle
|
||||||
/>
|
class="border border-surface-5 bg-surface-4 !shadow-none"
|
||||||
<PackageOpenIcon
|
/>
|
||||||
v-else-if="queueEntry.project.project_type === 'modpack'"
|
<NuxtLink
|
||||||
class="size-4 flex-shrink-0"
|
:to="`/organization/${queueEntry.org.slug}`"
|
||||||
aria-hidden="true"
|
target="_blank"
|
||||||
/>
|
class="text-sm font-medium text-secondary hover:underline"
|
||||||
<GlassesIcon
|
>
|
||||||
v-else-if="queueEntry.project.project_type === 'shader'"
|
{{ queueEntry.org.name }}
|
||||||
class="size-4 flex-shrink-0"
|
</NuxtLink>
|
||||||
aria-hidden="true"
|
</div>
|
||||||
/>
|
|
||||||
<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>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<span class="hidden text-sm sm:inline">•</span>
|
<div class="flex items-center gap-3">
|
||||||
|
|
||||||
<span
|
<span
|
||||||
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
|
v-tooltip="`Since ${queuedDate.toLocaleString()}`"
|
||||||
class="truncate text-sm"
|
class="text-base text-secondary"
|
||||||
:class="{
|
:class="{
|
||||||
'text-red': daysInQueue > 4,
|
'text-red': daysInQueue > 4,
|
||||||
'text-orange': daysInQueue > 2,
|
'text-orange': daysInQueue > 2 && daysInQueue <= 4,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<span class="hidden sm:inline">{{ getSubmittedTime(queueEntry) }}</span>
|
{{ formattedDate }}
|
||||||
<span class="sm:hidden">{{
|
|
||||||
getSubmittedTime(queueEntry).replace('Submitted ', '')
|
|
||||||
}}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-2 sm:justify-start">
|
<div class="flex items-center gap-2">
|
||||||
<ButtonStyled circular>
|
<ButtonStyled circular color="orange">
|
||||||
<NuxtLink target="_blank" :to="`/project/${queueEntry.project.slug}`">
|
<button @click="openProjectForReview">
|
||||||
<EyeIcon class="size-4" />
|
<ScaleIcon class="size-5" />
|
||||||
</NuxtLink>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled circular color="orange" @click="openProjectForReview">
|
<ButtonStyled circular>
|
||||||
<button>
|
<OverflowMenu :options="quickActions">
|
||||||
<ScaleIcon class="size-4" />
|
<template #default>
|
||||||
</button>
|
<EllipsisVerticalIcon class="size-4" />
|
||||||
</ButtonStyled>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ClipboardCopyIcon, EllipsisVerticalIcon, LinkIcon, ScaleIcon } from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
BoxIcon,
|
Avatar,
|
||||||
BracesIcon,
|
Badge,
|
||||||
EyeIcon,
|
ButtonStyled,
|
||||||
GlassesIcon,
|
getProjectTypeIcon,
|
||||||
PackageOpenIcon,
|
injectNotificationManager,
|
||||||
PaintbrushIcon,
|
OverflowMenu,
|
||||||
PlugIcon,
|
type OverflowMenuOption,
|
||||||
ScaleIcon,
|
useRelativeTime,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/ui'
|
||||||
import { Avatar, Badge, ButtonStyled, useRelativeTime } from '@modrinth/ui'
|
|
||||||
import { formatProjectType } from '@modrinth/utils'
|
import { formatProjectType } from '@modrinth/utils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
@@ -145,6 +129,7 @@ import { computed } from 'vue'
|
|||||||
import type { ModerationProject } from '~/helpers/moderation'
|
import type { ModerationProject } from '~/helpers/moderation'
|
||||||
import { useModerationStore } from '~/store/moderation.ts'
|
import { useModerationStore } from '~/store/moderation.ts'
|
||||||
|
|
||||||
|
const { addNotification } = injectNotificationManager()
|
||||||
const formatRelativeTime = useRelativeTime()
|
const formatRelativeTime = useRelativeTime()
|
||||||
const moderationStore = useModerationStore()
|
const moderationStore = useModerationStore()
|
||||||
|
|
||||||
@@ -170,6 +155,49 @@ const daysInQueue = computed(() => {
|
|||||||
return getDaysQueued(queuedDate.value.toDate())
|
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() {
|
function openProjectForReview() {
|
||||||
moderationStore.setSingleProject(props.queueEntry.project.id)
|
moderationStore.setSingleProject(props.queueEntry.project.id)
|
||||||
navigateTo({
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,176 +1,287 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="universal-card">
|
<div class="overflow-hidden rounded-2xl">
|
||||||
<div
|
<div class="bg-bg-raised p-4">
|
||||||
class="flex w-full flex-col items-start justify-between gap-3 sm:flex-row sm:items-center sm:gap-0"
|
<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-md flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||||
Reported for
|
<span class="flex items-center gap-2">
|
||||||
<span class="whitespace-nowrap rounded-full align-middle font-semibold text-contrast">
|
<span class="text-secondary">Reported for</span>
|
||||||
{{ formattedReportType }}
|
<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>
|
</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">
|
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
|
||||||
<span class="text-md whitespace-nowrap text-secondary">{{
|
<span class="whitespace-nowrap text-sm text-secondary">{{
|
||||||
formatRelativeTime(report.created)
|
formatRelativeTime(report.created)
|
||||||
}}</span>
|
}}</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">
|
|
||||||
<ButtonStyled circular>
|
<ButtonStyled circular>
|
||||||
<nuxt-link :to="reportItemUrl">
|
<OverflowMenu :options="quickActions">
|
||||||
<EyeIcon />
|
<template #default>
|
||||||
</nuxt-link>
|
<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>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<CollapsibleRegion ref="collapsibleRegion" class="my-4">
|
<div class="my-4 h-px bg-surface-5" />
|
||||||
<ReportThread
|
|
||||||
v-if="report.thread"
|
<div class="flex items-center justify-between">
|
||||||
ref="reportThread"
|
<div class="flex items-center gap-4">
|
||||||
class="mb-16 sm:mb-0"
|
<Avatar
|
||||||
:thread="report.thread"
|
:src="reportItemAvatarUrl"
|
||||||
:report="report"
|
:circle="report.item_type === 'user'"
|
||||||
:reporter="report.reporter_user"
|
size="4rem"
|
||||||
@update-thread="updateThread"
|
: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>
|
</CollapsibleRegion>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
import {
|
||||||
|
CheckCircleIcon,
|
||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
EllipsisVerticalIcon,
|
EllipsisVerticalIcon,
|
||||||
EyeIcon,
|
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
OrganizationIcon,
|
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import { type ExtendedReport, reportQuickReplies } from '@modrinth/moderation'
|
||||||
type ExtendedReport,
|
import type { OverflowMenuOption } from '@modrinth/ui'
|
||||||
reportQuickReplies,
|
|
||||||
type ReportQuickReply,
|
|
||||||
} from '@modrinth/moderation'
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
CollapsibleRegion,
|
CollapsibleRegion,
|
||||||
|
getProjectTypeIcon,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
OverflowMenu,
|
OverflowMenu,
|
||||||
type OverflowMenuOption,
|
|
||||||
useRelativeTime,
|
useRelativeTime,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import { formatProjectType } from '@modrinth/utils'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
|
import { isStaff } from '~/helpers/users.js'
|
||||||
import ReportThread from '../thread/ReportThread.vue'
|
|
||||||
|
import ThreadView from '../thread/ThreadView.vue'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const auth = await useAuth()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
report: ExtendedReport
|
report: ExtendedReport
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const reportThread = ref<InstanceType<typeof ReportThread> | null>(null)
|
const reportThread = ref<{
|
||||||
const collapsibleRegion = ref<InstanceType<typeof CollapsibleRegion> | null>(null)
|
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()
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
|
||||||
|
function formatExactDate(date: string): string {
|
||||||
|
return dayjs(date).format('MMMM D, YYYY [at] h:mm A')
|
||||||
|
}
|
||||||
|
|
||||||
function updateThread(newThread: any) {
|
function updateThread(newThread: any) {
|
||||||
if (props.report.thread) {
|
if (props.report.thread) {
|
||||||
Object.assign(props.report.thread, newThread)
|
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(() => {
|
const reportItemAvatarUrl = computed(() => {
|
||||||
switch (props.report.item_type) {
|
switch (props.report.item_type) {
|
||||||
case 'project':
|
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 formattedReportType = computed(() => {
|
||||||
const reportType = props.report.report_type
|
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(' ')
|
return words.map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped></style>
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<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">
|
<div class="my-8 flex items-center justify-between">
|
||||||
<h2 class="m-0 mx-auto text-3xl font-extrabold sm:text-4xl">
|
<h2 class="m-0 mx-auto text-3xl font-extrabold sm:text-4xl">
|
||||||
{{ formatMessage(messages.latestNews) }}
|
{{ 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.
|
the mod.
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
class="mt-2 flex items-center gap-1"
|
class="mt-2 flex items-center gap-1"
|
||||||
:to="`/servers/manage/${props.serverId}/options/loader`"
|
:to="`/hosting/manage/${props.serverId}/options/loader`"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version
|
<ExternalIcon class="size-5 flex-none"></ExternalIcon> Modify modpack version
|
||||||
|
|||||||
@@ -68,26 +68,24 @@
|
|||||||
import {
|
import {
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
EditIcon,
|
EditIcon,
|
||||||
FileArchiveIcon,
|
|
||||||
FileIcon,
|
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
PackageOpenIcon,
|
PackageOpenIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { ButtonStyled } from '@modrinth/ui'
|
import {
|
||||||
import { computed, ref, shallowRef } from 'vue'
|
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 { renderToString } from 'vue/server-renderer'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import {
|
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
|
||||||
UiServersIconsCodeFileIcon,
|
|
||||||
UiServersIconsCogFolderIcon,
|
|
||||||
UiServersIconsEarthIcon,
|
|
||||||
UiServersIconsImageFileIcon,
|
|
||||||
UiServersIconsTextFileIcon,
|
|
||||||
} from '#components'
|
|
||||||
import PaletteIcon from '~/assets/icons/palette.svg?component'
|
import PaletteIcon from '~/assets/icons/palette.svg?component'
|
||||||
|
|
||||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||||
@@ -116,36 +114,6 @@ const emit = defineEmits<{
|
|||||||
const isDragOver = ref(false)
|
const isDragOver = ref(false)
|
||||||
const isDragging = 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 units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
|
||||||
|
|
||||||
const route = shallowRef(useRoute())
|
const route = shallowRef(useRoute())
|
||||||
@@ -199,12 +167,7 @@ const iconComponent = computed(() => {
|
|||||||
return FolderOpenIcon
|
return FolderOpenIcon
|
||||||
}
|
}
|
||||||
|
|
||||||
const ext = fileExtension.value
|
return getFileExtensionIcon(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
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const subText = computed(() => {
|
const subText = computed(() => {
|
||||||
@@ -245,9 +208,9 @@ const isEditableFile = computed(() => {
|
|||||||
const ext = fileExtension.value
|
const ext = fileExtension.value
|
||||||
return (
|
return (
|
||||||
!props.name.includes('.') ||
|
!props.name.includes('.') ||
|
||||||
textExtensions.includes(ext) ||
|
TEXT_EXTENSIONS.includes(ext) ||
|
||||||
codeExtensions.includes(ext) ||
|
CODE_EXTENSIONS.includes(ext) ||
|
||||||
imageExtensions.includes(ext)
|
IMAGE_EXTENSIONS.includes(ext)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
@@ -294,32 +257,7 @@ const selectItem = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getDragIcon = async () => {
|
const getDragIcon = async () => {
|
||||||
let iconToUse
|
return await renderToString(h(iconComponent.value))
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = async (event: DragEvent) => {
|
const handleDragStart = async (event: DragEvent) => {
|
||||||
|
|||||||
@@ -135,6 +135,6 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
const goHome = () => {
|
const goHome = () => {
|
||||||
emit('cancel')
|
emit('cancel')
|
||||||
router.push({ path: '/servers/manage/' + route.params.id + '/files' })
|
router.push({ path: '/hosting/manage/' + route.params.id + '/files' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
/>
|
/>
|
||||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||||
</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">
|
<div class="flex justify-start gap-2">
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
|
<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 { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
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 LoadingIcon from './icons/LoadingIcon.vue'
|
||||||
import PanelSpinner from './PanelSpinner.vue'
|
import PanelSpinner from './PanelSpinner.vue'
|
||||||
@@ -214,7 +214,7 @@ const menuOptions = computed(() => [
|
|||||||
id: 'allServers',
|
id: 'allServers',
|
||||||
label: 'All servers',
|
label: 'All servers',
|
||||||
icon: ServerIcon,
|
icon: ServerIcon,
|
||||||
action: () => router.push('/servers/manage'),
|
action: () => router.push('/hosting/manage'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'details',
|
id: 'details',
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BackupWarning :backup-link="`/servers/manage/${props.server?.serverId}/backups`" />
|
<BackupWarning :backup-link="`/hosting/manage/${props.server?.serverId}/backups`" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 flex justify-start gap-4">
|
<div class="mt-4 flex justify-start gap-4">
|
||||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||||
@@ -133,7 +133,7 @@ import { ModrinthServersFetchError } from '@modrinth/utils'
|
|||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers'
|
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()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
|
|
||||||
<BackupWarning
|
<BackupWarning
|
||||||
v-if="!initialSetup"
|
v-if="!initialSetup"
|
||||||
:backup-link="`/servers/manage/${props.server?.serverId}/backups`"
|
:backup-link="`/hosting/manage/${props.server?.serverId}/backups`"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -218,7 +218,7 @@ import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
|
|||||||
import { $fetch } from 'ofetch'
|
import { $fetch } from 'ofetch'
|
||||||
|
|
||||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
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 LoaderIcon from './icons/LoaderIcon.vue'
|
||||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
Switch modpack
|
Switch modpack
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</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" />
|
<TransferIcon class="size-4" />
|
||||||
Switch modpack
|
Switch modpack
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
v-tooltip="backupInProgress ? formatMessage(backupInProgress.tooltip) : undefined"
|
||||||
:class="{ disabled: backupInProgress }"
|
:class="{ disabled: backupInProgress }"
|
||||||
class="!w-full sm:!w-auto"
|
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
|
<CompassIcon class="size-4" /> Find a modpack
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -163,7 +163,7 @@ import { ButtonStyled, NewProjectCard } from '@modrinth/ui'
|
|||||||
import type { Loaders } from '@modrinth/utils'
|
import type { Loaders } from '@modrinth/utils'
|
||||||
|
|
||||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
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 LoaderSelector from './LoaderSelector.vue'
|
||||||
import PlatformChangeModpackVersionModal from './PlatformChangeModpackVersionModal.vue'
|
import PlatformChangeModpackVersionModal from './PlatformChangeModpackVersionModal.vue'
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ import { RightArrowIcon } from '@modrinth/assets'
|
|||||||
import type { RouteLocationNormalized } from 'vue-router'
|
import type { RouteLocationNormalized } from 'vue-router'
|
||||||
|
|
||||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
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'])
|
const emit = defineEmits(['reinstall'])
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nuxt-link
|
<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="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'"
|
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,232 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg
|
<div v-if="loaderData?.icon" v-html="loaderData.icon" />
|
||||||
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>
|
|
||||||
<LoaderIcon v-else />
|
<LoaderIcon v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LoaderIcon } from '@modrinth/assets'
|
import { LoaderIcon } from '@modrinth/assets'
|
||||||
import type { Loaders } from '@modrinth/utils'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
defineProps<{
|
import { useGeneratedState } from '~/composables/generated'
|
||||||
loader: Loaders
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const billingMonths = computed(() => {
|
|||||||
:ram="ram"
|
:ram="ram"
|
||||||
:storage="storage"
|
:storage="storage"
|
||||||
:cpus="cpus"
|
:cpus="cpus"
|
||||||
:bursting-link="'/servers#cpu-burst'"
|
:bursting-link="'/hosting#cpu-burst'"
|
||||||
@click-bursting-link="() => emit('scroll-to-faq')"
|
@click-bursting-link="() => emit('scroll-to-faq')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -217,6 +217,14 @@
|
|||||||
hoverFilled: true,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'withheld',
|
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,
|
hoverFilled: true,
|
||||||
disabled: project.status === 'withheld',
|
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" />
|
<EyeOffIcon aria-hidden="true" />
|
||||||
Withhold
|
Withhold
|
||||||
</template>
|
</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>
|
</OverflowMenu>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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="{
|
:class="{
|
||||||
'has-body': message.body.type === 'text' && !forceCompact,
|
'has-body': message.body.type === 'text' && !forceCompact,
|
||||||
'no-actions': noLinks,
|
'no-actions': noLinks,
|
||||||
private: message.body.private,
|
private: isPrivateMessage,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<template v-if="members[message.author_id]">
|
<template v-if="members[message.author_id]">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</AutoLink>
|
</AutoLink>
|
||||||
<span :class="`message__author role-${members[message.author_id].role}`">
|
<span :class="`message__author role-${members[message.author_id].role}`">
|
||||||
<LockIcon
|
<LockIcon
|
||||||
v-if="message.body.private"
|
v-if="isPrivateMessage"
|
||||||
v-tooltip="'Only visible to moderators'"
|
v-tooltip="'Only visible to moderators'"
|
||||||
class="private-icon"
|
class="private-icon"
|
||||||
/>
|
/>
|
||||||
@@ -40,13 +40,30 @@
|
|||||||
v-tooltip="'Reporter'"
|
v-tooltip="'Reporter'"
|
||||||
class="reporter-icon"
|
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>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<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 />
|
<ScaleIcon />
|
||||||
</div>
|
</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
|
Moderator
|
||||||
<ScaleIcon v-tooltip="'Moderator'" />
|
<ScaleIcon v-tooltip="'Moderator'" />
|
||||||
</span>
|
</span>
|
||||||
@@ -69,6 +86,17 @@
|
|||||||
</template>
|
</template>
|
||||||
<span v-else-if="message.body.type === 'thread_closure'">closed the thread.</span>
|
<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 === '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>
|
</div>
|
||||||
<span class="message__date">
|
<span class="message__date">
|
||||||
<span v-tooltip="$dayjs(message.created).format('MMMM D, YYYY [at] h:mm A')">
|
<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 formatRelativeTime = useRelativeTime()
|
||||||
const timeSincePosted = ref(formatRelativeTime(props.message.created))
|
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() {
|
async function deleteMessage() {
|
||||||
await useBaseFetch(`message/${props.message.id}`, {
|
await useBaseFetch(`message/${props.message.id}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
@@ -333,4 +370,8 @@ a:active + .message__author a,
|
|||||||
.private {
|
.private {
|
||||||
color: var(--color-icon);
|
color: var(--color-icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-message-icon {
|
||||||
|
--size: 2rem !important;
|
||||||
|
}
|
||||||
</style>
|
</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,
|
newProjectGeneralSettings: false,
|
||||||
newProjectEnvironmentSettings: true,
|
newProjectEnvironmentSettings: true,
|
||||||
hideRussiaCensorshipBanner: false,
|
hideRussiaCensorshipBanner: false,
|
||||||
|
serverDiscovery: false,
|
||||||
// advancedRendering: true,
|
// advancedRendering: true,
|
||||||
// externalLinksNewTab: true,
|
// externalLinksNewTab: true,
|
||||||
// notUsingBlockers: false,
|
// notUsingBlockers: false,
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { ISO3166, Labrinth } from '@modrinth/api-client'
|
import type { ISO3166, Labrinth } from '@modrinth/api-client'
|
||||||
|
import type { DisplayProjectType } from '@modrinth/utils'
|
||||||
|
|
||||||
import generatedState from '~/generated/state.json'
|
import generatedState from '~/generated/state.json'
|
||||||
|
import type { DisplayMode } from '~/plugins/cosmetics'
|
||||||
|
|
||||||
export interface ProjectType {
|
export interface ProjectType {
|
||||||
actual: string
|
actual: string
|
||||||
id: string
|
id: DisplayProjectType
|
||||||
display: string
|
display: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -25,7 +27,7 @@ export interface GeneratedState extends Labrinth.State.GeneratedState {
|
|||||||
// Additional runtime-defined fields not from the API
|
// Additional runtime-defined fields not from the API
|
||||||
projectTypes: ProjectType[]
|
projectTypes: ProjectType[]
|
||||||
loaderData: LoaderData
|
loaderData: LoaderData
|
||||||
projectViewModes: string[]
|
projectViewModes: DisplayMode[]
|
||||||
approvedStatuses: string[]
|
approvedStatuses: string[]
|
||||||
rejectedStatuses: string[]
|
rejectedStatuses: string[]
|
||||||
staffRoles: string[]
|
staffRoles: string[]
|
||||||
|
|||||||
@@ -54,12 +54,12 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
const motd = await this.getMotd()
|
const motd = await this.getMotd()
|
||||||
if (motd === 'A Minecraft Server') {
|
if (motd === 'A Minecraft Server') {
|
||||||
await this.setMotd(
|
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
|
data.motd = motd
|
||||||
} catch {
|
} catch {
|
||||||
console.error('[Modrinth Servers] [General] Failed to fetch MOTD.')
|
console.error('[Modrinth Hosting] [General] Failed to fetch MOTD.')
|
||||||
data.motd = undefined
|
data.motd = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +224,7 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
console.error(
|
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 type { V1ErrorInfo } from '@modrinth/utils'
|
||||||
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
|
import { ModrinthServerError, ModrinthServersFetchError } from '@modrinth/utils'
|
||||||
import { $fetch, FetchError } from 'ofetch'
|
import { $fetch, FetchError } from 'ofetch'
|
||||||
@@ -27,7 +28,7 @@ export async function useServersFetch<T>(
|
|||||||
|
|
||||||
if (!authToken && !options.bypassAuth) {
|
if (!authToken && !options.bypassAuth) {
|
||||||
const error = new ModrinthServersFetchError(
|
const error = new ModrinthServersFetchError(
|
||||||
'[Modrinth Servers] Cannot fetch without auth',
|
'[Modrinth Hosting] Cannot fetch without auth',
|
||||||
10000,
|
10000,
|
||||||
)
|
)
|
||||||
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
|
throw new ModrinthServerError('Missing auth token', 401, error, module, undefined, undefined)
|
||||||
@@ -49,7 +50,7 @@ export async function useServersFetch<T>(
|
|||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
|
if (failureCount.value >= 3 && now - lastFailureTime.value < 30000) {
|
||||||
const error = new ModrinthServersFetchError(
|
const error = new ModrinthServersFetchError(
|
||||||
'[Modrinth Servers] Circuit breaker open - too many recent failures',
|
'[Modrinth Hosting] Circuit breaker open - too many recent failures',
|
||||||
503,
|
503,
|
||||||
)
|
)
|
||||||
throw new ModrinthServerError(
|
throw new ModrinthServerError(
|
||||||
@@ -73,7 +74,7 @@ export async function useServersFetch<T>(
|
|||||||
|
|
||||||
if (!base) {
|
if (!base) {
|
||||||
const error = new ModrinthServersFetchError(
|
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,
|
10001,
|
||||||
)
|
)
|
||||||
throw new ModrinthServerError(
|
throw new ModrinthServerError(
|
||||||
@@ -103,6 +104,7 @@ export async function useServersFetch<T>(
|
|||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
|
'User-Agent': 'Modrinth/1.0 (https://modrinth.com)',
|
||||||
'X-Archon-Request': 'true',
|
'X-Archon-Request': 'true',
|
||||||
|
'X-Panel-Version': String(PANEL_VERSION),
|
||||||
Vary: 'Accept, Origin',
|
Vary: 'Accept, Origin',
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,12 +185,12 @@ export async function useServersFetch<T>(
|
|||||||
console.error('Fetch error:', error)
|
console.error('Fetch error:', error)
|
||||||
|
|
||||||
const fetchError = new ModrinthServersFetchError(
|
const fetchError = new ModrinthServersFetchError(
|
||||||
`[Modrinth Servers] ${error.message}`,
|
`[Modrinth Hosting] ${error.message}`,
|
||||||
statusCode,
|
statusCode,
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
throw new ModrinthServerError(
|
throw new ModrinthServerError(
|
||||||
`[Modrinth Servers] ${message}`,
|
`[Modrinth Hosting] ${message}`,
|
||||||
statusCode,
|
statusCode,
|
||||||
fetchError,
|
fetchError,
|
||||||
module,
|
module,
|
||||||
@@ -206,7 +208,7 @@ export async function useServersFetch<T>(
|
|||||||
|
|
||||||
console.error('Unexpected fetch error:', error)
|
console.error('Unexpected fetch error:', error)
|
||||||
const fetchError = new ModrinthServersFetchError(
|
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,
|
undefined,
|
||||||
error as Error,
|
error as Error,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -52,7 +52,12 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { SadRinthbot } from '@modrinth/assets'
|
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 { defineMessage, useVIntl } from '@vintl/vintl'
|
||||||
import { IntlFormatted } from '@vintl/vintl/components'
|
import { IntlFormatted } from '@vintl/vintl/components'
|
||||||
|
|
||||||
@@ -73,6 +78,10 @@ const client = createModrinthClient(auth.value, {
|
|||||||
rateLimitKey: config.rateLimitKey,
|
rateLimitKey: config.rateLimitKey,
|
||||||
})
|
})
|
||||||
provideModrinthClient(client)
|
provideModrinthClient(client)
|
||||||
|
providePageContext({
|
||||||
|
hierarchicalSidebarAvailable: ref(false),
|
||||||
|
showAds: ref(false),
|
||||||
|
})
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
NuxtCircuitBreakerStorage,
|
NuxtCircuitBreakerStorage,
|
||||||
type NuxtClientConfig,
|
type NuxtClientConfig,
|
||||||
NuxtModrinthClient,
|
NuxtModrinthClient,
|
||||||
|
PanelVersionFeature,
|
||||||
VerboseLoggingFeature,
|
VerboseLoggingFeature,
|
||||||
} from '@modrinth/api-client'
|
} from '@modrinth/api-client'
|
||||||
import type { Ref } from 'vue'
|
import type { Ref } from 'vue'
|
||||||
@@ -31,6 +32,7 @@ export function createModrinthClient(
|
|||||||
maxFailures: 3,
|
maxFailures: 3,
|
||||||
resetTimeout: 30000,
|
resetTimeout: 30000,
|
||||||
}),
|
}),
|
||||||
|
new PanelVersionFeature(),
|
||||||
...optionalFeatures,
|
...optionalFeatures,
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -204,7 +204,7 @@
|
|||||||
<template #description>
|
<template #description>
|
||||||
{{
|
{{
|
||||||
formatMessage(failedToBuildBannerMessages.description, {
|
formatMessage(failedToBuildBannerMessages.description, {
|
||||||
errors: generatedStateErrors,
|
errors: JSON.stringify(generatedStateErrors),
|
||||||
url: config.public.apiBaseUrl,
|
url: config.public.apiBaseUrl,
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
@@ -237,12 +237,12 @@
|
|||||||
<template v-if="flags.projectTypesPrimaryNav">
|
<template v-if="flags.projectTypesPrimaryNav">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
:highlighted="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
|
||||||
:highlighted-style="
|
: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" />
|
<BoxIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.mod) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.mod) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -250,61 +250,63 @@
|
|||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="
|
:highlighted="
|
||||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||||
"
|
"
|
||||||
:highlighted-style="
|
: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" />
|
<PaintbrushIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.resourcepack) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.resourcepack) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-datapacks' || route.path.startsWith('/datapack/')"
|
:highlighted="
|
||||||
|
route.name === 'discover-datapacks' || route.path.startsWith('/datapack/')
|
||||||
|
"
|
||||||
:highlighted-style="
|
: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" />
|
<BracesIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.datapack) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-modpacks' || route.path.startsWith('/modpack/')"
|
:highlighted="route.name === 'discover-modpacks' || route.path.startsWith('/modpack/')"
|
||||||
:highlighted-style="
|
: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" />
|
<PackageOpenIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-shaders' || route.path.startsWith('/shader/')"
|
:highlighted="route.name === 'discover-shaders' || route.path.startsWith('/shader/')"
|
||||||
:highlighted-style="
|
: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" />
|
<GlassesIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.shader) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="route.name === 'search-plugins' || route.path.startsWith('/plugin/')"
|
:highlighted="route.name === 'discover-plugins' || route.path.startsWith('/plugin/')"
|
||||||
:highlighted-style="
|
: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" />
|
<PlugIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.plugin) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.plugin) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -320,55 +322,66 @@
|
|||||||
:options="[
|
:options="[
|
||||||
{
|
{
|
||||||
id: 'mods',
|
id: 'mods',
|
||||||
action: '/mods',
|
action: '/discover/mods',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'resourcepacks',
|
id: 'resourcepacks',
|
||||||
action: '/resourcepacks',
|
action: '/discover/resourcepacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'datapacks',
|
id: 'datapacks',
|
||||||
action: '/datapacks',
|
action: '/discover/datapacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'shaders',
|
id: 'shaders',
|
||||||
action: '/shaders',
|
action: '/discover/shaders',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'modpacks',
|
id: 'modpacks',
|
||||||
action: '/modpacks',
|
action: '/discover/modpacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'plugins',
|
id: 'plugins',
|
||||||
action: '/plugins',
|
action: '/discover/plugins',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'servers',
|
||||||
|
action: '/discover/servers',
|
||||||
|
shown: flags.serverDiscovery,
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
hoverable
|
hoverable
|
||||||
>
|
>
|
||||||
<BoxIcon
|
<BoxIcon
|
||||||
v-if="route.name === 'search-mods' || route.path.startsWith('/mod/')"
|
v-if="route.name === 'discover-mods' || route.path.startsWith('/mod/')"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<PaintbrushIcon
|
<PaintbrushIcon
|
||||||
v-else-if="
|
v-else-if="
|
||||||
route.name === 'search-resourcepacks' || route.path.startsWith('/resourcepack/')
|
route.name === 'discover-resourcepacks' || route.path.startsWith('/resourcepack/')
|
||||||
"
|
"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<BracesIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<PackageOpenIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<GlassesIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<PlugIcon
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<CompassIcon v-else aria-hidden="true" />
|
<CompassIcon v-else aria-hidden="true" />
|
||||||
@@ -402,19 +415,23 @@
|
|||||||
<PackageOpenIcon aria-hidden="true" />
|
<PackageOpenIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
|
{{ formatMessage(commonProjectTypeCategoryMessages.modpack) }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #servers>
|
||||||
|
<ServerIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(commonProjectTypeCategoryMessages.server) }}
|
||||||
|
</template>
|
||||||
</TeleportOverflowMenu>
|
</TeleportOverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
type="transparent"
|
type="transparent"
|
||||||
:highlighted="
|
:highlighted="
|
||||||
route.name?.startsWith('servers') ||
|
route.name?.startsWith('hosting') ||
|
||||||
(route.name?.startsWith('search-') && route.query.sid)
|
(route.name?.startsWith('discover-') && !!route.query.sid)
|
||||||
"
|
"
|
||||||
:highlighted-style="
|
: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" />
|
<ServerIcon aria-hidden="true" />
|
||||||
{{ formatMessage(navMenuMessages.hostAServer) }}
|
{{ formatMessage(navMenuMessages.hostAServer) }}
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -447,6 +464,11 @@
|
|||||||
color: 'orange',
|
color: 'orange',
|
||||||
link: '/moderation/',
|
link: '/moderation/',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'tech-review',
|
||||||
|
color: 'orange',
|
||||||
|
link: '/moderation/technical-review',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'review-reports',
|
id: 'review-reports',
|
||||||
color: 'orange',
|
color: 'orange',
|
||||||
@@ -494,6 +516,9 @@
|
|||||||
<template #review-projects>
|
<template #review-projects>
|
||||||
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProjects) }}
|
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProjects) }}
|
||||||
</template>
|
</template>
|
||||||
|
<template #tech-review>
|
||||||
|
<ShieldAlertIcon aria-hidden="true" /> {{ formatMessage(messages.techReview) }}
|
||||||
|
</template>
|
||||||
<template #review-reports>
|
<template #review-reports>
|
||||||
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
|
<ReportIcon aria-hidden="true" /> {{ formatMessage(messages.reports) }}
|
||||||
</template>
|
</template>
|
||||||
@@ -683,7 +708,7 @@
|
|||||||
<LibraryIcon class="icon" />
|
<LibraryIcon class="icon" />
|
||||||
{{ formatMessage(commonMessages.collectionsLabel) }}
|
{{ formatMessage(commonMessages.collectionsLabel) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink class="iconified-button" to="/servers/manage">
|
<NuxtLink class="iconified-button" to="/hosting/manage">
|
||||||
<ServerIcon class="icon" />
|
<ServerIcon class="icon" />
|
||||||
{{ formatMessage(commonMessages.serversLabel) }}
|
{{ formatMessage(commonMessages.serversLabel) }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
@@ -925,6 +950,7 @@ import {
|
|||||||
SearchIcon,
|
SearchIcon,
|
||||||
ServerIcon,
|
ServerIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
|
ShieldAlertIcon,
|
||||||
SunIcon,
|
SunIcon,
|
||||||
TwitterIcon,
|
TwitterIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
@@ -1180,11 +1206,15 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
reviewProjects: {
|
reviewProjects: {
|
||||||
id: 'layout.action.review-projects',
|
id: 'layout.action.review-projects',
|
||||||
defaultMessage: 'Review projects',
|
defaultMessage: 'Project review',
|
||||||
|
},
|
||||||
|
techReview: {
|
||||||
|
id: 'layout.action.tech-review',
|
||||||
|
defaultMessage: 'Tech review',
|
||||||
},
|
},
|
||||||
reports: {
|
reports: {
|
||||||
id: 'layout.action.reports',
|
id: 'layout.action.reports',
|
||||||
defaultMessage: 'Reports',
|
defaultMessage: 'Review reports',
|
||||||
},
|
},
|
||||||
lookupByEmail: {
|
lookupByEmail: {
|
||||||
id: 'layout.action.lookup-by-email',
|
id: 'layout.action.lookup-by-email',
|
||||||
@@ -1328,27 +1358,27 @@ const navRoutes = computed(() => [
|
|||||||
{
|
{
|
||||||
id: 'mods',
|
id: 'mods',
|
||||||
label: formatMessage(getProjectTypeMessage('mod', true)),
|
label: formatMessage(getProjectTypeMessage('mod', true)),
|
||||||
href: '/mods',
|
href: '/discover/mods',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('plugin', true)),
|
label: formatMessage(getProjectTypeMessage('plugin', true)),
|
||||||
href: '/plugins',
|
href: '/discover/plugins',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('datapack', true)),
|
label: formatMessage(getProjectTypeMessage('datapack', true)),
|
||||||
href: '/datapacks',
|
href: '/discover/datapacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('shader', true)),
|
label: formatMessage(getProjectTypeMessage('shader', true)),
|
||||||
href: '/shaders',
|
href: '/discover/shaders',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
|
label: formatMessage(getProjectTypeMessage('resourcepack', true)),
|
||||||
href: '/resourcepacks',
|
href: '/discover/resourcepacks',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: formatMessage(getProjectTypeMessage('modpack', true)),
|
label: formatMessage(getProjectTypeMessage('modpack', true)),
|
||||||
href: '/modpacks',
|
href: '/discover/modpacks',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -1366,7 +1396,7 @@ const userMenuOptions = computed(() => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'servers',
|
id: 'servers',
|
||||||
link: '/servers/manage',
|
link: '/hosting/manage',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'flags',
|
id: 'flags',
|
||||||
@@ -1439,7 +1469,7 @@ const userMenuOptions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const isDiscovering = 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(
|
const isDiscoveringSubpage = computed(
|
||||||
@@ -1455,7 +1485,7 @@ const disableRandomProjects = ref(false)
|
|||||||
|
|
||||||
const disableRandomProjectsForRoute = computed(
|
const disableRandomProjectsForRoute = computed(
|
||||||
() =>
|
() =>
|
||||||
route.name.startsWith('servers') ||
|
route.name.startsWith('hosting') ||
|
||||||
route.name.includes('settings') ||
|
route.name.includes('settings') ||
|
||||||
route.name.includes('admin'),
|
route.name.includes('admin'),
|
||||||
)
|
)
|
||||||
@@ -1685,11 +1715,11 @@ const footerLinks = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
href: '/servers',
|
href: '/hosting',
|
||||||
label: formatMessage(
|
label: formatMessage(
|
||||||
defineMessage({
|
defineMessage({
|
||||||
id: 'layout.footer.products.servers',
|
id: 'layout.footer.products.servers',
|
||||||
defaultMessage: 'Modrinth Servers',
|
defaultMessage: 'Modrinth Hosting',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -365,20 +365,26 @@
|
|||||||
"auth.welcome.title": {
|
"auth.welcome.title": {
|
||||||
"message": "Welcome"
|
"message": "Welcome"
|
||||||
},
|
},
|
||||||
"collection.button.delete-icon": {
|
|
||||||
"message": "Delete icon"
|
|
||||||
},
|
|
||||||
"collection.button.edit-icon": {
|
"collection.button.edit-icon": {
|
||||||
"message": "Edit icon"
|
"message": "Edit icon"
|
||||||
},
|
},
|
||||||
|
"collection.button.remove-icon": {
|
||||||
|
"message": "Remove icon"
|
||||||
|
},
|
||||||
"collection.button.remove-project": {
|
"collection.button.remove-project": {
|
||||||
"message": "Remove project"
|
"message": "Remove project"
|
||||||
},
|
},
|
||||||
|
"collection.button.replace-icon": {
|
||||||
|
"message": "Replace icon"
|
||||||
|
},
|
||||||
|
"collection.button.select-icon": {
|
||||||
|
"message": "Select icon"
|
||||||
|
},
|
||||||
"collection.button.unfollow-project": {
|
"collection.button.unfollow-project": {
|
||||||
"message": "Unfollow project"
|
"message": "Unfollow project"
|
||||||
},
|
},
|
||||||
"collection.delete-modal.description": {
|
"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": {
|
"collection.delete-modal.title": {
|
||||||
"message": "Are you sure you want to delete this collection?"
|
"message": "Are you sure you want to delete this collection?"
|
||||||
@@ -389,33 +395,39 @@
|
|||||||
"collection.description.following": {
|
"collection.description.following": {
|
||||||
"message": "Auto-generated collection of all the projects you're following."
|
"message": "Auto-generated collection of all the projects you're following."
|
||||||
},
|
},
|
||||||
|
"collection.editing": {
|
||||||
|
"message": "Editing collection"
|
||||||
|
},
|
||||||
"collection.error.not-found": {
|
"collection.error.not-found": {
|
||||||
"message": "Collection not found"
|
"message": "Collection not found"
|
||||||
},
|
},
|
||||||
"collection.label.collection": {
|
|
||||||
"message": "Collection"
|
|
||||||
},
|
|
||||||
"collection.label.created-at": {
|
"collection.label.created-at": {
|
||||||
"message": "Created {ago}"
|
"message": "Created {ago}"
|
||||||
},
|
},
|
||||||
"collection.label.curated-by": {
|
"collection.label.curated-by": {
|
||||||
"message": "Curated by"
|
"message": "Curated by"
|
||||||
},
|
},
|
||||||
|
"collection.label.description": {
|
||||||
|
"message": "Description"
|
||||||
|
},
|
||||||
|
"collection.label.details": {
|
||||||
|
"message": "Details"
|
||||||
|
},
|
||||||
"collection.label.no-projects": {
|
"collection.label.no-projects": {
|
||||||
"message": "This collection has no projects!"
|
"message": "No projects in collection yet"
|
||||||
},
|
|
||||||
"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"
|
|
||||||
},
|
},
|
||||||
"collection.label.projects-count": {
|
"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": {
|
"collection.label.updated-at": {
|
||||||
"message": "Updated {ago}"
|
"message": "Updated {ago}"
|
||||||
},
|
},
|
||||||
|
"collection.return-link.dashboard-collections": {
|
||||||
|
"message": "Your collections"
|
||||||
|
},
|
||||||
|
"collection.return-link.user": {
|
||||||
|
"message": "{user}'s profile"
|
||||||
|
},
|
||||||
"collection.title": {
|
"collection.title": {
|
||||||
"message": "{name} - Collection"
|
"message": "{name} - Collection"
|
||||||
},
|
},
|
||||||
@@ -425,6 +437,9 @@
|
|||||||
"common.yes": {
|
"common.yes": {
|
||||||
"message": "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": {
|
"create.collection.cancel": {
|
||||||
"message": "Cancel"
|
"message": "Cancel"
|
||||||
},
|
},
|
||||||
@@ -653,9 +668,15 @@
|
|||||||
"dashboard.creator-withdraw-modal.fee-breakdown-fee": {
|
"dashboard.creator-withdraw-modal.fee-breakdown-fee": {
|
||||||
"message": "Fee"
|
"message": "Fee"
|
||||||
},
|
},
|
||||||
|
"dashboard.creator-withdraw-modal.fee-breakdown-gift-card-value": {
|
||||||
|
"message": "Gift card value"
|
||||||
|
},
|
||||||
"dashboard.creator-withdraw-modal.fee-breakdown-net-amount": {
|
"dashboard.creator-withdraw-modal.fee-breakdown-net-amount": {
|
||||||
"message": "Net amount"
|
"message": "Net amount"
|
||||||
},
|
},
|
||||||
|
"dashboard.creator-withdraw-modal.fee-breakdown-usd-equivalent": {
|
||||||
|
"message": "USD equivalent"
|
||||||
|
},
|
||||||
"dashboard.creator-withdraw-modal.kyc.business-entity": {
|
"dashboard.creator-withdraw-modal.kyc.business-entity": {
|
||||||
"message": "Business entity"
|
"message": "Business entity"
|
||||||
},
|
},
|
||||||
@@ -800,6 +821,18 @@
|
|||||||
"dashboard.creator-withdraw-modal.tax-form-required.header": {
|
"dashboard.creator-withdraw-modal.tax-form-required.header": {
|
||||||
"message": "Tax form required"
|
"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": {
|
"dashboard.creator-withdraw-modal.tremendous-details.payment-method": {
|
||||||
"message": "Payment method"
|
"message": "Payment method"
|
||||||
},
|
},
|
||||||
@@ -812,6 +845,15 @@
|
|||||||
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
|
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
|
||||||
"message": "Rewards"
|
"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": {
|
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
|
||||||
"message": "Unverified email"
|
"message": "Unverified email"
|
||||||
},
|
},
|
||||||
@@ -1239,10 +1281,13 @@
|
|||||||
"message": "New project"
|
"message": "New project"
|
||||||
},
|
},
|
||||||
"layout.action.reports": {
|
"layout.action.reports": {
|
||||||
"message": "Reports"
|
"message": "Review reports"
|
||||||
},
|
},
|
||||||
"layout.action.review-projects": {
|
"layout.action.review-projects": {
|
||||||
"message": "Review projects"
|
"message": "Project review"
|
||||||
|
},
|
||||||
|
"layout.action.tech-review": {
|
||||||
|
"message": "Tech review"
|
||||||
},
|
},
|
||||||
"layout.avatar.alt": {
|
"layout.avatar.alt": {
|
||||||
"message": "Your avatar"
|
"message": "Your avatar"
|
||||||
@@ -1359,7 +1404,7 @@
|
|||||||
"message": "Modrinth+"
|
"message": "Modrinth+"
|
||||||
},
|
},
|
||||||
"layout.footer.products.servers": {
|
"layout.footer.products.servers": {
|
||||||
"message": "Modrinth Servers"
|
"message": "Modrinth Hosting"
|
||||||
},
|
},
|
||||||
"layout.footer.resources": {
|
"layout.footer.resources": {
|
||||||
"message": "Resources"
|
"message": "Resources"
|
||||||
@@ -1481,9 +1526,6 @@
|
|||||||
"moderation.sort.by": {
|
"moderation.sort.by": {
|
||||||
"message": "Sort by"
|
"message": "Sort by"
|
||||||
},
|
},
|
||||||
"moderation.technical.search.placeholder": {
|
|
||||||
"message": "Search tech reviews..."
|
|
||||||
},
|
|
||||||
"muralpay.account-type.checking": {
|
"muralpay.account-type.checking": {
|
||||||
"message": "Checking"
|
"message": "Checking"
|
||||||
},
|
},
|
||||||
@@ -1781,6 +1823,9 @@
|
|||||||
"profile.details.label.email": {
|
"profile.details.label.email": {
|
||||||
"message": "Email"
|
"message": "Email"
|
||||||
},
|
},
|
||||||
|
"profile.details.label.email-verified": {
|
||||||
|
"message": "Email verified"
|
||||||
|
},
|
||||||
"profile.details.label.has-password": {
|
"profile.details.label.has-password": {
|
||||||
"message": "Has password"
|
"message": "Has password"
|
||||||
},
|
},
|
||||||
@@ -2001,7 +2046,7 @@
|
|||||||
"message": "Review project"
|
"message": "Review project"
|
||||||
},
|
},
|
||||||
"project.actions.servers-promo.description": {
|
"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": {
|
"project.actions.servers-promo.pricing": {
|
||||||
"message": "Starting at {price}<small> / month</small>"
|
"message": "Starting at {price}<small> / month</small>"
|
||||||
@@ -2219,12 +2264,6 @@
|
|||||||
"project.status.archived.message": {
|
"project.status.archived.message": {
|
||||||
"message": "{title} has been archived. {title} will not receive any further updates unless the author decides to unarchive the project."
|
"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": {
|
"project.versions.title": {
|
||||||
"message": "Versions"
|
"message": "Versions"
|
||||||
},
|
},
|
||||||
@@ -2558,48 +2597,9 @@
|
|||||||
"search.filter.locked.server.sync": {
|
"search.filter.locked.server.sync": {
|
||||||
"message": "Sync with server"
|
"message": "Sync with server"
|
||||||
},
|
},
|
||||||
"servers.backup.create.in-progress.tooltip": {
|
|
||||||
"message": "Backup creation in progress"
|
|
||||||
},
|
|
||||||
"servers.backup.restore.in-progress.tooltip": {
|
"servers.backup.restore.in-progress.tooltip": {
|
||||||
"message": "Backup restore in progress"
|
"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": {
|
"servers.notice.actions": {
|
||||||
"message": "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"
|
:versions="versions"
|
||||||
:current-member="currentMember"
|
:current-member="currentMember"
|
||||||
:is-settings="route.name.startsWith('type-id-settings')"
|
:is-settings="route.name.startsWith('type-id-settings')"
|
||||||
:route-name="route.name"
|
|
||||||
:set-processing="setProcessing"
|
:set-processing="setProcessing"
|
||||||
:collapsed="collapsedChecklist"
|
|
||||||
:toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
|
||||||
:all-members="allMembers"
|
:all-members="allMembers"
|
||||||
:update-members="updateMembers"
|
:update-members="updateMembers"
|
||||||
:auth="auth"
|
:auth="auth"
|
||||||
@@ -55,6 +52,7 @@
|
|||||||
:patch-project="patchProject"
|
:patch-project="patchProject"
|
||||||
:patch-icon="patchIcon"
|
:patch-icon="patchIcon"
|
||||||
:reset-project="resetProject"
|
:reset-project="resetProject"
|
||||||
|
:reset-versions="resetVersions"
|
||||||
:reset-organization="resetOrganization"
|
:reset-organization="resetOrganization"
|
||||||
:reset-members="resetMembers"
|
:reset-members="resetMembers"
|
||||||
:route="route"
|
:route="route"
|
||||||
@@ -418,7 +416,7 @@
|
|||||||
</AutomaticAccordion>
|
</AutomaticAccordion>
|
||||||
<ServersPromo
|
<ServersPromo
|
||||||
v-if="flags.showProjectPageDownloadModalServersPromo"
|
v-if="flags.showProjectPageDownloadModalServersPromo"
|
||||||
:link="`/servers#plan`"
|
:link="`/hosting#plan`"
|
||||||
@close="
|
@close="
|
||||||
() => {
|
() => {
|
||||||
flags.showProjectPageDownloadModalServersPromo = false
|
flags.showProjectPageDownloadModalServersPromo = false
|
||||||
@@ -447,14 +445,34 @@
|
|||||||
<div class="normal-page__header relative my-4">
|
<div class="normal-page__header relative my-4">
|
||||||
<ProjectHeader :project="project" :member="!!currentMember">
|
<ProjectHeader :project="project" :member="!!currentMember">
|
||||||
<template #actions>
|
<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">
|
<div class="hidden sm:contents">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
|
v-tooltip="
|
||||||
|
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
|
||||||
|
"
|
||||||
size="large"
|
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)">
|
<button @click="(event) => downloadModal.show(event)">
|
||||||
<DownloadIcon aria-hidden="true" />
|
<DownloadIcon aria-hidden="true" />
|
||||||
{{ formatMessage(commonMessages.downloadButton) }}
|
{{
|
||||||
|
auth.user && currentMember ? '' : formatMessage(commonMessages.downloadButton)
|
||||||
|
}}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -485,7 +503,7 @@
|
|||||||
<ButtonStyled size="large" circular>
|
<ButtonStyled size="large" circular>
|
||||||
<nuxt-link
|
<nuxt-link
|
||||||
v-tooltip="formatMessage(messages.createServerTooltip)"
|
v-tooltip="formatMessage(messages.createServerTooltip)"
|
||||||
:to="`/servers?project=${project.id}#plan`"
|
:to="`/hosting?project=${project.id}#plan`"
|
||||||
@click="
|
@click="
|
||||||
() => {
|
() => {
|
||||||
flags.showProjectPageCreateServersTooltip = false
|
flags.showProjectPageCreateServersTooltip = false
|
||||||
@@ -641,14 +659,7 @@
|
|||||||
<BookmarkIcon aria-hidden="true" />
|
<BookmarkIcon aria-hidden="true" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
</ButtonStyled>
|
</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">
|
<ButtonStyled size="large" circular type="transparent">
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
|
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
|
||||||
@@ -903,6 +914,7 @@
|
|||||||
v-model:organization="organization"
|
v-model:organization="organization"
|
||||||
:current-member="currentMember"
|
:current-member="currentMember"
|
||||||
:reset-project="resetProject"
|
:reset-project="resetProject"
|
||||||
|
:reset-versions="resetVersions"
|
||||||
:reset-organization="resetOrganization"
|
:reset-organization="resetOrganization"
|
||||||
:reset-members="resetMembers"
|
:reset-members="resetMembers"
|
||||||
:route="route"
|
:route="route"
|
||||||
@@ -1314,7 +1326,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
serversPromoDescription: {
|
serversPromoDescription: {
|
||||||
id: 'project.actions.servers-promo.description',
|
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: {
|
serversPromoPricing: {
|
||||||
id: 'project.actions.servers-promo.pricing',
|
id: 'project.actions.servers-promo.pricing',
|
||||||
@@ -1446,6 +1458,7 @@ let project,
|
|||||||
resetMembers,
|
resetMembers,
|
||||||
dependencies,
|
dependencies,
|
||||||
versions,
|
versions,
|
||||||
|
resetVersions,
|
||||||
organization,
|
organization,
|
||||||
resetOrganization,
|
resetOrganization,
|
||||||
projectV2Error,
|
projectV2Error,
|
||||||
@@ -1459,7 +1472,7 @@ try {
|
|||||||
{ data: projectV3, error: projectV3Error, refresh: resetProjectV3 },
|
{ data: projectV3, error: projectV3Error, refresh: resetProjectV3 },
|
||||||
{ data: allMembers, error: membersError, refresh: resetMembers },
|
{ data: allMembers, error: membersError, refresh: resetMembers },
|
||||||
{ data: dependencies, error: dependenciesError },
|
{ data: dependencies, error: dependenciesError },
|
||||||
{ data: versions, error: versionsError },
|
{ data: versions, error: versionsError, refresh: resetVersions },
|
||||||
{ data: organization, refresh: resetOrganization },
|
{ data: organization, refresh: resetOrganization },
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
useAsyncData(`project/${projectId.value}`, () => useBaseFetch(`project/${projectId.value}`), {
|
useAsyncData(`project/${projectId.value}`, () => useBaseFetch(`project/${projectId.value}`), {
|
||||||
@@ -1746,10 +1759,10 @@ async function patchProject(resData, quiet = false) {
|
|||||||
|
|
||||||
await updateProjectRoute()
|
await updateProjectRoute()
|
||||||
|
|
||||||
if (resData.license_id) {
|
if ('license_id' in resData) {
|
||||||
project.value.license.id = resData.license_id
|
project.value.license.id = resData.license_id
|
||||||
}
|
}
|
||||||
if (resData.license_url) {
|
if ('license_url' in resData) {
|
||||||
project.value.license.url = resData.license_url
|
project.value.license.url = resData.license_url
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1917,6 +1930,7 @@ provideProjectPageContext({
|
|||||||
projectV2: project,
|
projectV2: project,
|
||||||
projectV3,
|
projectV3,
|
||||||
refreshProject: resetProject,
|
refreshProject: resetProject,
|
||||||
|
refreshVersions: resetVersions,
|
||||||
currentMember,
|
currentMember,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
:href="version.primaryFile.url"
|
:href="version.primaryFile?.url"
|
||||||
class="iconified-button download"
|
class="iconified-button download"
|
||||||
:title="`Download ${version.name}`"
|
:title="`Download ${version.name}`"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -195,7 +195,36 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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
|
<FileInput
|
||||||
:max-size="5242880"
|
:max-size="5242880"
|
||||||
:accept="acceptFileTypes"
|
:accept="acceptFileTypes"
|
||||||
@@ -216,7 +245,7 @@
|
|||||||
@change="handleFiles"
|
@change="handleFiles"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div v-for="(item, index) in project.gallery" :key="index" class="card gallery-item">
|
||||||
<a class="gallery-thumbnail" @click="expandImage(item, index)">
|
<a class="gallery-thumbnail" @click="expandImage(item, index)">
|
||||||
<img
|
<img
|
||||||
@@ -273,6 +302,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -289,6 +327,7 @@ import {
|
|||||||
PlusIcon,
|
PlusIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
|
SettingsIcon,
|
||||||
StarIcon,
|
StarIcon,
|
||||||
TransferIcon,
|
TransferIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
@@ -296,12 +335,15 @@ import {
|
|||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
|
Admonition,
|
||||||
|
ButtonStyled,
|
||||||
ConfirmModal,
|
ConfirmModal,
|
||||||
DropArea,
|
DropArea,
|
||||||
FileInput,
|
FileInput,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
NewModal as Modal,
|
NewModal as Modal,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
|
|
||||||
import { isPermission } from '~/utils/permissions.ts'
|
import { isPermission } from '~/utils/permissions.ts'
|
||||||
|
|
||||||
@@ -334,6 +376,11 @@ useSeoMeta({
|
|||||||
ogTitle: title,
|
ogTitle: title,
|
||||||
ogDescription: description,
|
ogDescription: description,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hideGalleryAdmonition = useLocalStorage(
|
||||||
|
'hideGalleryHasMovedAdmonition',
|
||||||
|
!props.project.gallery.length,
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -3,12 +3,21 @@
|
|||||||
<div v-if="project.body" class="card">
|
<div v-if="project.body" class="card">
|
||||||
<ProjectPageDescription :description="project.body" />
|
<ProjectPageDescription :description="project.body" />
|
||||||
</div>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ProjectPageDescription } from '@modrinth/ui'
|
import { ProjectPageDescription } from '@modrinth/ui'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
project: {
|
project: {
|
||||||
type: Object,
|
type: Object,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
AlignLeftIcon,
|
AlignLeftIcon,
|
||||||
BookTextIcon,
|
BookTextIcon,
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
GlobeIcon,
|
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
InfoIcon,
|
InfoIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
@@ -11,11 +10,17 @@ import {
|
|||||||
UsersIcon,
|
UsersIcon,
|
||||||
VersionIcon,
|
VersionIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { commonMessages, commonProjectSettingsMessages } from '@modrinth/ui'
|
import {
|
||||||
|
commonMessages,
|
||||||
|
commonProjectSettingsMessages,
|
||||||
|
injectNotificationManager,
|
||||||
|
} from '@modrinth/ui'
|
||||||
import type { Project, ProjectV3Partial } from '@modrinth/utils'
|
import type { Project, ProjectV3Partial } from '@modrinth/utils'
|
||||||
import { useVIntl } from '@vintl/vintl'
|
import { useVIntl } from '@vintl/vintl'
|
||||||
|
import { useLocalStorage, useScroll } from '@vueuse/core'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
||||||
import NavStack from '~/components/ui/NavStack.vue'
|
import NavStack from '~/components/ui/NavStack.vue'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
@@ -25,6 +30,7 @@ defineProps<{
|
|||||||
patchProject: any
|
patchProject: any
|
||||||
patchIcon: any
|
patchIcon: any
|
||||||
resetProject: any
|
resetProject: any
|
||||||
|
resetVersions: any
|
||||||
resetOrganization: any
|
resetOrganization: any
|
||||||
resetMembers: any
|
resetMembers: any
|
||||||
}>()
|
}>()
|
||||||
@@ -55,15 +61,6 @@ const navItems = computed(() => {
|
|||||||
icon: InfoIcon,
|
icon: InfoIcon,
|
||||||
}
|
}
|
||||||
: null,
|
: 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`,
|
link: `/${base}/settings/tags`,
|
||||||
label: formatMessage(commonProjectSettingsMessages.tags),
|
label: formatMessage(commonProjectSettingsMessages.tags),
|
||||||
@@ -74,11 +71,21 @@ const navItems = computed(() => {
|
|||||||
label: formatMessage(commonProjectSettingsMessages.description),
|
label: formatMessage(commonProjectSettingsMessages.description),
|
||||||
icon: AlignLeftIcon,
|
icon: AlignLeftIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
link: `/${base}/settings/versions`,
|
||||||
|
label: formatMessage(commonProjectSettingsMessages.versions),
|
||||||
|
icon: VersionIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
link: `/${base}/settings/license`,
|
link: `/${base}/settings/license`,
|
||||||
label: formatMessage(commonProjectSettingsMessages.license),
|
label: formatMessage(commonProjectSettingsMessages.license),
|
||||||
icon: BookTextIcon,
|
icon: BookTextIcon,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
link: `/${base}/settings/gallery`,
|
||||||
|
label: formatMessage(commonProjectSettingsMessages.gallery),
|
||||||
|
icon: ImageIcon,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
link: `/${base}/settings/links`,
|
link: `/${base}/settings/links`,
|
||||||
label: formatMessage(commonProjectSettingsMessages.links),
|
label: formatMessage(commonProjectSettingsMessages.links),
|
||||||
@@ -89,51 +96,91 @@ const navItems = computed(() => {
|
|||||||
label: formatMessage(commonProjectSettingsMessages.members),
|
label: formatMessage(commonProjectSettingsMessages.members),
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
},
|
},
|
||||||
{ type: 'heading', label: formatMessage(commonProjectSettingsMessages.view) },
|
|
||||||
{
|
{
|
||||||
link: `/${base}/settings/analytics`,
|
link: `/${base}/settings/analytics`,
|
||||||
label: formatMessage(commonProjectSettingsMessages.analytics),
|
label: formatMessage(commonProjectSettingsMessages.analytics),
|
||||||
icon: ChartIcon,
|
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[]
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="experimental-styles-within grid gap-4 lg:grid-cols-[1fr_3fr]">
|
<div class="mb-8 flex w-full flex-col gap-4">
|
||||||
<div>
|
<ModerationProjectNags
|
||||||
<NavStack :items="navItems" />
|
v-if="
|
||||||
</div>
|
(currentMember && project.status === 'draft') ||
|
||||||
<div class="min-w-0">
|
tags.rejectedStatuses.includes(project.status)
|
||||||
<NuxtPage
|
"
|
||||||
v-model:project="project"
|
:project="project"
|
||||||
v-model:project-v3="projectV3"
|
:versions="versions"
|
||||||
v-model:versions="versions"
|
:current-member="currentMember"
|
||||||
v-model:members="members"
|
:collapsed="collapsedChecklist"
|
||||||
v-model:all-members="allMembers"
|
:route-name="route.name as string"
|
||||||
v-model:dependencies="dependencies"
|
:tags="tags"
|
||||||
v-model:organization="organization"
|
@toggle-collapsed="() => (collapsedChecklist = !collapsedChecklist)"
|
||||||
:current-member="currentMember"
|
@set-processing="setProcessing"
|
||||||
:patch-project="patchProject"
|
/>
|
||||||
:patch-icon="patchIcon"
|
<div class="experimental-styles-within grid gap-4 lg:grid-cols-[1fr_3fr]">
|
||||||
:reset-project="resetProject"
|
<div>
|
||||||
:reset-organization="resetOrganization"
|
<NavStack :items="navItems" />
|
||||||
:reset-members="resetMembers"
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user