You've already forked AstralRinth
forked from didirus/AstralRinth
Merge tag 'v0.10.27' into beta
This commit is contained in:
@@ -2,7 +2,9 @@
|
||||
**/dist
|
||||
**/.output
|
||||
**/.data
|
||||
**/.wrangler
|
||||
src/generated/**
|
||||
src/locales/**
|
||||
src/public/news/feed
|
||||
src/assets/**/*.svg
|
||||
**/.wrangler
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
# Copying
|
||||
|
||||
The source code of the knossos repository is licensed under the GNU Affero General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE). However, some files listed below are licensed under a different license.
|
||||
The source code of Modrinth's web frontend is licensed under the GNU Affero General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE). However, some files listed below are licensed under a different license.
|
||||
|
||||
## Modrinth logo
|
||||
|
||||
The use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, the landing image, and any variations thereof, is strictly prohibited without explicit written permission from Rinth, Inc. This includes trademarks, logos, or other branding elements.
|
||||
The use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, the landing image, and any variations thereof, is strictly prohibited without explicit written permission from Rinth, Inc. This includes trademarks, logos, or other branding elements. The assets associated with our blog articles are also subject to the same restrictions.
|
||||
|
||||
> All rights reserved. © 2020-2024 Rinth, Inc.
|
||||
> All rights reserved. © 2020-2025 Rinth, Inc.
|
||||
|
||||
This includes, but may not be limited to, the following files:
|
||||
|
||||
- assets/images/404.svg
|
||||
- assets/images/logo.svg
|
||||
- components/brand/\*
|
||||
- static/favicon.ico
|
||||
- static/favicon-light.ico
|
||||
- src/assets/images/404.svg
|
||||
- src/assets/images/logo.svg
|
||||
- src/components/brand/\*
|
||||
- src/public/favicon.ico
|
||||
- src/public/favicon-light.ico
|
||||
- src/public/news/\*
|
||||
|
||||
## External logos
|
||||
|
||||
The following files are owned by their respective copyright holders and must be used within each of their Brand Guidelines:
|
||||
|
||||
- assets/images/external/\*
|
||||
- src/assets/icons/auth/sso-\*
|
||||
- src/assets/images/external/\*
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import { match as matchLocale } from '@formatjs/intl-localematcher'
|
||||
import { GenericModrinthClient, type Labrinth } from '@modrinth/api-client'
|
||||
import serverSidedVue from '@vitejs/plugin-vue'
|
||||
import { consola } from 'consola'
|
||||
import { promises as fs } from 'fs'
|
||||
import { globIterate } from 'glob'
|
||||
import fs from 'fs/promises'
|
||||
import { defineNuxtConfig } from 'nuxt/config'
|
||||
import { basename, relative } from 'pathe'
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
|
||||
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
|
||||
@@ -25,27 +19,8 @@ const favicons = {
|
||||
'(prefers-color-scheme:dark)': '/favicon.ico',
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags of locales that are auto-discovered besides the default locale.
|
||||
*
|
||||
* Preferably only the locales that reach a certain threshold of complete
|
||||
* translations would be included in this array.
|
||||
*/
|
||||
// const enabledLocales: string[] = []
|
||||
|
||||
/**
|
||||
* Overrides for the categories of the certain locales.
|
||||
*/
|
||||
const localesCategoriesOverrides: Partial<Record<string, 'fun' | 'experimental'>> = {
|
||||
'en-x-pirate': 'fun',
|
||||
'en-x-updown': 'fun',
|
||||
'en-x-lolcat': 'fun',
|
||||
'en-x-uwu': 'fun',
|
||||
'ru-x-bandit': 'fun',
|
||||
ar: 'experimental',
|
||||
he: 'experimental',
|
||||
pes: 'experimental',
|
||||
}
|
||||
const PROD_MODRINTH_URL = 'https://modrinth.com'
|
||||
const STAGING_MODRINTH_URL = 'https://staging.modrinth.com'
|
||||
|
||||
export default defineNuxtConfig({
|
||||
srcDir: 'src/',
|
||||
@@ -82,6 +57,18 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
vite: {
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
// TODO: dont forget about this
|
||||
silenceDeprecations: ['import'],
|
||||
},
|
||||
},
|
||||
},
|
||||
ssr: {
|
||||
// https://github.com/Akryum/floating-vue/issues/809#issuecomment-1002996240
|
||||
noExternal: ['v-tooltip'],
|
||||
},
|
||||
define: {
|
||||
global: {},
|
||||
},
|
||||
@@ -110,6 +97,11 @@ export default defineNuxtConfig({
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
external: ['cloudflare:workers'],
|
||||
},
|
||||
},
|
||||
},
|
||||
hooks: {
|
||||
async 'nitro:config'(nitroConfig) {
|
||||
@@ -174,116 +166,25 @@ export default defineNuxtConfig({
|
||||
|
||||
await fs.writeFile('./src/generated/state.json', JSON.stringify(state))
|
||||
|
||||
console.log('Tags generated!')
|
||||
},
|
||||
async 'vintl:extendOptions'(opts) {
|
||||
opts.locales ??= []
|
||||
|
||||
// const isProduction = getDomain() === 'https://modrinth.com'
|
||||
|
||||
const resolveCompactNumberDataImport = await (async () => {
|
||||
const compactNumberLocales: string[] = []
|
||||
|
||||
for await (const localeFile of globIterate(
|
||||
'node_modules/@vintl/compact-number/dist/locale-data/*.mjs',
|
||||
{ ignore: '**/*.data.mjs' },
|
||||
)) {
|
||||
const tag = basename(localeFile, '.mjs')
|
||||
compactNumberLocales.push(tag)
|
||||
}
|
||||
|
||||
function resolveImport(tag: string) {
|
||||
const matchedTag = matchLocale([tag], compactNumberLocales, 'en-x-placeholder')
|
||||
return matchedTag === 'en-x-placeholder'
|
||||
? undefined
|
||||
: `@vintl/compact-number/locale-data/${matchedTag}`
|
||||
}
|
||||
|
||||
return resolveImport
|
||||
})()
|
||||
|
||||
const resolveOmorphiaLocaleImport = await (async () => {
|
||||
const omorphiaLocales: string[] = []
|
||||
const omorphiaLocaleSets = new Map<string, { files: { from: string; format?: string }[] }>()
|
||||
|
||||
for (const pkgLocales of [`node_modules/@modrinth/**/src/locales/*`]) {
|
||||
for await (const localeDir of globIterate(pkgLocales, {
|
||||
posix: true,
|
||||
})) {
|
||||
const tag = basename(localeDir)
|
||||
if (!omorphiaLocales.includes(tag)) {
|
||||
omorphiaLocales.push(tag)
|
||||
}
|
||||
|
||||
const entry = omorphiaLocaleSets.get(tag) ?? { files: [] }
|
||||
omorphiaLocaleSets.set(tag, entry)
|
||||
|
||||
for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
|
||||
entry.files.push({
|
||||
from: pathToFileURL(localeFile).toString(),
|
||||
format: 'default',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return function resolveLocaleImport(tag: string) {
|
||||
return omorphiaLocaleSets.get(matchLocale([tag], omorphiaLocales, 'en-x-placeholder'))
|
||||
}
|
||||
})()
|
||||
|
||||
for await (const localeDir of globIterate('src/locales/*/', { posix: true })) {
|
||||
const tag = basename(localeDir)
|
||||
|
||||
// NOTICE: temporarily disabled all locales except en-US
|
||||
if (opts.defaultLocale !== tag) continue
|
||||
|
||||
const locale =
|
||||
opts.locales.find((locale) => locale.tag === tag) ??
|
||||
opts.locales[opts.locales.push({ tag }) - 1]!
|
||||
|
||||
const localeFiles = (locale.files ??= [])
|
||||
|
||||
for await (const localeFile of globIterate(`${localeDir}/*`, { posix: true })) {
|
||||
const fileName = basename(localeFile)
|
||||
if (fileName === 'index.json') {
|
||||
localeFiles.push({
|
||||
from: `./${relative('./src', localeFile)}`,
|
||||
format: 'crowdin',
|
||||
})
|
||||
} else if (fileName === 'meta.json') {
|
||||
const meta: Record<string, { message: string }> = await fs
|
||||
.readFile(localeFile, 'utf8')
|
||||
.then((date) => JSON.parse(date))
|
||||
const localeMeta = (locale.meta ??= {})
|
||||
for (const key in meta) {
|
||||
const value = meta[key]
|
||||
if (value === undefined) continue
|
||||
localeMeta[key] = value.message
|
||||
}
|
||||
} else {
|
||||
;(locale.resources ??= {})[fileName] = `./${relative('./src', localeFile)}`
|
||||
}
|
||||
}
|
||||
|
||||
const categoryOverride = localesCategoriesOverrides[tag]
|
||||
if (categoryOverride != null) {
|
||||
;(locale.meta ??= {}).category = categoryOverride
|
||||
}
|
||||
|
||||
const omorphiaLocaleData = resolveOmorphiaLocaleImport(tag)
|
||||
if (omorphiaLocaleData != null) {
|
||||
localeFiles.push(...omorphiaLocaleData.files)
|
||||
}
|
||||
|
||||
const cnDataImport = resolveCompactNumberDataImport(tag)
|
||||
if (cnDataImport != null) {
|
||||
;(locale.additionalImports ??= []).push({
|
||||
from: cnDataImport,
|
||||
resolve: false,
|
||||
})
|
||||
}
|
||||
// throw if errors and building for prod (preview & staging allowed to have errors)
|
||||
if (
|
||||
process.env.BUILD_ENV === 'production' &&
|
||||
process.env.PREVIEW !== 'true' &&
|
||||
generatedState.errors.length > 0
|
||||
) {
|
||||
throw new Error(
|
||||
`Production build failed: State generation encountered errors. Error codes: ${JSON.stringify(generatedState.errors)}; API URL: ${API_URL}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log('Tags generated!')
|
||||
|
||||
const robotsContent =
|
||||
getDomain() === PROD_MODRINTH_URL
|
||||
? 'User-agent: *\nDisallow: /_internal/'
|
||||
: 'User-agent: *\nDisallow: /'
|
||||
|
||||
await fs.writeFile('./src/public/robots.txt', robotsContent)
|
||||
},
|
||||
},
|
||||
runtimeConfig: {
|
||||
@@ -297,6 +198,8 @@ export default defineNuxtConfig({
|
||||
pyroBaseUrl: process.env.PYRO_BASE_URL,
|
||||
siteUrl: getDomain(),
|
||||
production: isProduction(),
|
||||
buildEnv: process.env.BUILD_ENV,
|
||||
preview: process.env.PREVIEW === 'true',
|
||||
featureFlagOverrides: getFeatureFlagOverrides(),
|
||||
|
||||
owner: process.env.VERCEL_GIT_REPO_OWNER || 'modrinth',
|
||||
@@ -306,7 +209,7 @@ export default defineNuxtConfig({
|
||||
process.env.CF_PAGES_BRANCH ||
|
||||
// @ts-ignore
|
||||
globalThis.CF_PAGES_BRANCH ||
|
||||
'master',
|
||||
'main',
|
||||
hash:
|
||||
process.env.VERCEL_GIT_COMMIT_SHA ||
|
||||
process.env.CF_PAGES_COMMIT_SHA ||
|
||||
@@ -331,55 +234,39 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
modules: ['@vintl/nuxt', '@pinia/nuxt'],
|
||||
vintl: {
|
||||
defaultLocale: 'en-US',
|
||||
locales: [
|
||||
{
|
||||
tag: 'en-US',
|
||||
meta: {
|
||||
static: {
|
||||
iso: 'en',
|
||||
},
|
||||
},
|
||||
modules: [
|
||||
'@pinia/nuxt',
|
||||
'floating-vue/nuxt',
|
||||
// Sentry causes rollup-plugin-inject errors in dev, only enable in production
|
||||
...(isProduction() ? ['@sentry/nuxt/module'] : []),
|
||||
],
|
||||
floatingVue: {
|
||||
themes: {
|
||||
'ribbit-popout': {
|
||||
$extend: 'dropdown',
|
||||
placement: 'bottom-end',
|
||||
instantMove: true,
|
||||
distance: 8,
|
||||
},
|
||||
'dismissable-prompt': {
|
||||
$extend: 'dropdown',
|
||||
placement: 'bottom-start',
|
||||
},
|
||||
],
|
||||
storage: 'cookie',
|
||||
parserless: 'only-prod',
|
||||
seo: {
|
||||
defaultLocaleHasParameter: false,
|
||||
},
|
||||
onParseError({ error, message, messageId, moduleId, parseMessage, parserOptions }) {
|
||||
const errorMessage = String(error)
|
||||
const modulePath = relative(__dirname, moduleId)
|
||||
|
||||
try {
|
||||
const fallback = parseMessage(message, { ...parserOptions, ignoreTag: true })
|
||||
|
||||
consola.warn(
|
||||
`[i18n] ${messageId} in ${modulePath} cannot be parsed normally due to ${errorMessage}. The tags will will not be parsed.`,
|
||||
)
|
||||
|
||||
return fallback
|
||||
} catch (err) {
|
||||
const secondaryErrorMessage = String(err)
|
||||
|
||||
const reason =
|
||||
errorMessage === secondaryErrorMessage
|
||||
? errorMessage
|
||||
: `${errorMessage} and ${secondaryErrorMessage}`
|
||||
|
||||
consola.warn(
|
||||
`[i18n] ${messageId} in ${modulePath} cannot be parsed due to ${reason}. It will be skipped.`,
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
nitro: {
|
||||
moduleSideEffects: ['@vintl/compact-number/locale-data'],
|
||||
rollupConfig: {
|
||||
// @ts-expect-error it's not infinite.
|
||||
// @ts-expect-error because of rolldown-vite - completely fine though
|
||||
plugins: [serverSidedVue()],
|
||||
external: ['cloudflare:workers'],
|
||||
},
|
||||
preset: 'cloudflare_module',
|
||||
cloudflare: {
|
||||
nodeCompat: true,
|
||||
},
|
||||
replace: {
|
||||
__SENTRY_RELEASE__: JSON.stringify(process.env.CF_PAGES_COMMIT_SHA || 'unknown'),
|
||||
__SENTRY_ENVIRONMENT__: JSON.stringify(process.env.BUILD_ENV || 'development'),
|
||||
},
|
||||
},
|
||||
devtools: {
|
||||
@@ -423,8 +310,17 @@ export default defineNuxtConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
compatibilityDate: '2024-07-03',
|
||||
compatibilityDate: '2025-01-01',
|
||||
telemetry: false,
|
||||
experimental: {
|
||||
asyncContext: true,
|
||||
},
|
||||
sourcemap: { client: 'hidden' },
|
||||
sentry: {
|
||||
sourcemaps: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function getApiUrl() {
|
||||
@@ -442,11 +338,8 @@ function getFeatureFlagOverrides() {
|
||||
|
||||
function getDomain() {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
if (process.env.SITE_URL) {
|
||||
return process.env.SITE_URL
|
||||
}
|
||||
// @ts-ignore
|
||||
else if (process.env.CF_PAGES_URL || globalThis.CF_PAGES_URL) {
|
||||
if (process.env.CF_PAGES_URL || globalThis.CF_PAGES_URL) {
|
||||
// @ts-ignore
|
||||
return process.env.CF_PAGES_URL ?? globalThis.CF_PAGES_URL
|
||||
} else if (process.env.HEROKU_APP_NAME) {
|
||||
@@ -454,9 +347,9 @@ function getDomain() {
|
||||
} else if (process.env.VERCEL_URL) {
|
||||
return `https://${process.env.VERCEL_URL}`
|
||||
} else if (getApiUrl() === STAGING_API_URL) {
|
||||
return 'https://staging.modrinth.com'
|
||||
return STAGING_MODRINTH_URL
|
||||
} else {
|
||||
return 'https://modrinth.com'
|
||||
return PROD_MODRINTH_URL
|
||||
}
|
||||
} else {
|
||||
const port = process.env.PORT || 3000
|
||||
|
||||
@@ -3,29 +3,29 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nuxi build",
|
||||
"build": "NODE_OPTIONS=\"--max-old-space-size=8192\" nuxi build",
|
||||
"dev": "nuxi dev",
|
||||
"generate": "nuxi generate",
|
||||
"preview": "nuxi preview",
|
||||
"postinstall": "nuxi prepare",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"test": "nuxi build"
|
||||
"intl:extract": "formatjs extract \"src/{components,composables,layouts,middleware,modules,pages,plugins,utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
|
||||
"cf-deploy": "pnpm run build && wrangler deploy --env staging",
|
||||
"cf-dev": "pnpm run build && wrangler dev --env staging",
|
||||
"cf-typegen": "wrangler types"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@nuxt/devtools": "^1.3.3",
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/iso-3166-2": "^1.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
"@types/node": "^20.1.0",
|
||||
"@vintl/compact-number": "^2.0.5",
|
||||
"@vintl/how-ago": "^3.0.1",
|
||||
"@vintl/nuxt": "^1.9.2",
|
||||
"@types/semver": "^7.7.1",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"eslint": "^8.57.0",
|
||||
"glob": "^10.2.7",
|
||||
"nuxt": "^3.14.1592",
|
||||
"nuxt": "^3.20.2",
|
||||
"postcss": "^8.4.39",
|
||||
"prettier-plugin-tailwindcss": "^0.6.5",
|
||||
"sass": "^1.58.0",
|
||||
@@ -33,7 +33,8 @@
|
||||
"typescript": "^5.4.5",
|
||||
"vite-svg-loader": "^5.1.0",
|
||||
"vue-component-type-helpers": "^3.1.8",
|
||||
"vue-tsc": "^2.0.24"
|
||||
"vue-tsc": "^2.0.24",
|
||||
"wrangler": "^4.54.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
@@ -45,11 +46,11 @@
|
||||
"@modrinth/moderation": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@pinia/nuxt": "^0.11.3",
|
||||
"@sentry/nuxt": "^10.33.0",
|
||||
"@tanstack/vue-query": "^5.90.7",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue-email/components": "^0.0.21",
|
||||
"@vue-email/render": "^0.0.9",
|
||||
"@vueuse/core": "^11.1.0",
|
||||
@@ -60,12 +61,14 @@
|
||||
"floating-vue": "^5.2.2",
|
||||
"fuse.js": "^6.6.2",
|
||||
"highlight.js": "^11.7.0",
|
||||
"intl-messageformat": "^10.7.7",
|
||||
"iso-3166-2": "1.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"lru-cache": "^11.2.4",
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia": "^3.0.0",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
"prettier": "^3.6.2",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtRouteAnnouncer />
|
||||
<ModrinthLoadingIndicator />
|
||||
<NotificationPanel />
|
||||
<NuxtPage />
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-palette" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M8.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M16.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
|
||||
|
Before Width: | Height: | Size: 619 B |
@@ -1,185 +0,0 @@
|
||||
<template>
|
||||
<div class="wrapper relative mb-3 flex w-full justify-center rounded-2xl">
|
||||
<AutoLink
|
||||
:to="currentAd.link"
|
||||
:aria-label="currentAd.description"
|
||||
class="flex max-h-[250px] min-h-[250px] min-w-[300px] max-w-[300px] flex-col gap-4 rounded-[inherit] bg-bg-raised"
|
||||
>
|
||||
<img
|
||||
:src="currentAd.light"
|
||||
aria-hidden="true"
|
||||
:alt="currentAd.description"
|
||||
class="light-image hidden rounded-[inherit]"
|
||||
/>
|
||||
<img
|
||||
:src="currentAd.dark"
|
||||
aria-hidden="true"
|
||||
:alt="currentAd.description"
|
||||
class="dark-image rounded-[inherit]"
|
||||
/>
|
||||
</AutoLink>
|
||||
<div
|
||||
class="absolute top-0 flex items-center justify-center overflow-hidden rounded-2xl bg-bg-raised"
|
||||
>
|
||||
<div id="modrinth-rail-1" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { AutoLink } from '@modrinth/ui'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
useHead({
|
||||
script: [
|
||||
// {
|
||||
// // Clean.io
|
||||
// src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js",
|
||||
// },
|
||||
{
|
||||
// Aditude
|
||||
src: 'https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js',
|
||||
async: true,
|
||||
},
|
||||
{
|
||||
// Optima
|
||||
src: 'https://bservr.com/o.js?uid=8118d1fdb2e0d6f32180bd27',
|
||||
async: true,
|
||||
},
|
||||
{
|
||||
src: '/inmobi.js',
|
||||
async: true,
|
||||
},
|
||||
],
|
||||
link: [
|
||||
{
|
||||
rel: 'preload',
|
||||
as: 'script',
|
||||
href: 'https://www.googletagservices.com/tag/js/gpt.js',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const AD_PRESETS = {
|
||||
medal: {
|
||||
light: 'https://cdn-raw.modrinth.com/modrinth-hosting-medal-light.webp',
|
||||
dark: 'https://cdn-raw.modrinth.com/modrinth-hosting-medal-dark.webp',
|
||||
description: 'Host your next server with Modrinth Hosting',
|
||||
link: '/hosting?plan&ref=medal',
|
||||
},
|
||||
'modrinth-hosting': {
|
||||
light: 'https://cdn-raw.modrinth.com/modrinth-hosting-light.webp',
|
||||
dark: 'https://cdn-raw.modrinth.com/modrinth-hosting-dark.webp',
|
||||
description: 'Host your next server with Modrinth Hosting',
|
||||
link: '/hosting',
|
||||
},
|
||||
}
|
||||
|
||||
const currentAd = computed(() =>
|
||||
flags.value.enableMedalPromotion ? AD_PRESETS.medal : AD_PRESETS['modrinth-hosting'],
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
window.tude = window.tude || { cmd: [] }
|
||||
window.Raven = window.Raven || { cmd: [] }
|
||||
|
||||
window.Raven.cmd.push(({ config }) => {
|
||||
config.setCustom({
|
||||
param1: 'web',
|
||||
})
|
||||
})
|
||||
|
||||
tude.cmd.push(function () {
|
||||
tude.refreshAdsViaDivMappings([
|
||||
{
|
||||
divId: 'modrinth-rail-1',
|
||||
baseDivId: 'pb-slot-square-2',
|
||||
targeting: {
|
||||
location: 'web',
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
</script>
|
||||
<style>
|
||||
iframe[id^='google_ads_iframe'] {
|
||||
color-scheme: normal;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
#qc-cmp2-ui {
|
||||
background: var(--color-raised-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--color-base);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui::before {
|
||||
background: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui::after {
|
||||
background: var(--color-raised-bg);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='primary'] {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
border-radius: var(--radius-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='secondary'] {
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-base);
|
||||
border-radius: var(--radius-lg);
|
||||
border: none;
|
||||
}
|
||||
|
||||
#qc-cmp2-ui button[mode='link'] {
|
||||
color: var(--color-link);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui h2 {
|
||||
color: var(--color-contrast);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
#qc-cmp2-ui div,
|
||||
#qc-cmp2-ui li,
|
||||
#qc-cmp2-ui strong,
|
||||
#qc-cmp2-ui p,
|
||||
#qc-cmp2-ui .qc-cmp2-list-item-title,
|
||||
#qc-cmp2-ui .qc-cmp2-expandable-info {
|
||||
color: var(--color-base);
|
||||
font-family: var(--font-standard);
|
||||
}
|
||||
|
||||
#qc-cmp2-ui .qc-cmp2-toggle[aria-checked='true'] {
|
||||
background-color: var(--color-brand);
|
||||
border: 1px solid var(--color-brand);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.wrapper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper > * {
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.light,
|
||||
.light-mode {
|
||||
.dark-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.light-image {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
339
apps/frontend/src/components/ui/ModrinthFooter.vue
Normal file
339
apps/frontend/src/components/ui/ModrinthFooter.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<script setup lang="ts">
|
||||
import { BlueskyIcon, DiscordIcon, GithubIcon, MastodonIcon, TwitterIcon } from '@modrinth/assets'
|
||||
import {
|
||||
AutoLink,
|
||||
ButtonStyled,
|
||||
defineMessage,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
type MessageDescriptor,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import TextLogo from '~/components/brand/TextLogo.vue'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const messages = defineMessages({
|
||||
modrinthInformation: {
|
||||
id: 'layout.footer.modrinth-information',
|
||||
defaultMessage: 'Modrinth information',
|
||||
},
|
||||
openSource: {
|
||||
id: 'layout.footer.open-source',
|
||||
defaultMessage: 'Modrinth is <github-link>open source</github-link>.',
|
||||
},
|
||||
legalDisclaimer: {
|
||||
id: 'layout.footer.legal-disclaimer',
|
||||
defaultMessage:
|
||||
'NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.',
|
||||
},
|
||||
})
|
||||
|
||||
const socialLinks: {
|
||||
label: MessageDescriptor
|
||||
href: string
|
||||
icon: Component
|
||||
rel?: string
|
||||
}[] = [
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.social.discord', defaultMessage: 'Discord' }),
|
||||
href: 'https://discord.modrinth.com',
|
||||
icon: DiscordIcon,
|
||||
},
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.social.bluesky', defaultMessage: 'Bluesky' }),
|
||||
href: 'https://bsky.app/profile/modrinth.com',
|
||||
icon: BlueskyIcon,
|
||||
},
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.social.mastodon', defaultMessage: 'Mastodon' }),
|
||||
href: 'https://floss.social/@modrinth',
|
||||
icon: MastodonIcon,
|
||||
rel: 'me',
|
||||
},
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.social.x', defaultMessage: 'X' }),
|
||||
href: 'https://x.com/modrinth',
|
||||
icon: TwitterIcon,
|
||||
},
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.social.github', defaultMessage: 'GitHub' }),
|
||||
href: 'https://github.com/modrinth',
|
||||
icon: GithubIcon,
|
||||
},
|
||||
]
|
||||
|
||||
const footerLinks: {
|
||||
label: MessageDescriptor
|
||||
links: {
|
||||
href: string
|
||||
label: MessageDescriptor
|
||||
}[]
|
||||
}[] = [
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.about', defaultMessage: 'About' }),
|
||||
links: [
|
||||
{
|
||||
href: '/news',
|
||||
label: defineMessage({ id: 'layout.footer.about.news', defaultMessage: 'News' }),
|
||||
},
|
||||
{
|
||||
href: '/news/changelog',
|
||||
label: defineMessage({ id: 'layout.footer.about.changelog', defaultMessage: 'Changelog' }),
|
||||
},
|
||||
{
|
||||
href: 'https://status.modrinth.com',
|
||||
label: defineMessage({ id: 'layout.footer.about.status', defaultMessage: 'Status' }),
|
||||
},
|
||||
{
|
||||
href: 'https://careers.modrinth.com',
|
||||
label: defineMessage({ id: 'layout.footer.about.careers', defaultMessage: 'Careers' }),
|
||||
},
|
||||
{
|
||||
href: '/legal/cmp-info',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.about.rewards-program',
|
||||
defaultMessage: 'Rewards Program',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.products', defaultMessage: 'Products' }),
|
||||
links: [
|
||||
{
|
||||
href: '/plus',
|
||||
label: defineMessage({ id: 'layout.footer.products.plus', defaultMessage: 'Modrinth+' }),
|
||||
},
|
||||
{
|
||||
href: '/app',
|
||||
label: defineMessage({ id: 'layout.footer.products.app', defaultMessage: 'Modrinth App' }),
|
||||
},
|
||||
{
|
||||
href: '/hosting',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.products.servers',
|
||||
defaultMessage: 'Modrinth Hosting',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.resources', defaultMessage: 'Resources' }),
|
||||
links: [
|
||||
{
|
||||
href: 'https://support.modrinth.com',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.resources.help-center',
|
||||
defaultMessage: 'Help Center',
|
||||
}),
|
||||
},
|
||||
{
|
||||
href: 'https://translate.modrinth.com',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.resources.translate',
|
||||
defaultMessage: 'Translate',
|
||||
}),
|
||||
},
|
||||
{
|
||||
href: 'https://github.com/modrinth/code/issues',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.resources.report-issues',
|
||||
defaultMessage: 'Report issues',
|
||||
}),
|
||||
},
|
||||
{
|
||||
href: 'https://docs.modrinth.com/api/',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.resources.api-docs',
|
||||
defaultMessage: 'API documentation',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: defineMessage({ id: 'layout.footer.legal', defaultMessage: 'Legal' }),
|
||||
links: [
|
||||
{
|
||||
href: '/legal/rules',
|
||||
label: defineMessage({ id: 'layout.footer.legal.rules', defaultMessage: 'Content Rules' }),
|
||||
},
|
||||
{
|
||||
href: '/legal/terms',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.legal.terms-of-use',
|
||||
defaultMessage: 'Terms of Use',
|
||||
}),
|
||||
},
|
||||
{
|
||||
href: '/legal/privacy',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.legal.privacy-policy',
|
||||
defaultMessage: 'Privacy Policy',
|
||||
}),
|
||||
},
|
||||
{
|
||||
href: '/legal/security',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.legal.security-notice',
|
||||
defaultMessage: 'Security Notice',
|
||||
}),
|
||||
},
|
||||
{
|
||||
href: '/legal/copyright',
|
||||
label: defineMessage({
|
||||
id: 'layout.footer.legal.copyright-policy',
|
||||
defaultMessage: 'Copyright Policy and DMCA',
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const developerModeCounter = ref(0)
|
||||
|
||||
const state = useGeneratedState()
|
||||
|
||||
function developerModeIncrement() {
|
||||
if (developerModeCounter.value >= 5) {
|
||||
flags.value.developerMode = !flags.value.developerMode
|
||||
developerModeCounter.value = 0
|
||||
saveFeatureFlags()
|
||||
if (flags.value.developerMode) {
|
||||
addNotification({
|
||||
title: 'Developer mode activated',
|
||||
text: 'Developer mode has been enabled',
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Developer mode deactivated',
|
||||
text: 'Developer mode has been disabled',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
} else {
|
||||
developerModeCounter.value++
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer
|
||||
class="footer-brand-background experimental-styles-within border-0 border-t-[1px] border-solid"
|
||||
>
|
||||
<div class="mx-auto flex max-w-screen-xl flex-col gap-6 p-6 pb-20 sm:px-12 md:py-12">
|
||||
<div
|
||||
class="grid grid-cols-1 gap-4 text-primary md:grid-cols-[1fr_2fr] lg:grid-cols-[auto_auto_auto_auto_auto]"
|
||||
>
|
||||
<div
|
||||
class="flex flex-col items-center gap-3 md:items-start"
|
||||
role="region"
|
||||
:aria-label="formatMessage(messages.modrinthInformation)"
|
||||
>
|
||||
<TextLogo
|
||||
aria-hidden="true"
|
||||
class="text-logo button-base h-6 w-auto text-contrast lg:h-8"
|
||||
@click="developerModeIncrement()"
|
||||
/>
|
||||
<div class="flex flex-wrap justify-center gap-px sm:-mx-2">
|
||||
<ButtonStyled
|
||||
v-for="(social, index) in socialLinks"
|
||||
:key="`footer-social-${index}`"
|
||||
circular
|
||||
type="transparent"
|
||||
>
|
||||
<a
|
||||
v-tooltip="formatMessage(social.label)"
|
||||
:href="social.href"
|
||||
target="_blank"
|
||||
:rel="`noopener${social.rel ? ` ${social.rel}` : ''}`"
|
||||
>
|
||||
<component :is="social.icon" class="h-5 w-5" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="mt-auto flex flex-wrap justify-center gap-3 md:flex-col">
|
||||
<p class="m-0">
|
||||
<IntlFormatted :message-id="messages.openSource">
|
||||
<template #github-link="{ children }">
|
||||
<a
|
||||
href="https://github.com/modrinth/code"
|
||||
class="text-brand hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
<component :is="() => children" />
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</p>
|
||||
<p class="m-0">© {{ state.buildYear ?? '2025' }} Rinth, Inc.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:contents">
|
||||
<div
|
||||
v-for="group in footerLinks"
|
||||
:key="group.label.id"
|
||||
class="flex flex-col items-center gap-3 sm:items-start"
|
||||
>
|
||||
<h3 class="m-0 text-base text-contrast">{{ formatMessage(group.label) }}</h3>
|
||||
<template v-for="item in group.links" :key="item.label">
|
||||
<nuxt-link
|
||||
v-if="item.href.startsWith('/')"
|
||||
:to="item.href"
|
||||
class="w-fit hover:underline"
|
||||
>
|
||||
{{ formatMessage(item.label) }}
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else
|
||||
:href="item.href"
|
||||
class="w-fit hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
{{ formatMessage(item.label) }}
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="flags.developerMode" class="m-0 text-sm text-secondary">
|
||||
Based on
|
||||
<a
|
||||
v-if="config.public.owner && config.public.branch"
|
||||
class="hover:underline"
|
||||
target="_blank"
|
||||
:href="`https://github.com/${config.public.owner}/code/tree/${config.public.branch}`"
|
||||
>
|
||||
{{ config.public.owner }}/{{ config.public.branch }}
|
||||
</a>
|
||||
@
|
||||
<span v-if="config.public.hash === 'unknown'">unknown</span>
|
||||
<AutoLink
|
||||
v-else
|
||||
class="text-link"
|
||||
target="_blank"
|
||||
:to="`https://github.com/${config.public.owner}/code/commit/${config.public.hash}`"
|
||||
>
|
||||
{{ config.public.hash }}
|
||||
</AutoLink>
|
||||
</p>
|
||||
<div class="flex justify-center text-center text-xs font-medium text-secondary opacity-50">
|
||||
{{ formatMessage(messages.legalDisclaimer) }}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.footer-brand-background {
|
||||
background: var(--brand-gradient-strong-bg);
|
||||
border-color: var(--brand-gradient-border);
|
||||
}
|
||||
</style>
|
||||
@@ -2,71 +2,54 @@
|
||||
<nav
|
||||
ref="scrollContainer"
|
||||
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]"
|
||||
:class="{ 'card-shadow': mode === 'navigation' }"
|
||||
>
|
||||
<template v-if="mode === 'navigation'">
|
||||
<NuxtLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown === undefined ? true : link.shown"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
ref="tabLinkElements"
|
||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@mouseenter="link.onHover?.()"
|
||||
@focus="link.onHover?.()"
|
||||
>
|
||||
<component
|
||||
:is="link.icon"
|
||||
v-if="link.icon"
|
||||
class="size-5"
|
||||
:class="{
|
||||
'text-brand': currentActiveIndex === index && !subpageSelected,
|
||||
'text-secondary': currentActiveIndex !== index || subpageSelected,
|
||||
}"
|
||||
/>
|
||||
<span class="text-nowrap text-contrast">{{ link.label }}</span>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown === undefined ? true : link.shown"
|
||||
v-show="link.shown ?? true"
|
||||
: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"
|
||||
:class="getSSRFallbackClasses(index)"
|
||||
@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
|
||||
>
|
||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||
{{ link.label }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Animated slider background -->
|
||||
<div
|
||||
:class="`navtabs-transition pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1 ${
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected'
|
||||
}`"
|
||||
:style="{
|
||||
left: sliderLeftPx,
|
||||
top: sliderTopPx,
|
||||
right: sliderRightPx,
|
||||
bottom: sliderBottomPx,
|
||||
opacity:
|
||||
sliderLeft === 4 && sliderLeft === sliderRight ? 0 : currentActiveIndex === -1 ? 0 : 1,
|
||||
}"
|
||||
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
|
||||
:class="[
|
||||
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
|
||||
{ 'navtabs-transition': transitionsEnabled },
|
||||
]"
|
||||
:style="sliderStyle"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
@@ -82,6 +65,7 @@ interface Tab {
|
||||
shown?: boolean
|
||||
icon?: Component
|
||||
subpages?: string[]
|
||||
onHover?: () => void
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -102,124 +86,194 @@ const emit = defineEmits<{
|
||||
tabClick: [index: number, tab: Tab]
|
||||
}>()
|
||||
|
||||
// DOM refs
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const tabLinkElements = ref<HTMLElement[]>()
|
||||
|
||||
// Slider pos state
|
||||
const sliderLeft = ref(4)
|
||||
const sliderTop = ref(4)
|
||||
const sliderRight = ref(4)
|
||||
const sliderBottom = ref(4)
|
||||
|
||||
// active tab state
|
||||
const currentActiveIndex = ref(-1)
|
||||
const subpageSelected = ref(false)
|
||||
|
||||
const filteredLinks = computed(() =>
|
||||
props.links.filter((x) => (x.shown === undefined ? true : x.shown)),
|
||||
// SSR state
|
||||
const sliderReady = ref(false) // Slider is positioned and should be visible
|
||||
const transitionsEnabled = ref(false) // CSS transitions should apply (after first paint)
|
||||
|
||||
const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true))
|
||||
|
||||
const sliderStyle = computed(() => ({
|
||||
left: `${sliderLeft.value}px`,
|
||||
top: `${sliderTop.value}px`,
|
||||
right: `${sliderRight.value}px`,
|
||||
bottom: `${sliderBottom.value}px`,
|
||||
opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0,
|
||||
}))
|
||||
|
||||
const isActiveAndNotSubpage = computed(
|
||||
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
|
||||
)
|
||||
const sliderLeftPx = computed(() => `${sliderLeft.value}px`)
|
||||
const sliderTopPx = computed(() => `${sliderTop.value}px`)
|
||||
const sliderRightPx = computed(() => `${sliderRight.value}px`)
|
||||
const sliderBottomPx = computed(() => `${sliderBottom.value}px`)
|
||||
|
||||
const tabLinkElements = ref()
|
||||
function getSSRFallbackClasses(index: number) {
|
||||
if (sliderReady.value) return {}
|
||||
if (currentActiveIndex.value !== index) return {}
|
||||
|
||||
function pickLink() {
|
||||
let index = -1
|
||||
subpageSelected.value = false
|
||||
return {
|
||||
'rounded-full': true,
|
||||
'bg-button-bgSelected': !subpageSelected.value,
|
||||
'bg-button-bg': subpageSelected.value,
|
||||
}
|
||||
}
|
||||
|
||||
function getIconClasses(index: number) {
|
||||
return {
|
||||
'text-button-textSelected': isActiveAndNotSubpage.value(index),
|
||||
'text-secondary': !isActiveAndNotSubpage.value(index),
|
||||
}
|
||||
}
|
||||
|
||||
function getLabelClasses(index: number) {
|
||||
return {
|
||||
'text-button-textSelected': isActiveAndNotSubpage.value(index),
|
||||
'text-contrast': !isActiveAndNotSubpage.value(index),
|
||||
}
|
||||
}
|
||||
|
||||
function computeActiveIndex(): { index: number; isSubpage: boolean } {
|
||||
if (props.mode === 'local' && props.activeIndex !== undefined) {
|
||||
index = Math.min(props.activeIndex, filteredLinks.value.length - 1)
|
||||
} else {
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||
const link = filteredLinks.value[i]
|
||||
if (props.query) {
|
||||
if (route.query[props.query] === link.href || (!route.query[props.query] && !link.href)) {
|
||||
index = i
|
||||
break
|
||||
}
|
||||
} else if (decodeURIComponent(route.path) === link.href) {
|
||||
index = i
|
||||
break
|
||||
} else if (
|
||||
decodeURIComponent(route.path).includes(link.href) ||
|
||||
(link.subpages &&
|
||||
link.subpages.some((subpage) => decodeURIComponent(route.path).includes(subpage)))
|
||||
) {
|
||||
index = i
|
||||
subpageSelected.value = true
|
||||
break
|
||||
}
|
||||
return {
|
||||
index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
|
||||
isSubpage: false,
|
||||
}
|
||||
}
|
||||
|
||||
currentActiveIndex.value = index
|
||||
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
|
||||
const link = filteredLinks.value[i]
|
||||
const decodedPath = decodeURIComponent(route.path)
|
||||
|
||||
if (currentActiveIndex.value !== -1) {
|
||||
nextTick(() => startAnimation())
|
||||
// Query-based matching
|
||||
if (props.query) {
|
||||
const queryValue = route.query[props.query]
|
||||
if (queryValue === link.href || (!queryValue && !link.href)) {
|
||||
return { index: i, isSubpage: false }
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Exact path match
|
||||
if (decodedPath === link.href) {
|
||||
return { index: i, isSubpage: false }
|
||||
}
|
||||
|
||||
// Subpage match
|
||||
const isSubpageMatch =
|
||||
decodedPath.includes(link.href) ||
|
||||
link.subpages?.some((subpage) => decodedPath.includes(subpage))
|
||||
|
||||
if (isSubpageMatch) {
|
||||
return { index: i, isSubpage: true }
|
||||
}
|
||||
}
|
||||
|
||||
return { index: -1, isSubpage: false }
|
||||
}
|
||||
|
||||
function getTabElement(index: number): HTMLElement | null {
|
||||
if (!tabLinkElements.value?.[index]) return null
|
||||
|
||||
// In navigation mode, elements are NuxtLinks with $el property
|
||||
// In local mode, elements are plain divs
|
||||
const element = tabLinkElements.value[index]
|
||||
return props.mode === 'navigation' ? (element as any).$el : element
|
||||
}
|
||||
|
||||
function positionSlider() {
|
||||
const el = getTabElement(currentActiveIndex.value)
|
||||
if (!el?.offsetParent) return
|
||||
|
||||
const parent = el.offsetParent as HTMLElement
|
||||
const newPosition = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
|
||||
|
||||
if (isInitialPosition) {
|
||||
// Initial positioning: set position instantly, no animation
|
||||
sliderLeft.value = newPosition.left
|
||||
sliderRight.value = newPosition.right
|
||||
sliderTop.value = newPosition.top
|
||||
sliderBottom.value = newPosition.bottom
|
||||
|
||||
sliderReady.value = true
|
||||
|
||||
// enable transitions after slider is painted, so future changes animate
|
||||
requestAnimationFrame(() => {
|
||||
transitionsEnabled.value = true
|
||||
})
|
||||
} else {
|
||||
animateSliderTo(newPosition)
|
||||
}
|
||||
}
|
||||
|
||||
function animateSliderTo(newPosition: {
|
||||
left: number
|
||||
top: number
|
||||
right: number
|
||||
bottom: number
|
||||
}) {
|
||||
const STAGGER_DELAY = 200
|
||||
|
||||
// Horizontal animation - lead with the direction of movement
|
||||
if (newPosition.left < sliderLeft.value) {
|
||||
sliderLeft.value = newPosition.left
|
||||
setTimeout(() => (sliderRight.value = newPosition.right), STAGGER_DELAY)
|
||||
} else {
|
||||
sliderRight.value = newPosition.right
|
||||
setTimeout(() => (sliderLeft.value = newPosition.left), STAGGER_DELAY)
|
||||
}
|
||||
|
||||
// Vertical animation - lead with the direction of movement
|
||||
if (newPosition.top < sliderTop.value) {
|
||||
sliderTop.value = newPosition.top
|
||||
setTimeout(() => (sliderBottom.value = newPosition.bottom), STAGGER_DELAY)
|
||||
} else {
|
||||
sliderBottom.value = newPosition.bottom
|
||||
setTimeout(() => (sliderTop.value = newPosition.top), STAGGER_DELAY)
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveTab() {
|
||||
const { index, isSubpage } = computeActiveIndex()
|
||||
currentActiveIndex.value = index
|
||||
subpageSelected.value = isSubpage
|
||||
|
||||
if (index !== -1) {
|
||||
nextTick(positionSlider)
|
||||
} else {
|
||||
sliderLeft.value = 0
|
||||
sliderRight.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function startAnimation() {
|
||||
// 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]
|
||||
const initialActive = computeActiveIndex()
|
||||
currentActiveIndex.value = initialActive.index
|
||||
subpageSelected.value = initialActive.isSubpage
|
||||
|
||||
if (!el || !el.offsetParent) return
|
||||
|
||||
const newValues = {
|
||||
left: el.offsetLeft,
|
||||
top: el.offsetTop,
|
||||
right: el.offsetParent.offsetWidth - el.offsetLeft - el.offsetWidth,
|
||||
bottom: el.offsetParent.offsetHeight - el.offsetTop - el.offsetHeight,
|
||||
}
|
||||
|
||||
if (sliderLeft.value === 4 && sliderRight.value === 4) {
|
||||
sliderLeft.value = newValues.left
|
||||
sliderRight.value = newValues.right
|
||||
sliderTop.value = newValues.top
|
||||
sliderBottom.value = newValues.bottom
|
||||
} else {
|
||||
const delay = 200
|
||||
|
||||
if (newValues.left < sliderLeft.value) {
|
||||
sliderLeft.value = newValues.left
|
||||
setTimeout(() => {
|
||||
sliderRight.value = newValues.right
|
||||
}, delay)
|
||||
} else {
|
||||
sliderRight.value = newValues.right
|
||||
setTimeout(() => {
|
||||
sliderLeft.value = newValues.left
|
||||
}, delay)
|
||||
}
|
||||
|
||||
if (newValues.top < sliderTop.value) {
|
||||
sliderTop.value = newValues.top
|
||||
setTimeout(() => {
|
||||
sliderBottom.value = newValues.bottom
|
||||
}, delay)
|
||||
} else {
|
||||
sliderBottom.value = newValues.bottom
|
||||
setTimeout(() => {
|
||||
sliderTop.value = newValues.top
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
pickLink()
|
||||
})
|
||||
onMounted(updateActiveTab)
|
||||
|
||||
watch(
|
||||
() => [route.path, route.query],
|
||||
() => {
|
||||
if (props.mode === 'navigation') {
|
||||
pickLink()
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -228,19 +282,12 @@ watch(
|
||||
() => props.activeIndex,
|
||||
() => {
|
||||
if (props.mode === 'local') {
|
||||
pickLink()
|
||||
updateActiveTab()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.links,
|
||||
() => {
|
||||
// Re-trigger animation when links change
|
||||
pickLink()
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
watch(() => props.links, updateActiveTab, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -24,9 +24,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
type MessageDescriptor,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { Project, User, Version } from '@modrinth/utils'
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { acceptTeamInvite, removeTeamMember } from '~/helpers/teams.js'
|
||||
|
||||
397
apps/frontend/src/components/ui/admin/TransferModal.vue
Normal file
397
apps/frontend/src/components/ui/admin/TransferModal.vue
Normal file
@@ -0,0 +1,397 @@
|
||||
<template>
|
||||
<NewModal ref="modal">
|
||||
<template #title>
|
||||
<span class="text-lg font-extrabold text-contrast">Schedule transfer</span>
|
||||
</template>
|
||||
<div class="flex w-[550px] max-w-[90vw] flex-col gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Type </span>
|
||||
<span>Select transfer type.</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="mode"
|
||||
:options="modeOptions"
|
||||
placeholder="Select type"
|
||||
class="max-w-[10rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'servers'" class="flex flex-col gap-2">
|
||||
<label for="server-ids" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Server IDs
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>Server IDs (one per line or comma-separated.)</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="server-ids"
|
||||
v-model="serverIdsInput"
|
||||
rows="4"
|
||||
class="w-full bg-surface-3"
|
||||
placeholder="123e4569-e89b-12d3-a456-426614174005 123e9569-e89b-12d3-a456-413678919876"
|
||||
/>
|
||||
</div>
|
||||
<span v-if="parsedServerIds.length" class="text-sm text-secondary">
|
||||
{{ parsedServerIds.length }} server{{ parsedServerIds.length === 1 ? '' : 's' }} selected
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="node-input" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Node hostnames
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>Add nodes to transfer.</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="node-input"
|
||||
v-model="nodeInput"
|
||||
class="w-40"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="us-vin200"
|
||||
@keydown.enter.prevent="addNode"
|
||||
/>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
<button class="shrink-0" @click="addNode">
|
||||
<PlusIcon />
|
||||
Add
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="selectedNodes.length" class="mt-1 flex flex-wrap gap-2">
|
||||
<TagItem v-for="h in selectedNodes" :key="`node-${h}`" :action="() => removeNode(h)">
|
||||
<XIcon />
|
||||
{{ h }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3">
|
||||
<label for="cordon-nodes" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">Cordon nodes now</span>
|
||||
<span>
|
||||
Prevent new servers from being provisioned on the transferred nodes from now on.<br /><br />
|
||||
Note that if this option isn't chosen, new servers provisioned onto transferred nodes
|
||||
between now and the scheduled time will still be transferred.
|
||||
</span>
|
||||
</label>
|
||||
<Toggle id="cordon-nodes" v-model="cordonNodes" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="tag-nodes" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">Tag transferred nodes</span>
|
||||
<span>Optional tag to add to the transferred nodes.</span>
|
||||
</label>
|
||||
<input
|
||||
id="tag-nodes"
|
||||
v-model="tagNodes"
|
||||
class="max-w-[12rem]"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="region-select" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Target region </span>
|
||||
<span>Select the destination region for transferred servers.</span>
|
||||
</label>
|
||||
<Combobox
|
||||
v-model="selectedRegion"
|
||||
:options="regions"
|
||||
placeholder="Select region"
|
||||
class="max-w-[24rem]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="tag-input" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Node tags </span>
|
||||
<span>Optional preferred node tags for node selection.</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
id="tag-input"
|
||||
v-model="tagInput"
|
||||
class="w-40"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="ovh-gen4"
|
||||
@keydown.enter.prevent="addTag"
|
||||
/>
|
||||
<ButtonStyled color="blue" color-fill="text">
|
||||
<button class="shrink-0" @click="addTag">
|
||||
<PlusIcon />
|
||||
Add
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="selectedTags.length" class="mt-1 flex flex-wrap gap-2">
|
||||
<TagItem v-for="t in selectedTags" :key="`tag-${t}`" :action="() => removeTag(t)">
|
||||
<XIcon />
|
||||
{{ t }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast"> Schedule </span>
|
||||
</label>
|
||||
<Chips
|
||||
v-model="scheduleOption"
|
||||
:items="scheduleOptions"
|
||||
:format-label="(item) => scheduleOptionLabels[item]"
|
||||
:capitalize="false"
|
||||
/>
|
||||
<input
|
||||
v-if="scheduleOption === 'later'"
|
||||
v-model="scheduledDate"
|
||||
type="datetime-local"
|
||||
class="mt-2 max-w-[16rem]"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="reason" class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
Reason
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
<span>Provide a reason for this transfer batch.</span>
|
||||
</label>
|
||||
<div class="textarea-wrapper">
|
||||
<textarea
|
||||
id="reason"
|
||||
v-model="reason"
|
||||
rows="2"
|
||||
class="w-full bg-surface-3"
|
||||
placeholder="Node maintenance scheduled"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="submitDisabled || submitting" @click="submit">
|
||||
<SendIcon aria-hidden="true" />
|
||||
{{ submitting ? 'Scheduling...' : 'Schedule transfer' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="modal?.hide?.()">
|
||||
<XIcon aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, SendIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
Combobox,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
TagItem,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
|
||||
const emit = defineEmits<{
|
||||
success: []
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const modeOptions = [
|
||||
{ value: 'servers', label: 'Servers' },
|
||||
{ value: 'nodes', label: 'Nodes' },
|
||||
]
|
||||
const mode = ref<string>('servers')
|
||||
|
||||
const serverIdsInput = ref('')
|
||||
const parsedServerIds = computed(() => {
|
||||
const input = serverIdsInput.value.trim()
|
||||
if (!input) return []
|
||||
return input
|
||||
.split(/[\n,\s]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0)
|
||||
})
|
||||
|
||||
const nodeInput = ref('')
|
||||
const selectedNodes = ref<string[]>([])
|
||||
const cordonNodes = ref(true)
|
||||
const tagNodes = ref('')
|
||||
|
||||
type RegionOpt = { value: string; label: string }
|
||||
const regions = ref<RegionOpt[]>([])
|
||||
const selectedRegion = ref<string | null>(null)
|
||||
const nodeHostnames = ref<string[]>([])
|
||||
|
||||
const tagInput = ref('')
|
||||
const selectedTags = ref<string[]>([])
|
||||
|
||||
const scheduleOptions: ('now' | 'later')[] = ['now', 'later']
|
||||
const scheduleOptionLabels: Record<string, string> = {
|
||||
now: 'Now',
|
||||
later: 'Schedule for later',
|
||||
}
|
||||
const scheduleOption = ref<'now' | 'later'>('now')
|
||||
const scheduledDate = ref<string>('')
|
||||
|
||||
const reason = ref('')
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
void ensureOverview()
|
||||
mode.value = 'servers'
|
||||
serverIdsInput.value = ''
|
||||
selectedNodes.value = []
|
||||
cordonNodes.value = true
|
||||
tagNodes.value = `migration${dayjs().format('YYYYMMDD')}`
|
||||
selectedTags.value = []
|
||||
tagInput.value = ''
|
||||
nodeInput.value = ''
|
||||
scheduleOption.value = 'now'
|
||||
scheduledDate.value = ''
|
||||
reason.value = ''
|
||||
modal.value?.show(event)
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
function addNode() {
|
||||
const v = nodeInput.value.trim()
|
||||
if (!v) return
|
||||
if (!nodeHostnames.value.includes(v)) {
|
||||
addNotification({
|
||||
title: 'Unknown node',
|
||||
text: "This hostname doesn't exist",
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (!selectedNodes.value.includes(v)) selectedNodes.value.push(v)
|
||||
nodeInput.value = ''
|
||||
}
|
||||
|
||||
function removeNode(v: string) {
|
||||
selectedNodes.value = selectedNodes.value.filter((x) => x !== v)
|
||||
}
|
||||
|
||||
function addTag() {
|
||||
const v = tagInput.value.trim()
|
||||
if (!v) return
|
||||
if (!selectedTags.value.includes(v)) selectedTags.value.push(v)
|
||||
tagInput.value = ''
|
||||
}
|
||||
|
||||
function removeTag(v: string) {
|
||||
selectedTags.value = selectedTags.value.filter((x) => x !== v)
|
||||
}
|
||||
|
||||
const submitDisabled = computed(() => {
|
||||
if (!reason.value.trim()) return true
|
||||
if (mode.value === 'servers') {
|
||||
if (parsedServerIds.value.length === 0) return true
|
||||
} else {
|
||||
if (selectedNodes.value.length === 0) return true
|
||||
}
|
||||
if (scheduleOption.value === 'later' && !scheduledDate.value) return true
|
||||
return false
|
||||
})
|
||||
|
||||
async function ensureOverview() {
|
||||
if (regions.value.length || nodeHostnames.value.length) return
|
||||
try {
|
||||
const data = await useServersFetch<any>('/nodes/overview', { version: 'internal' })
|
||||
regions.value = (data.regions || []).map((r: any) => ({
|
||||
value: r.key,
|
||||
label: `${r.display_name} (${r.key})`,
|
||||
}))
|
||||
nodeHostnames.value = data.node_hostnames || []
|
||||
if (!selectedRegion.value && regions.value.length) {
|
||||
selectedRegion.value = regions.value[0].value
|
||||
}
|
||||
} catch (err) {
|
||||
addNotification({ title: 'Failed to load nodes overview', text: String(err), type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (submitDisabled.value || submitting.value) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const scheduledAt =
|
||||
scheduleOption.value === 'now' ? undefined : dayjs(scheduledDate.value).toISOString()
|
||||
|
||||
if (mode.value === 'servers') {
|
||||
await useServersFetch('/transfers/schedule/servers', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
server_ids: parsedServerIds.value,
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await useServersFetch('/transfers/schedule/nodes', {
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: {
|
||||
node_hostnames: selectedNodes.value.slice(),
|
||||
scheduled_at: scheduledAt,
|
||||
target_region: selectedRegion.value || undefined,
|
||||
node_tags: selectedTags.value.length > 0 ? selectedTags.value : undefined,
|
||||
reason: reason.value.trim(),
|
||||
cordon_nodes: cordonNodes.value,
|
||||
tag_nodes: tagNodes.value.trim() || undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
addNotification({ title: 'Transfer scheduled', type: 'success' })
|
||||
emit('success')
|
||||
modal.value?.hide()
|
||||
} catch (err: any) {
|
||||
addNotification({
|
||||
title: 'Error scheduling transfer',
|
||||
text: err?.data?.description ?? err?.message ?? String(err),
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.build-fail.title',
|
||||
defaultMessage: 'Error generating state from API when building.',
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.build-fail.description',
|
||||
defaultMessage:
|
||||
"This deploy of Modrinth's frontend failed to generate state from the API. This may be due to an outage or an error in configuration. Rebuild when the API is available. Error codes: {errors}; Current API URL is: {url}",
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
errors: any[] | undefined
|
||||
apiUrl: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="errors?.length" variant="error">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
{{
|
||||
formatMessage(messages.description, {
|
||||
errors: JSON.stringify(errors),
|
||||
url: apiUrl,
|
||||
})
|
||||
}}
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
86
apps/frontend/src/components/ui/banner/PreviewBanner.vue
Normal file
86
apps/frontend/src/components/ui/banner/PreviewBanner.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
PagewideBanner,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.preview.title',
|
||||
defaultMessage: `This is a preview deploy of the Modrinth website.`,
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.preview.description',
|
||||
defaultMessage: `If you meant to access the official Modrinth website, visit <link>https://modrinth.com</link>. This preview deploy is used by Modrinth staff for testing purposes. It was built using <branch-link>{owner}/{branch}</branch-link> @ {commit}.`,
|
||||
},
|
||||
})
|
||||
|
||||
function hidePreviewBanner() {
|
||||
flags.value.hidePreviewBanner = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="!flags.hidePreviewBanner" variant="info">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>
|
||||
<IntlFormatted
|
||||
:message-id="messages.description"
|
||||
:values="{
|
||||
owner: config.public.owner,
|
||||
branch: config.public.branch,
|
||||
}"
|
||||
>
|
||||
<template #link="{ children }">
|
||||
<a href="https://modrinth.com" target="_blank" rel="noopener" class="text-link">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</a>
|
||||
</template>
|
||||
<template #branch-link="{ children }">
|
||||
<a
|
||||
:href="`https://github.com/${config.public.owner}/code/tree/${config.public.branch}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="hover:underline"
|
||||
>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</a>
|
||||
</template>
|
||||
<template #commit>
|
||||
<span v-if="config.public.hash === 'unknown'">unknown</span>
|
||||
<a
|
||||
v-else
|
||||
:href="`https://github.com/${config.public.owner}/code/commit/${config.public.hash}`"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-link"
|
||||
>
|
||||
{{ config.public.hash }}
|
||||
</a>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hidePreviewBanner">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
69
apps/frontend/src/components/ui/banner/RussiaBanner.vue
Normal file
69
apps/frontend/src/components/ui/banner/RussiaBanner.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { BookTextIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function hideRussiaCensorshipBanner() {
|
||||
flags.value.hideRussiaCensorshipBanner = true
|
||||
saveFeatureFlags()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="!flags.hideRussiaCensorshipBanner" variant="error">
|
||||
<template #title>
|
||||
<div class="flex flex-col gap-1 text-contrast">
|
||||
<span lang="ru">К сожалению, Modrinth скоро станет недоступен в России</span>
|
||||
<span class="text-sm font-medium opacity-50" lang="en">
|
||||
Modrinth will soon be unavailable in Russia
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #description>
|
||||
<p class="m-0" lang="ru">
|
||||
Российское правительство потребовало от нас заблокировать некоторые проекты на Modrinth, но
|
||||
мы решили отказать им в цензуре.
|
||||
</p>
|
||||
<p class="-mt-2 mb-0 text-sm opacity-50" lang="en">
|
||||
The Russian government has asked us to censor certain topics on Modrinth and we have decided
|
||||
to refuse to comply with their requests.
|
||||
</p>
|
||||
|
||||
<p class="m-0 font-semibold" lang="ru">
|
||||
Пожалуйста, найдите какой-нибудь надёжный VPN или прокси, чтобы не потерять доступ к
|
||||
Modrinth.
|
||||
</p>
|
||||
<p class="-mt-2 mb-0 text-sm opacity-50" lang="en">
|
||||
Please seek a reputable VPN or proxy of some kind to continue to access Modrinth in Russia.
|
||||
</p>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="mt-2 flex w-fit gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link to="/news/article/standing-by-our-values-russian">
|
||||
<BookTextIcon /> Прочесть наше полное заявление
|
||||
<span class="text-xs font-medium">(Перевод на русский)</span>
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<nuxt-link to="/news/article/standing-by-our-values">
|
||||
<BookTextIcon /> Read our full statement
|
||||
<span class="text-xs font-medium">(English)</span>
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.closeButton)"
|
||||
@click="hideRussiaCensorshipBanner"
|
||||
>
|
||||
<XIcon :aria-label="formatMessage(commonMessages.closeButton)" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
47
apps/frontend/src/components/ui/banner/StagingBanner.vue
Normal file
47
apps/frontend/src/components/ui/banner/StagingBanner.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
PagewideBanner,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const cosmetics = useCosmetics()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.staging.title',
|
||||
defaultMessage: 'You’re viewing Modrinth’s staging environment',
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.staging.description',
|
||||
defaultMessage:
|
||||
'The staging environment is completely separate from the production Modrinth database. This is used for testing and debugging purposes, and may be running in-development versions of the Modrinth backend or frontend newer than the production instance.',
|
||||
},
|
||||
})
|
||||
|
||||
function hideStagingBanner() {
|
||||
cosmetics.value.hideStagingBanner = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner v-if="!cosmetics.hideStagingBanner" variant="warning">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
{{ formatMessage(messages.description) }}
|
||||
</template>
|
||||
<template #actions_right>
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button :aria-label="formatMessage(commonMessages.closeButton)" @click="hideStagingBanner">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { SettingsIcon } from '@modrinth/assets'
|
||||
import { defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.subscription-payment-failed.title',
|
||||
defaultMessage: 'Billing action required.',
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.subscription-payment-failed.description',
|
||||
defaultMessage:
|
||||
'One or more subscriptions failed to renew. Please update your payment method to prevent losing access!',
|
||||
},
|
||||
action: {
|
||||
id: 'layout.banner.subscription-payment-failed.button',
|
||||
defaultMessage: 'Update billing info',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner variant="error">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<nuxt-link class="btn" to="/settings/billing">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.action) }}
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { FileTextIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import CreatorTaxFormModal from '~/components/ui/dashboard/CreatorTaxFormModal.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const modal = useTemplateRef('modal')
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.tax.title',
|
||||
defaultMessage: 'Tax form required',
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.tax.description',
|
||||
defaultMessage:
|
||||
"You've already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted.",
|
||||
},
|
||||
action: {
|
||||
id: 'layout.banner.tax.action',
|
||||
defaultMessage: 'Complete tax form',
|
||||
},
|
||||
})
|
||||
|
||||
function openTaxForm(e: MouseEvent) {
|
||||
if (modal.value && modal.value.startTaxForm) {
|
||||
modal.value.startTaxForm(e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CreatorTaxFormModal ref="modal" close-button-text="Close" :emit-success-on-close="false" />
|
||||
<PagewideBanner variant="warning">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="openTaxForm"><FileTextIcon /> {{ formatMessage(messages.action) }}</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { MessageIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, defineMessages, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.tin-mismatch.title',
|
||||
defaultMessage: 'Tax form failed',
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.tin-mismatch.description',
|
||||
defaultMessage:
|
||||
"Your withdrawals are temporarily locked because your TIN or SSN didn't match IRS records. Please contact support to reset and resubmit your tax form.",
|
||||
},
|
||||
action: {
|
||||
id: 'layout.banner.tin-mismatch.action',
|
||||
defaultMessage: 'Contact support',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner variant="error">
|
||||
<template #title>
|
||||
<span>{{ formatMessage(messages.title) }}</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>{{ formatMessage(messages.description) }}</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<div class="flex w-fit flex-row">
|
||||
<ButtonStyled color="red">
|
||||
<nuxt-link to="https://support.modrinth.com" target="_blank" rel="noopener">
|
||||
<MessageIcon />
|
||||
{{ formatMessage(messages.action) }}
|
||||
</nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
103
apps/frontend/src/components/ui/banner/VerifyEmailBanner.vue
Normal file
103
apps/frontend/src/components/ui/banner/VerifyEmailBanner.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import { SettingsIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, PagewideBanner, useVIntl } from '@modrinth/ui'
|
||||
import { FetchError } from 'ofetch'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
defineProps<{
|
||||
hasEmail: boolean
|
||||
}>()
|
||||
|
||||
const verifyEmailBannerMessages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.account-action',
|
||||
defaultMessage: 'Account action required',
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.verify-email.description',
|
||||
defaultMessage:
|
||||
'For security reasons, Modrinth needs you to verify the email address associated with your account.',
|
||||
},
|
||||
action: {
|
||||
id: 'layout.banner.verify-email.action',
|
||||
defaultMessage: 'Re-send verification email',
|
||||
},
|
||||
})
|
||||
|
||||
const addEmailBannerMessages = defineMessages({
|
||||
title: {
|
||||
id: 'layout.banner.account-action',
|
||||
defaultMessage: 'Account action required',
|
||||
},
|
||||
description: {
|
||||
id: 'layout.banner.add-email.description',
|
||||
defaultMessage:
|
||||
'For security reasons, Modrinth needs you to register an email address to your account.',
|
||||
},
|
||||
action: {
|
||||
id: 'layout.banner.add-email.button',
|
||||
defaultMessage: 'Visit account settings',
|
||||
},
|
||||
})
|
||||
|
||||
async function handleResendEmailVerification() {
|
||||
try {
|
||||
await resendVerifyEmail()
|
||||
addNotification({
|
||||
title: 'Verification email sent',
|
||||
text: 'Please check your inbox for the verification email.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (err) {
|
||||
if (err instanceof FetchError) {
|
||||
const description = err.data?.description || err.message
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: description,
|
||||
type: 'error',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
text: `${err}`,
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PagewideBanner variant="warning">
|
||||
<template #title>
|
||||
<span>
|
||||
{{
|
||||
hasEmail
|
||||
? formatMessage(verifyEmailBannerMessages.title)
|
||||
: formatMessage(addEmailBannerMessages.title)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #description>
|
||||
<span>
|
||||
{{
|
||||
hasEmail
|
||||
? formatMessage(verifyEmailBannerMessages.description)
|
||||
: formatMessage(addEmailBannerMessages.description)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<button v-if="hasEmail" class="btn" @click="handleResendEmailVerification">
|
||||
{{ formatMessage(verifyEmailBannerMessages.action) }}
|
||||
</button>
|
||||
<nuxt-link v-else class="btn" to="/settings/account">
|
||||
<SettingsIcon aria-hidden="true" />
|
||||
{{ formatMessage(addEmailBannerMessages.action) }}
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
</template>
|
||||
@@ -286,7 +286,7 @@ const flipLegend = (legend, newVal) => {
|
||||
}
|
||||
|
||||
const resetChart = () => {
|
||||
if (!chart.value) return
|
||||
if (!chart.value?.chart) return
|
||||
chart.value.updateSeries([...props.data])
|
||||
chart.value.updateOptions({
|
||||
xaxis: {
|
||||
|
||||
@@ -309,14 +309,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { DownloadIcon, PaletteIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { Button, Card, DropdownSelect } from '@modrinth/ui'
|
||||
import { formatCategoryHeader, formatMoney, formatNumber } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { UiChartsChart as Chart, UiChartsCompactChart as CompactChart } from '#components'
|
||||
import PaletteIcon from '~/assets/icons/palette.svg?component'
|
||||
import {
|
||||
analyticsSetToCSVString,
|
||||
countryCodeToFlag,
|
||||
|
||||
@@ -125,13 +125,14 @@ const chartOptions = {
|
||||
const chart = ref(null)
|
||||
|
||||
const resetChart = () => {
|
||||
chart.value?.updateSeries([...props.data])
|
||||
chart.value?.updateOptions({
|
||||
if (!chart.value?.chart) return
|
||||
chart.value.updateSeries([...props.data])
|
||||
chart.value.updateOptions({
|
||||
xaxis: {
|
||||
categories: props.labels,
|
||||
},
|
||||
})
|
||||
chart.value?.resetSeries()
|
||||
chart.value.resetSeries()
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
<template>
|
||||
<MultiStageModal ref="modal" :stages="ctx.stageConfigs" :context="ctx" />
|
||||
<MultiStageModal
|
||||
ref="modal"
|
||||
:stages="ctx.stageConfigs"
|
||||
:context="ctx"
|
||||
:breadcrumbs="!editingVersion"
|
||||
@hide="() => (modalOpen = false)"
|
||||
/>
|
||||
<DropArea
|
||||
v-if="!modalOpen"
|
||||
:accept="acceptFileFromProjectType(projectV2.project_type)"
|
||||
@change="handleDropArea"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
DropArea,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
MultiStageModal,
|
||||
} from '@modrinth/ui'
|
||||
import { acceptFileFromProjectType } from '@modrinth/utils'
|
||||
import type { ComponentExposed } from 'vue-component-type-helpers'
|
||||
|
||||
import {
|
||||
@@ -17,12 +30,17 @@ import {
|
||||
provideManageVersionContext,
|
||||
} from '~/providers/version/manage-version-modal'
|
||||
|
||||
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
|
||||
const emit = defineEmits<{
|
||||
(e: 'save'): void
|
||||
}>()
|
||||
|
||||
const ctx = createManageVersionContext(modal)
|
||||
const modal = useTemplateRef<ComponentExposed<typeof MultiStageModal>>('modal')
|
||||
const modalOpen = ref(false)
|
||||
|
||||
const ctx = createManageVersionContext(modal, () => emit('save'))
|
||||
provideManageVersionContext(ctx)
|
||||
|
||||
const { newDraftVersion } = ctx
|
||||
const { newDraftVersion, editingVersion, handleNewFiles } = ctx
|
||||
|
||||
const { projectV2 } = injectProjectPageContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
@@ -64,6 +82,15 @@ function openCreateVersionModal(
|
||||
newDraftVersion(projectV2.value.id, version)
|
||||
modal.value?.setStage(stageId ?? 0)
|
||||
modal.value?.show()
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
async function handleDropArea(files: FileList) {
|
||||
newDraftVersion(projectV2.value.id, null)
|
||||
modal.value?.setStage(0)
|
||||
await handleNewFiles(Array.from(files))
|
||||
modal.value?.show()
|
||||
modalOpen.value = true
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
|
||||
class="flex h-11 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" />
|
||||
@@ -9,7 +9,7 @@
|
||||
{{ name || 'Unknown Project' }}
|
||||
</span>
|
||||
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5">
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
|
||||
{{ dependencyType }}
|
||||
</TagItem>
|
||||
</div>
|
||||
@@ -17,14 +17,15 @@
|
||||
<span
|
||||
v-if="versionName"
|
||||
v-tooltip="versionName"
|
||||
class="max-w-[35%] truncate whitespace-nowrap font-medium"
|
||||
class="truncate whitespace-nowrap font-medium"
|
||||
:class="!hideRemove ? 'max-w-[35%]' : 'max-w-[50%]'"
|
||||
>
|
||||
{{ versionName }}
|
||||
</span>
|
||||
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<div v-if="!hideRemove" class="flex items-center justify-end gap-1">
|
||||
<ButtonStyled size="standard" :circular="true">
|
||||
<button aria-label="Remove file" class="!shadow-none" @click="emitRemove">
|
||||
<button aria-label="Remove file" class="-mr-2 !shadow-none" @click="emitRemove">
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -42,12 +43,13 @@ const emit = defineEmits<{
|
||||
(e: 'remove'): void
|
||||
}>()
|
||||
|
||||
const { projectId, name, icon, dependencyType, versionName } = defineProps<{
|
||||
const { projectId, name, icon, dependencyType, versionName, hideRemove } = defineProps<{
|
||||
projectId: string
|
||||
name?: string
|
||||
icon?: string
|
||||
dependencyType: Labrinth.Versions.v2.DependencyType
|
||||
versionName?: string
|
||||
hideRemove?: boolean
|
||||
}>()
|
||||
|
||||
function emitRemove() {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div v-if="addedDependencies.length" 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"
|
||||
:hide-remove="disableRemove"
|
||||
@remove="() => removeDependency(index)"
|
||||
/>
|
||||
</template>
|
||||
<span v-if="!addedDependencies.length"> No dependencies added. </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
|
||||
import AddedDependencyRow from './AddedDependencyRow.vue'
|
||||
|
||||
const { disableRemove } = defineProps<{
|
||||
disableRemove?: boolean
|
||||
}>()
|
||||
|
||||
const { draftVersion, dependencyProjects, dependencyVersions, projectsFetchLoading } =
|
||||
injectManageVersionContext()
|
||||
|
||||
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 removeDependency = (index: number) => {
|
||||
if (!draftVersion.value.dependencies) return
|
||||
draftVersion.value.dependencies.splice(index, 1)
|
||||
}
|
||||
</script>
|
||||
@@ -118,4 +118,17 @@ function groupLoaders(loaders: Labrinth.Tags.v2.Loader[]) {
|
||||
}
|
||||
|
||||
const groupedLoaders = computed(() => groupLoaders(loaders))
|
||||
|
||||
onMounted(() => {
|
||||
if (selectedLoaders.value.length === 0) return
|
||||
|
||||
// Find the first group that contains any of the selected loaders
|
||||
const groups = groupedLoaders.value
|
||||
for (const [groupName, loadersInGroup] of Object.entries(groups)) {
|
||||
if (loadersInGroup.some((loader) => selectedLoaders.value.includes(loader.name))) {
|
||||
loaderGroup.value = groupName as GroupLabels
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -66,7 +66,7 @@ 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'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
|
||||
type GameVersion = Labrinth.Tags.v2.GameVersion
|
||||
|
||||
@@ -147,9 +147,15 @@ function groupVersions(gameVersions: GameVersion[]) {
|
||||
)
|
||||
|
||||
const getGroupKey = (v: string) => v.split('.').slice(0, 2).join('.')
|
||||
|
||||
const getSnapshotGroupKey = (v: string) => {
|
||||
const cleanVersion = v.split('-')[0]
|
||||
return cleanVersion.split('.').slice(0, 2).join('.')
|
||||
}
|
||||
|
||||
const groups: Record<string, string[]> = {}
|
||||
|
||||
let currentGroupKey = getGroupKey(gameVersions.find((v) => v.major)?.version || '')
|
||||
let currentGroupKey = getSnapshotGroupKey(gameVersions.find((v) => v.major)?.version || '')
|
||||
|
||||
gameVersions.forEach((gameVersion) => {
|
||||
if (gameVersion.version_type === 'release') {
|
||||
@@ -157,6 +163,8 @@ function groupVersions(gameVersions: GameVersion[]) {
|
||||
if (!groups[currentGroupKey]) groups[currentGroupKey] = []
|
||||
groups[currentGroupKey].push(gameVersion.version)
|
||||
} else {
|
||||
if (!currentGroupKey) currentGroupKey = getSnapshotGroupKey(gameVersion.version)
|
||||
|
||||
const key = `${currentGroupKey} ${DEV_RELEASE_KEY}`
|
||||
if (!groups[key]) groups[key] = []
|
||||
groups[key].push(gameVersion.version)
|
||||
@@ -205,4 +213,27 @@ function compareGroupKeys(a: string, b: string) {
|
||||
function searchFilter(gameVersion: Labrinth.Tags.v2.GameVersion) {
|
||||
return gameVersion.version.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.modelValue.length === 0) return
|
||||
|
||||
// Open non-release tab if any non-release versions are selected
|
||||
const hasNonReleaseVersions = props.gameVersions.some(
|
||||
(v) => props.modelValue.includes(v.version) && v.version_type !== 'release',
|
||||
)
|
||||
|
||||
if (hasNonReleaseVersions) {
|
||||
versionType.value = 'all'
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
const firstSelectedVersion = allVersionsFlat.value.find((v) => props.modelValue.includes(v))
|
||||
if (firstSelectedVersion) {
|
||||
const buttons = Array.from(document.querySelectorAll('button'))
|
||||
const element = buttons.find((btn) => btn.textContent?.trim() === firstSelectedVersion)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,27 +1,24 @@
|
||||
<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 v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-2">
|
||||
<template v-for="(dependency, index) in visibleSuggestedDependencies">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@@ -32,28 +29,7 @@ import { injectManageVersionContext } from '~/providers/version/manage-version-m
|
||||
|
||||
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 { visibleSuggestedDependencies } = injectManageVersionContext()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'onAddSuggestion', dependency: Labrinth.Versions.v3.Dependency): void
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 rounded-xl bg-button-bg px-4 py-1 text-button-text"
|
||||
class="flex items-center justify-between gap-2 rounded-xl border-2 border-dashed border-surface-5 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" />
|
||||
@@ -9,7 +9,7 @@
|
||||
{{ name || 'Unknown Project' }}
|
||||
</span>
|
||||
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5">
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5 capitalize">
|
||||
{{ dependencyType }}
|
||||
</TagItem>
|
||||
</div>
|
||||
@@ -23,7 +23,7 @@
|
||||
</span>
|
||||
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<ButtonStyled size="standard" :circular="true">
|
||||
<ButtonStyled size="standard" :circular="true" type="transparent">
|
||||
<button aria-label="Add dependency" class="!shadow-none" @click="emitAddSuggestion">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
@@ -68,10 +68,16 @@
|
||||
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 type { ComboboxOption } from '@modrinth/ui/src/components/base/Combobox.vue'
|
||||
import { acceptFileFromProjectType } from '@modrinth/utils'
|
||||
|
||||
import {
|
||||
fileTypeLabels,
|
||||
injectManageVersionContext,
|
||||
} from '~/providers/version/manage-version-modal'
|
||||
|
||||
const { projectV2 } = injectProjectPageContext()
|
||||
const { projectType } = injectManageVersionContext()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'setPrimaryFile', file?: File): void
|
||||
@@ -89,16 +95,29 @@ const { name, isPrimary, onRemove, initialFileType, editingVersion } = definePro
|
||||
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'>[]
|
||||
const isDatapackProject = computed(() => projectType.value === 'datapack')
|
||||
|
||||
const versionTypes = computed(
|
||||
() =>
|
||||
[
|
||||
!editingVersion && { class: 'text-sm', value: 'primary', label: fileTypeLabels.primary },
|
||||
{ class: 'text-sm', value: 'unknown', label: fileTypeLabels.unknown },
|
||||
isDatapackProject.value && {
|
||||
class: 'text-sm',
|
||||
value: 'required-resource-pack',
|
||||
label: fileTypeLabels['required-resource-pack'],
|
||||
},
|
||||
isDatapackProject.value && {
|
||||
class: 'text-sm',
|
||||
value: 'optional-resource-pack',
|
||||
label: fileTypeLabels['optional-resource-pack'],
|
||||
},
|
||||
{ class: 'text-sm', value: 'sources-jar', label: fileTypeLabels['sources-jar'] },
|
||||
{ class: 'text-sm', value: 'dev-jar', label: fileTypeLabels['dev-jar'] },
|
||||
{ class: 'text-sm', value: 'javadoc-jar', label: fileTypeLabels['javadoc-jar'] },
|
||||
{ class: 'text-sm', value: 'signature', label: fileTypeLabels.signature },
|
||||
].filter(Boolean) as ComboboxOption<Labrinth.Versions.v3.FileType | 'primary'>[],
|
||||
)
|
||||
|
||||
function emitFileTypeChange() {
|
||||
if (selectedType.value === 'primary') emit('setPrimaryFile')
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<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">
|
||||
<FileIcon v-if="isPrimary" class="text-lg" />
|
||||
<FilePlusIcon v-else class="text-lg" />
|
||||
|
||||
<span v-tooltip="name" class="overflow-hidden text-ellipsis whitespace-nowrap font-medium">
|
||||
{{ name }}
|
||||
</span>
|
||||
|
||||
<TagItem class="shrink-0 border !border-solid border-surface-5">
|
||||
{{ isPrimary ? 'Primary' : fileTypeLabels[fileType ?? 'unknown'] }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { FileIcon, FilePlusIcon } from '@modrinth/assets'
|
||||
import { TagItem } from '@modrinth/ui'
|
||||
|
||||
import { fileTypeLabels } from '~/providers/version/manage-version-modal'
|
||||
|
||||
const { name, isPrimary, fileType } = defineProps<{
|
||||
name: string
|
||||
isPrimary?: boolean
|
||||
fileType?: Labrinth.Versions.v3.FileType | 'primary'
|
||||
}>()
|
||||
</script>
|
||||
@@ -1,23 +0,0 @@
|
||||
<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>
|
||||
@@ -1,275 +0,0 @@
|
||||
<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>
|
||||
@@ -1,11 +1,15 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-4 sm:w-[512px]">
|
||||
<template v-if="!(filesToAdd.length || draftVersion.existing_files?.length)">
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<template
|
||||
v-if="handlingNewFiles || !(filesToAdd.length || draftVersion.existing_files?.length)"
|
||||
>
|
||||
<DropzoneFileInput
|
||||
aria-label="Upload file"
|
||||
multiple
|
||||
:accept="acceptFileFromProjectType(projectV2.project_type)"
|
||||
:max-size="524288000"
|
||||
primary-prompt="Upload primary and supporting files"
|
||||
secondary-prompt="Drag and drop files or click to browse"
|
||||
@change="handleNewFiles"
|
||||
/>
|
||||
</template>
|
||||
@@ -21,11 +25,7 @@
|
||||
:is-primary="true"
|
||||
:editing-version="editingVersion"
|
||||
:on-remove="undefined"
|
||||
@set-primary-file="
|
||||
(file) => {
|
||||
if (file && !editingVersion) filesToAdd[0] = { file }
|
||||
}
|
||||
"
|
||||
@set-primary-file="(file) => file && replacePrimaryFile(file)"
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
@@ -72,7 +72,7 @@
|
||||
: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))"
|
||||
@set-primary-file="() => swapPrimaryFile(idx + (primaryFile?.existing ? 0 : 1))"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,8 +86,13 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { Admonition, DropzoneFileInput, injectProjectPageContext } from '@modrinth/ui'
|
||||
import {
|
||||
Admonition,
|
||||
defineMessages,
|
||||
DropzoneFileInput,
|
||||
injectProjectPageContext,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { acceptFileFromProjectType } from '@modrinth/utils'
|
||||
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
@@ -101,68 +106,18 @@ const {
|
||||
draftVersion,
|
||||
filesToAdd,
|
||||
existingFilesToDelete,
|
||||
setPrimaryFile,
|
||||
setInferredVersionData,
|
||||
handlingNewFiles,
|
||||
swapPrimaryFile,
|
||||
replacePrimaryFile,
|
||||
editingVersion,
|
||||
primaryFile,
|
||||
handleNewFiles,
|
||||
} = 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(
|
||||
@@ -170,38 +125,6 @@ function handleRemoveExistingFile(sha1: string) {
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="flex w-full max-w-full flex-col gap-6 sm:w-[512px]">
|
||||
<div class="flex w-full max-w-full flex-col gap-6">
|
||||
<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>
|
||||
<span class="font-semibold text-contrast">Project</span>
|
||||
<DependencySelect v-model="newDependencyProjectId" />
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button
|
||||
class="self-start"
|
||||
:disabled="!newDependencyProjectId"
|
||||
@@ -55,28 +55,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SuggestedDependencies
|
||||
:suggested-dependencies="suggestedDependencies"
|
||||
@on-add-suggestion="handleAddSuggestedDependency"
|
||||
/>
|
||||
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-4">
|
||||
<span class="font-semibold text-contrast">Suggested dependencies</span>
|
||||
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<DependenciesList />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -88,19 +74,26 @@ import {
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
} from '@modrinth/ui'
|
||||
import type { DropdownOption } from '@modrinth/ui/src/components/base/Combobox.vue'
|
||||
import type { ComboboxOption } 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 DependenciesList from '../components/DependenciesList.vue'
|
||||
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { labrinth } = injectModrinthClient()
|
||||
|
||||
const {
|
||||
draftVersion,
|
||||
dependencyProjects,
|
||||
dependencyVersions,
|
||||
projectsFetchLoading,
|
||||
visibleSuggestedDependencies,
|
||||
} = injectManageVersionContext()
|
||||
|
||||
const errorNotification = (err: any) => {
|
||||
addNotification({
|
||||
title: 'An error occurred',
|
||||
@@ -113,12 +106,7 @@ 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 }>
|
||||
>([])
|
||||
const newDependencyVersions = ref<ComboboxOption<string>[]>([])
|
||||
|
||||
// reset to defaults when select different project
|
||||
watch(newDependencyProjectId, async () => {
|
||||
@@ -140,91 +128,6 @@ watch(newDependencyProjectId, async () => {
|
||||
}
|
||||
})
|
||||
|
||||
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) => {
|
||||
@@ -249,12 +152,13 @@ const addedDependencies = computed(() =>
|
||||
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,
|
||||
)
|
||||
) {
|
||||
const alreadyAdded = draftVersion.value.dependencies.some((existing) => {
|
||||
if (existing.project_id !== dependency.project_id) return false
|
||||
if (!existing.version_id && !dependency.version_id) return true
|
||||
return existing.version_id === dependency.version_id
|
||||
})
|
||||
|
||||
if (alreadyAdded) {
|
||||
addNotification({
|
||||
title: 'Dependency already added',
|
||||
text: 'You cannot add the same dependency twice.',
|
||||
@@ -268,11 +172,6 @@ const addDependency = (dependency: Labrinth.Versions.v3.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,
|
||||
@@ -0,0 +1,69 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-6">
|
||||
<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"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
</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"
|
||||
:disabled="isUploading"
|
||||
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"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast"> Version changlog </span>
|
||||
|
||||
<div class="w-full">
|
||||
<MarkdownEditor
|
||||
v-model="draftVersion.changelog"
|
||||
:on-image-upload="onImageUpload"
|
||||
:min-height="150"
|
||||
:disabled="isUploading"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Chips, MarkdownEditor } from '@modrinth/ui'
|
||||
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
|
||||
const { draftVersion, isUploading } = injectManageVersionContext()
|
||||
|
||||
async function onImageUpload(file: File) {
|
||||
const response = await useImageUpload(file, { context: 'version' })
|
||||
return response.url
|
||||
}
|
||||
</script>
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="sm:w-[512px]">
|
||||
<ProjectSettingsEnvSelector v-model="draftVersion.environment" />
|
||||
<EnvironmentSelector v-model="draftVersion.environment" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ProjectSettingsEnvSelector } from '@modrinth/ui'
|
||||
import { EnvironmentSelector } from '@modrinth/ui'
|
||||
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="space-y-6 sm:w-[512px]">
|
||||
<div class="space-y-6">
|
||||
<LoaderPicker
|
||||
v-model="draftVersion.loaders"
|
||||
:loaders="generatedState.loaders"
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6 sm:w-[512px]">
|
||||
<div class="flex flex-col gap-6">
|
||||
<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">
|
||||
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<div v-if="!editingVersion" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast"> Uploaded files </span>
|
||||
|
||||
<ButtonStyled type="transparent" size="standard">
|
||||
<button @click="editFiles">
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<ViewOnlyFileRow
|
||||
v-if="primaryFile"
|
||||
:key="primaryFile.name"
|
||||
:name="primaryFile.name"
|
||||
:is-primary="true"
|
||||
/>
|
||||
<ViewOnlyFileRow
|
||||
v-for="file in supplementaryNewFiles"
|
||||
:key="file.file.name"
|
||||
:name="file.file.name"
|
||||
:file-type="file.fileType"
|
||||
/>
|
||||
<ViewOnlyFileRow
|
||||
v-for="file in supplementaryExistingFiles"
|
||||
:key="file.filename"
|
||||
:name="file.filename"
|
||||
:file-type="file.file_type"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 loaders cannot be edited'
|
||||
: isResourcePack
|
||||
? 'Resource pack loaders cannot be edited'
|
||||
: undefined
|
||||
"
|
||||
:disabled="isModpack || isResourcePack"
|
||||
@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>
|
||||
|
||||
<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 v-if="!noEnvironmentProject">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-contrast"> Environment </span>
|
||||
<UnknownIcon v-tooltip="'Pre-filled from a previous similar version'" />
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<template v-if="!noDependenciesProject">
|
||||
<div v-if="visibleSuggestedDependencies.length" class="flex flex-col gap-1">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast"> Suggested dependencies </span>
|
||||
|
||||
<ButtonStyled type="transparent" size="standard">
|
||||
<button @click="editDependencies">
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<SuggestedDependencies @on-add-suggestion="handleAddSuggestedDependency" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!visibleSuggestedDependencies.length || draftVersion.dependencies?.length"
|
||||
class="flex flex-col gap-1"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-semibold text-contrast"> Dependencies </span>
|
||||
|
||||
<ButtonStyled type="transparent" size="standard">
|
||||
<button @click="editDependencies">
|
||||
<EditIcon />
|
||||
Edit
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-if="draftVersion.dependencies?.length" class="flex flex-col gap-4">
|
||||
<DependenciesList disable-remove />
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1.5 gap-y-4 rounded-xl bg-surface-2 p-3 py-4">
|
||||
<span class="text-sm font-medium">No dependencies added.</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { EditIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
ENVIRONMENTS_COPY,
|
||||
injectProjectPageContext,
|
||||
TagItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatCategory } from '@modrinth/utils'
|
||||
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { injectManageVersionContext } from '~/providers/version/manage-version-modal'
|
||||
|
||||
import DependenciesList from '../components/DependenciesList.vue'
|
||||
import SuggestedDependencies from '../components/SuggestedDependencies/SuggestedDependencies.vue'
|
||||
import ViewOnlyFileRow from '../components/ViewOnlyFileRow.vue'
|
||||
|
||||
const {
|
||||
draftVersion,
|
||||
inferredVersionData,
|
||||
projectType,
|
||||
noEnvironmentProject,
|
||||
noDependenciesProject,
|
||||
modal,
|
||||
filesToAdd,
|
||||
editingVersion,
|
||||
visibleSuggestedDependencies,
|
||||
} = injectManageVersionContext()
|
||||
|
||||
const { projectV2 } = injectProjectPageContext()
|
||||
|
||||
const generatedState = useGeneratedState()
|
||||
const loaders = computed(() => generatedState.value.loaders)
|
||||
const isModpack = computed(() => projectType.value === 'modpack')
|
||||
const isResourcePack = computed(
|
||||
() =>
|
||||
projectType.value === 'resourcepack' &&
|
||||
(projectV2.value?.project_type === 'resourcepack' ||
|
||||
projectV2.value?.project_type === 'project'),
|
||||
)
|
||||
|
||||
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 editFiles = () => {
|
||||
modal.value?.setStage('from-details-files')
|
||||
}
|
||||
const editDependencies = () => {
|
||||
modal.value?.setStage('from-details-dependencies')
|
||||
}
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
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 { formatMessage } = useVIntl()
|
||||
|
||||
const noEnvironmentMessage = defineMessages({
|
||||
title: {
|
||||
id: 'version.environment.none.title',
|
||||
defaultMessage: 'No environment set',
|
||||
},
|
||||
description: {
|
||||
id: 'version.environment.none.description',
|
||||
defaultMessage: 'The environment for this version has not been specified.',
|
||||
},
|
||||
})
|
||||
|
||||
const unknownEnvironmentMessage = defineMessages({
|
||||
title: {
|
||||
id: 'version.environment.unknown.title',
|
||||
defaultMessage: 'Unknown environment',
|
||||
},
|
||||
description: {
|
||||
id: 'version.environment.unknown.description',
|
||||
defaultMessage: 'The environment: "{environment}" is not recognized.',
|
||||
},
|
||||
})
|
||||
|
||||
const environmentCopy = computed(() => {
|
||||
if (!draftVersion.value.environment) {
|
||||
return {
|
||||
title: formatMessage(noEnvironmentMessage.title),
|
||||
description: formatMessage(noEnvironmentMessage.description),
|
||||
}
|
||||
}
|
||||
|
||||
const envCopy = ENVIRONMENTS_COPY[draftVersion.value.environment]
|
||||
if (envCopy) {
|
||||
return {
|
||||
title: formatMessage(envCopy.title),
|
||||
description: formatMessage(envCopy.description),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: formatMessage(unknownEnvironmentMessage.title),
|
||||
description: formatMessage(unknownEnvironmentMessage.description, {
|
||||
environment: draftVersion.value.environment,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const handleAddSuggestedDependency = (dependency: Labrinth.Versions.v3.Dependency) => {
|
||||
if (!draftVersion.value.dependencies) draftVersion.value.dependencies = []
|
||||
draftVersion.value.dependencies.push({
|
||||
project_id: dependency.project_id,
|
||||
version_id: dependency.version_id,
|
||||
dependency_type: dependency.dependency_type,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -58,8 +58,13 @@
|
||||
</template>
|
||||
<script setup>
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { defineMessages } from '@vintl/vintl'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import CreateLimitAlert from './CreateLimitAlert.vue'
|
||||
|
||||
|
||||
@@ -42,9 +42,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MessageIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled } from '@modrinth/ui'
|
||||
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { capitalizeString } from '@modrinth/utils'
|
||||
import { defineMessages } from '@vintl/vintl'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -81,8 +81,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { defineMessages } from '@vintl/vintl'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import CreateLimitAlert from './CreateLimitAlert.vue'
|
||||
|
||||
@@ -95,8 +95,14 @@
|
||||
|
||||
<script setup>
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Chips, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { defineMessages } from '@vintl/vintl'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import CreateLimitAlert from './CreateLimitAlert.vue'
|
||||
|
||||
|
||||
@@ -162,12 +162,13 @@ import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Chips,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
normalizeChildren,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
|
||||
import { type FormRequestResponse, useAvalara1099 } from '@/composables/avalara1099'
|
||||
|
||||
|
||||
@@ -116,12 +116,19 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, commonMessages, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import {
|
||||
createWithdrawContext,
|
||||
type PaymentProvider,
|
||||
type PayoutMethod,
|
||||
provideWithdrawContext,
|
||||
TAX_THRESHOLD_ACTUAL,
|
||||
@@ -326,51 +333,76 @@ function continueWithLimit() {
|
||||
setStage(nextStep.value)
|
||||
}
|
||||
|
||||
// TODO: God we need better errors from the backend (e.g error ids), this shit is insane
|
||||
function getWithdrawalError(error: any): { title: string; text: string } {
|
||||
const description = error?.data?.description?.toLowerCase() || ''
|
||||
function buildSupportData(error: any): Record<string, unknown> {
|
||||
// Extract response headers, excluding sensitive ones
|
||||
const responseHeaders: Record<string, string> = {}
|
||||
if (error?.response?.headers) {
|
||||
const headers = error.response.headers
|
||||
const entries =
|
||||
typeof headers.entries === 'function' ? [...headers.entries()] : Object.entries(headers)
|
||||
for (const [key, value] of entries) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
// Exclude sensitive headers
|
||||
if (!['authorization', 'cookie', 'set-cookie'].includes(lowerKey)) {
|
||||
responseHeaders[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
provider: withdrawData.value.selection.provider,
|
||||
method: withdrawData.value.selection.method,
|
||||
methodId: withdrawData.value.selection.methodId,
|
||||
country: withdrawData.value.selection.country?.id,
|
||||
amount: withdrawData.value.calculation?.amount,
|
||||
fee: withdrawData.value.calculation?.fee,
|
||||
request: {
|
||||
url: 'POST /api/v3/payout',
|
||||
},
|
||||
response: {
|
||||
status: error?.response?.status ?? error?.statusCode,
|
||||
statusText: error?.response?.statusText,
|
||||
headers: responseHeaders,
|
||||
body: error?.data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatBilingualText(
|
||||
messageDescriptor: { id: string; defaultMessage: string },
|
||||
values?: Record<string, string>,
|
||||
): string {
|
||||
const localized = formatMessage(messageDescriptor, values)
|
||||
// Interpolate values into the English default message
|
||||
let english = messageDescriptor.defaultMessage
|
||||
if (values) {
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
english = english.replace(`{${key}}`, value)
|
||||
}
|
||||
}
|
||||
|
||||
if (localized === english) {
|
||||
return localized
|
||||
}
|
||||
|
||||
return `${localized}\n${english}`
|
||||
}
|
||||
|
||||
function getWithdrawalError(
|
||||
error: any,
|
||||
provider: PaymentProvider | null,
|
||||
): { title: string; text: string; supportData: Record<string, unknown> } {
|
||||
const description = error?.data?.description?.toLowerCase() || ''
|
||||
const supportData = buildSupportData(error)
|
||||
|
||||
// === Common patterns (all providers) ===
|
||||
// Tax form error
|
||||
if (description.includes('tax form')) {
|
||||
return {
|
||||
title: formatMessage(messages.errorTaxFormTitle),
|
||||
text: formatMessage(messages.errorTaxFormText),
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid crypto wallet address
|
||||
if (
|
||||
(description.includes('wallet') && description.includes('invalid')) ||
|
||||
description.includes('wallet_address') ||
|
||||
(description.includes('blockchain') && description.includes('invalid'))
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorInvalidWalletTitle),
|
||||
text: formatMessage(messages.errorInvalidWalletText),
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid bank details
|
||||
if (
|
||||
(description.includes('bank') || description.includes('account')) &&
|
||||
(description.includes('invalid') || description.includes('failed'))
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorInvalidBankTitle),
|
||||
text: formatMessage(messages.errorInvalidBankText),
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid/fraudulent address
|
||||
if (
|
||||
description.includes('address') &&
|
||||
(description.includes('invalid') ||
|
||||
description.includes('verification') ||
|
||||
description.includes('fraudulent'))
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorInvalidAddressTitle),
|
||||
text: formatMessage(messages.errorInvalidAddressText),
|
||||
text: formatBilingualText(messages.errorTaxFormText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,14 +414,106 @@ function getWithdrawalError(error: any): { title: string; text: string } {
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorMinimumNotMetTitle),
|
||||
text: formatMessage(messages.errorMinimumNotMetText),
|
||||
text: formatBilingualText(messages.errorMinimumNotMetText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
|
||||
// Insufficient balance
|
||||
if (description.includes('enough funds') || description.includes('insufficient')) {
|
||||
return {
|
||||
title: formatMessage(messages.errorInsufficientBalanceTitle),
|
||||
text: formatBilingualText(messages.errorInsufficientBalanceText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
|
||||
// Email verification required
|
||||
if (description.includes('verify your email') || description.includes('email_verified')) {
|
||||
return {
|
||||
title: formatMessage(messages.errorEmailVerificationTitle),
|
||||
text: formatBilingualText(messages.errorEmailVerificationText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
|
||||
// === MuralPay-only patterns ===
|
||||
if (provider === 'muralpay') {
|
||||
// Invalid crypto wallet address
|
||||
if (
|
||||
(description.includes('wallet') && description.includes('invalid')) ||
|
||||
description.includes('wallet_address') ||
|
||||
(description.includes('blockchain') && description.includes('invalid'))
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorInvalidWalletTitle),
|
||||
text: formatBilingualText(messages.errorInvalidWalletText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid bank details
|
||||
if (
|
||||
(description.includes('bank') || description.includes('account')) &&
|
||||
(description.includes('invalid') || description.includes('failed'))
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorInvalidBankTitle),
|
||||
text: formatBilingualText(messages.errorInvalidBankText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
|
||||
// Invalid/fraudulent address (physical address for KYC)
|
||||
if (
|
||||
description.includes('address') &&
|
||||
(description.includes('invalid') ||
|
||||
description.includes('verification') ||
|
||||
description.includes('fraudulent'))
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorInvalidAddressTitle),
|
||||
text: formatBilingualText(messages.errorInvalidAddressText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === PayPal/Venmo-only patterns ===
|
||||
if (provider === 'paypal' || provider === 'venmo') {
|
||||
// Account not linked
|
||||
if (
|
||||
description.includes('not linked') ||
|
||||
description.includes('link') ||
|
||||
description.includes('paypal account') ||
|
||||
description.includes('venmo')
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorAccountNotLinkedTitle),
|
||||
text: formatBilingualText(messages.errorAccountNotLinkedText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
|
||||
// Country mismatch for PayPal
|
||||
if (
|
||||
provider === 'paypal' &&
|
||||
(description.includes('us paypal') || description.includes('international paypal'))
|
||||
) {
|
||||
return {
|
||||
title: formatMessage(messages.errorPaypalCountryMismatchTitle),
|
||||
text: formatBilingualText(messages.errorPaypalCountryMismatchText),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generic fallback
|
||||
const errorDescription = error?.data?.description || ''
|
||||
return {
|
||||
title: formatMessage(messages.errorGenericTitle),
|
||||
text: formatMessage(messages.errorGenericText),
|
||||
text: formatBilingualText(messages.errorGenericText, { error: errorDescription }),
|
||||
supportData,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -403,11 +527,15 @@ async function handleWithdraw() {
|
||||
} catch (error) {
|
||||
console.error('Withdrawal failed:', error)
|
||||
|
||||
const { title, text } = getWithdrawalError(error)
|
||||
const { title, text, supportData } = getWithdrawalError(
|
||||
error,
|
||||
withdrawData.value.selection.provider,
|
||||
)
|
||||
addNotification({
|
||||
title,
|
||||
text,
|
||||
type: 'error',
|
||||
supportData,
|
||||
})
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
@@ -564,7 +692,40 @@ const messages = defineMessages({
|
||||
errorGenericText: {
|
||||
id: 'dashboard.withdraw.error.generic.text',
|
||||
defaultMessage:
|
||||
'We were unable to submit your withdrawal request, please check your details or contact support.',
|
||||
'We were unable to submit your withdrawal request, please check your details or contact support.\n{error}',
|
||||
},
|
||||
errorInsufficientBalanceTitle: {
|
||||
id: 'dashboard.withdraw.error.insufficient-balance.title',
|
||||
defaultMessage: 'Insufficient balance',
|
||||
},
|
||||
errorInsufficientBalanceText: {
|
||||
id: 'dashboard.withdraw.error.insufficient-balance.text',
|
||||
defaultMessage: 'You do not have enough funds to make this withdrawal.',
|
||||
},
|
||||
errorEmailVerificationTitle: {
|
||||
id: 'dashboard.withdraw.error.email-verification.title',
|
||||
defaultMessage: 'Email verification required',
|
||||
},
|
||||
errorEmailVerificationText: {
|
||||
id: 'dashboard.withdraw.error.email-verification.text',
|
||||
defaultMessage: 'You must verify your email address before withdrawing funds.',
|
||||
},
|
||||
errorAccountNotLinkedTitle: {
|
||||
id: 'dashboard.withdraw.error.account-not-linked.title',
|
||||
defaultMessage: 'Account not linked',
|
||||
},
|
||||
errorAccountNotLinkedText: {
|
||||
id: 'dashboard.withdraw.error.account-not-linked.text',
|
||||
defaultMessage: 'Please link your payment account before withdrawing.',
|
||||
},
|
||||
errorPaypalCountryMismatchTitle: {
|
||||
id: 'dashboard.withdraw.error.paypal-country-mismatch.title',
|
||||
defaultMessage: 'PayPal region mismatch',
|
||||
},
|
||||
errorPaypalCountryMismatchText: {
|
||||
id: 'dashboard.withdraw.error.paypal-country-mismatch.text',
|
||||
defaultMessage:
|
||||
'Please use the correct PayPal transfer option for your region (US or International).',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -49,9 +49,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, Combobox, commonMessages, formFieldPlaceholders } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
commonMessages,
|
||||
formFieldPlaceholders,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
<template>
|
||||
<div class="flex flex-row gap-2 md:gap-3">
|
||||
<div
|
||||
class="flex h-10 min-h-10 w-10 min-w-10 justify-center rounded-full border-[1px] border-solid border-button-bg bg-bg-raised !p-0 shadow-md md:h-12 md:min-h-12 md:w-12 md:min-w-12"
|
||||
class="flex h-10 min-h-10 w-10 min-w-10 items-center justify-center rounded-full border-[1px] border-solid border-button-bg bg-bg-raised !p-0 shadow-md md:h-12 md:min-h-12 md:w-12 md:min-w-12"
|
||||
>
|
||||
<ArrowDownIcon v-if="isIncome" class="my-auto size-6 text-secondary md:size-8" />
|
||||
<ArrowUpIcon v-else class="my-auto size-6 text-secondary md:size-8" />
|
||||
<img
|
||||
v-if="methodIconUrl"
|
||||
:src="methodIconUrl"
|
||||
alt=""
|
||||
class="size-6 rounded-full object-cover md:size-8"
|
||||
/>
|
||||
<component
|
||||
:is="methodIconComponent"
|
||||
v-else-if="methodIconComponent"
|
||||
class="size-6 md:size-8"
|
||||
/>
|
||||
<ArrowDownIcon v-else-if="isIncome" class="size-6 text-secondary md:size-8" />
|
||||
<ArrowUpIcon v-else class="size-6 text-secondary md:size-8" />
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-between">
|
||||
<div class="flex flex-col">
|
||||
<span class="text-base font-semibold text-contrast md:text-lg">{{
|
||||
transaction.type === 'payout_available'
|
||||
? formatPayoutSource(transaction.payout_source)
|
||||
: formatMethodName(transaction.method_type || transaction.method)
|
||||
: formatMethodName(transaction.method_type || transaction.method, transaction.method_id)
|
||||
}}</span>
|
||||
<span class="text-xs text-secondary md:text-sm">
|
||||
<template v-if="transaction.type === 'withdrawal'">
|
||||
@@ -33,8 +44,8 @@
|
||||
<div class="my-auto flex flex-row items-center gap-2">
|
||||
<span
|
||||
class="text-base font-semibold md:text-lg"
|
||||
:class="transaction.type === 'payout_available' ? 'text-green' : 'text-contrast'"
|
||||
>{{ formatMoney(transaction.amount) }}</span
|
||||
:class="isIncome ? 'text-green' : 'text-contrast'"
|
||||
>{{ isIncome ? '' : '-' }}{{ formatMoney(transaction.amount) }}</span
|
||||
>
|
||||
<template v-if="transaction.type === 'withdrawal' && transaction.status === 'in-transit'">
|
||||
<Tooltip theme="dismissable-prompt" :triggers="['hover', 'focus']" no-auto-focus>
|
||||
@@ -55,12 +66,28 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowDownIcon, ArrowUpIcon, XIcon } from '@modrinth/assets'
|
||||
import { BulletDivider, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowUpIcon,
|
||||
LandmarkIcon,
|
||||
PayPalColorIcon,
|
||||
VenmoColorIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
BulletDivider,
|
||||
ButtonStyled,
|
||||
getCurrencyIcon,
|
||||
injectNotificationManager,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { capitalizeString, formatMoney } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { findRail } from '~/utils/muralpay-rails'
|
||||
|
||||
type PayoutStatus = 'in-transit' | 'cancelling' | 'cancelled' | 'success' | 'failed'
|
||||
type PayoutMethodType = 'paypal' | 'venmo' | 'tremendous' | 'muralpay'
|
||||
type PayoutSource = 'creator_rewards' | 'affilites'
|
||||
@@ -96,15 +123,73 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const generatedState = useGeneratedState()
|
||||
|
||||
const isIncome = computed(() => props.transaction.type === 'payout_available')
|
||||
|
||||
const methodIconUrl = computed(() => {
|
||||
if (props.transaction.type !== 'withdrawal') return null
|
||||
const method = props.transaction.method_type || props.transaction.method
|
||||
const methodId = props.transaction.method_id
|
||||
|
||||
if (method === 'tremendous' && methodId) {
|
||||
const methodInfo = generatedState.value.tremendousIdMap?.[methodId]
|
||||
if (methodInfo?.name?.toLowerCase()?.includes('paypal')) return null
|
||||
return methodInfo?.image_url ?? null
|
||||
}
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
const methodIconComponent = computed(() => {
|
||||
if (props.transaction.type !== 'withdrawal') return null
|
||||
const method = props.transaction.method_type || props.transaction.method
|
||||
switch (method) {
|
||||
case 'paypal':
|
||||
return PayPalColorIcon
|
||||
case 'tremendous': {
|
||||
const methodId = props.transaction.method_id
|
||||
if (methodId) {
|
||||
const info = generatedState.value.tremendousIdMap?.[methodId]
|
||||
if (info?.name?.toLowerCase()?.includes('paypal')) {
|
||||
return PayPalColorIcon
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
case 'venmo':
|
||||
return VenmoColorIcon
|
||||
case 'muralpay': {
|
||||
const methodId = props.transaction.method_id
|
||||
if (methodId) {
|
||||
const rail = findRail(methodId)
|
||||
if (rail) {
|
||||
if (rail.type === 'crypto') {
|
||||
const currencyIcon = getCurrencyIcon(rail.currency)
|
||||
if (currencyIcon) return currencyIcon
|
||||
}
|
||||
if (rail.type === 'fiat') {
|
||||
const currencyIcon = getCurrencyIcon(rail.currency)
|
||||
if (currencyIcon) return currencyIcon
|
||||
return LandmarkIcon
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
function formatTransactionStatus(status: string): string {
|
||||
if (status === 'in-transit') return 'In Transit'
|
||||
return capitalizeString(status)
|
||||
}
|
||||
|
||||
function formatMethodName(method: string | undefined): string {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
function formatMethodName(method: string | undefined, method_id: string | undefined): string {
|
||||
if (!method) return 'Unknown'
|
||||
switch (method) {
|
||||
case 'paypal':
|
||||
@@ -112,9 +197,19 @@ function formatMethodName(method: string | undefined): string {
|
||||
case 'venmo':
|
||||
return 'Venmo'
|
||||
case 'tremendous':
|
||||
if (method_id) {
|
||||
const info = generatedState.value.tremendousIdMap?.[method_id]
|
||||
if (info) return `${info.name}`
|
||||
}
|
||||
return 'Tremendous'
|
||||
case 'muralpay':
|
||||
return 'Muralpay'
|
||||
if (method_id) {
|
||||
const rail = findRail(method_id)
|
||||
if (rail) {
|
||||
return formatMessage(rail.name)
|
||||
}
|
||||
}
|
||||
return 'Mural Pay (Unknown)'
|
||||
default:
|
||||
return capitalizeString(method)
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-primary">{{ formatMessage(messages.feeBreakdownGiftCardValue) }}</span>
|
||||
<span class="font-semibold text-contrast"
|
||||
>{{ formatMoney(amount || 0) }} ({{ formattedLocalCurrency }})</span
|
||||
>{{ formatMoney(amountInUsd) }} ({{ formattedLocalCurrencyAmount }})</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>
|
||||
<span class="font-semibold text-contrast">{{ formatMoney(amountInUsd) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<template v-if="feeLoading">
|
||||
<LoaderCircleIcon class="size-5 animate-spin !text-secondary" />
|
||||
</template>
|
||||
<template v-else>-{{ formatMoney(fee || 0) }}</template>
|
||||
<template v-else>-{{ formatMoney(feeInUsd) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircleIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -79,9 +79,23 @@ const props = withDefaults(
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const amountInUsd = computed(() => {
|
||||
if (props.isGiftCard && shouldShowExchangeRate.value) {
|
||||
return (props.amount || 0) / (props.exchangeRate || 1)
|
||||
}
|
||||
return props.amount || 0
|
||||
})
|
||||
|
||||
const feeInUsd = computed(() => {
|
||||
if (props.isGiftCard && shouldShowExchangeRate.value) {
|
||||
return (props.fee || 0) / (props.exchangeRate || 1)
|
||||
}
|
||||
return props.fee || 0
|
||||
})
|
||||
|
||||
const netAmount = computed(() => {
|
||||
const amount = props.amount || 0
|
||||
const fee = props.fee || 0
|
||||
const amount = amountInUsd.value
|
||||
const fee = feeInUsd.value
|
||||
return Math.max(0, amount - fee)
|
||||
})
|
||||
|
||||
@@ -96,6 +110,11 @@ const netAmountInLocalCurrency = computed(() => {
|
||||
return netAmount.value * (props.exchangeRate || 0)
|
||||
})
|
||||
|
||||
const localCurrencyAmount = computed(() => {
|
||||
if (!shouldShowExchangeRate.value) return null
|
||||
return props.amount || 0
|
||||
})
|
||||
|
||||
const formattedLocalCurrency = computed(() => {
|
||||
if (!shouldShowExchangeRate.value || !netAmountInLocalCurrency.value || !props.localCurrency)
|
||||
return ''
|
||||
@@ -112,6 +131,21 @@ const formattedLocalCurrency = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const formattedLocalCurrencyAmount = computed(() => {
|
||||
if (!shouldShowExchangeRate.value || !localCurrencyAmount.value || !props.localCurrency) return ''
|
||||
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: props.localCurrency,
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
}).format(localCurrencyAmount.value)
|
||||
} catch {
|
||||
return `${props.localCurrency} ${localCurrencyAmount.value.toFixed(2)}`
|
||||
}
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
feeBreakdownAmount: {
|
||||
id: 'dashboard.creator-withdraw-modal.fee-breakdown-amount',
|
||||
|
||||
@@ -124,10 +124,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { normalizeChildren } from '@modrinth/ui'
|
||||
import { defineMessages, IntlFormatted, normalizeChildren, useVIntl } from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import ConfettiExplosion from 'vue-confetti-explosion'
|
||||
|
||||
@@ -107,12 +107,13 @@ import { CheckIcon, PayPalColorIcon, SaveIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
financialMessages,
|
||||
formFieldLabels,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
|
||||
@@ -83,13 +83,14 @@ import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { useGeolocation } from '@vueuse/core'
|
||||
|
||||
import { useCountries, useFormattedCountries, useUserCountry } from '@/composables/country.ts'
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRail?.type === 'fiat'" class="flex flex-col gap-2.5">
|
||||
<div v-if="selectedRail?.type === 'fiat' && !isBusinessEntity" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.accountOwner) }}
|
||||
@@ -46,6 +46,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRail?.type === 'fiat' && isBusinessEntity" class="flex flex-col gap-2">
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
{{ formatMessage(messages.bankAccountOwner) }}
|
||||
<span class="text-red">*</span>
|
||||
</span>
|
||||
<span class="text-sm leading-tight text-primary">
|
||||
{{ formatMessage(messages.bankAccountOwnerDescription) }}
|
||||
</span>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:gap-4">
|
||||
<div class="flex flex-1 flex-col gap-2.5">
|
||||
<input
|
||||
v-model="formData.bankAccountOwnerFirstName"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.firstNamePlaceholder)"
|
||||
autocomplete="given-name"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col gap-2.5">
|
||||
<input
|
||||
v-model="formData.bankAccountOwnerLastName"
|
||||
type="text"
|
||||
:placeholder="formatMessage(formFieldPlaceholders.lastNamePlaceholder)"
|
||||
autocomplete="family-name"
|
||||
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedRail?.requiresBankName" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
@@ -204,15 +234,18 @@ import {
|
||||
Admonition,
|
||||
Checkbox,
|
||||
Combobox,
|
||||
defineMessages,
|
||||
financialMessages,
|
||||
formFieldLabels,
|
||||
formFieldPlaceholders,
|
||||
getBlockchainColor,
|
||||
getBlockchainIcon,
|
||||
getCurrencyColor,
|
||||
getCurrencyIcon,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -276,6 +309,8 @@ const existingAmount = withdrawData.value.calculation.amount
|
||||
const formData = ref<Record<string, any>>({
|
||||
amount: existingAmount || undefined,
|
||||
bankName: existingAccountDetails?.bankName ?? '',
|
||||
bankAccountOwnerFirstName: existingAccountDetails?.bankAccountOwnerFirstName ?? '',
|
||||
bankAccountOwnerLastName: existingAccountDetails?.bankAccountOwnerLastName ?? '',
|
||||
...existingAccountDetails,
|
||||
})
|
||||
|
||||
@@ -360,6 +395,12 @@ const accountOwnerAddress = computed(() => {
|
||||
return parts.join(', ')
|
||||
})
|
||||
|
||||
const isBusinessEntity = computed(() => {
|
||||
const providerDataValue = withdrawData.value.providerData
|
||||
if (providerDataValue.type !== 'muralpay') return false
|
||||
return providerDataValue.kycData?.type === 'business'
|
||||
})
|
||||
|
||||
const allRequiredFieldsFilled = computed(() => {
|
||||
const rail = selectedRail.value
|
||||
if (!rail) return false
|
||||
@@ -508,5 +549,14 @@ const messages = defineMessages({
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.document-number-tax-id-placeholder',
|
||||
defaultMessage: 'Enter tax ID number',
|
||||
},
|
||||
bankAccountOwner: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.bank-account-owner',
|
||||
defaultMessage: 'Bank account owner',
|
||||
},
|
||||
bankAccountOwnerDescription: {
|
||||
id: 'dashboard.creator-withdraw-modal.muralpay-details.bank-account-owner-description',
|
||||
defaultMessage:
|
||||
'Enter the name of the person authorized to operate this bank account on behalf of the business.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -218,8 +218,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Chips, Combobox, formFieldLabels, formFieldPlaceholders } from '@modrinth/ui'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import {
|
||||
Chips,
|
||||
Combobox,
|
||||
defineMessages,
|
||||
formFieldLabels,
|
||||
formFieldPlaceholders,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
// TODO: Switch to using Muralpay's improved endpoint when it's available.
|
||||
import iso3166 from 'iso-3166-2'
|
||||
|
||||
|
||||
@@ -74,10 +74,15 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileTextIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, normalizeChildren } from '@modrinth/ui'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { TAX_THRESHOLD_ACTUAL } from '@/providers/creator-withdraw.ts'
|
||||
|
||||
@@ -15,27 +15,6 @@
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<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-40"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="shouldShowUsdWarning" class="overflow-hidden">
|
||||
<Admonition type="warning" :header="formatMessage(messages.usdPaypalWarningHeader)">
|
||||
<IntlFormatted :message-id="messages.usdPaypalWarningMessage">
|
||||
<template #direct-paypal-link="{ children }">
|
||||
<span class="cursor-pointer text-link" @click="switchToDirectPaypal">
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</Admonition>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="!showGiftCardSelector && selectedMethodDisplay" class="flex flex-col gap-2.5">
|
||||
<label>
|
||||
<span class="text-md font-semibold text-contrast">
|
||||
@@ -111,7 +90,14 @@
|
||||
</Combobox>
|
||||
</div>
|
||||
<span v-if="selectedMethodDetails" class="text-secondary">
|
||||
{{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
|
||||
{{
|
||||
formatMoney(
|
||||
selectedMethodCurrencyCode &&
|
||||
selectedMethodCurrencyCode !== 'USD' &&
|
||||
selectedMethodExchangeRate
|
||||
? (fixedDenominationMin ?? effectiveMinAmount) / selectedMethodExchangeRate
|
||||
: (fixedDenominationMin ?? effectiveMinAmount),
|
||||
)
|
||||
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
|
||||
({{
|
||||
formatAmountForDisplay(
|
||||
@@ -124,9 +110,15 @@
|
||||
min,
|
||||
{{
|
||||
formatMoney(
|
||||
fixedDenominationMax ??
|
||||
selectedMethodDetails.interval?.standard?.max ??
|
||||
effectiveMaxAmount,
|
||||
selectedMethodCurrencyCode &&
|
||||
selectedMethodCurrencyCode !== 'USD' &&
|
||||
selectedMethodExchangeRate
|
||||
? (fixedDenominationMax ??
|
||||
selectedMethodDetails.interval?.standard?.max ??
|
||||
effectiveMaxAmount) / selectedMethodExchangeRate
|
||||
: (fixedDenominationMax ??
|
||||
selectedMethodDetails.interval?.standard?.max ??
|
||||
effectiveMaxAmount),
|
||||
)
|
||||
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
|
||||
({{
|
||||
@@ -145,7 +137,15 @@
|
||||
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
|
||||
class="text-sm text-red"
|
||||
>
|
||||
You need at least {{ formatMoney(effectiveMinAmount)
|
||||
You need at least
|
||||
{{
|
||||
formatMoney(
|
||||
selectedMethodCurrencyCode &&
|
||||
selectedMethodCurrencyCode !== 'USD' &&
|
||||
selectedMethodExchangeRate
|
||||
? effectiveMinAmount / selectedMethodExchangeRate
|
||||
: effectiveMinAmount,
|
||||
)
|
||||
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
|
||||
({{
|
||||
formatAmountForDisplay(
|
||||
@@ -207,7 +207,7 @@
|
||||
formatMessage(messages.balanceWorthHint, {
|
||||
usdBalance: formatMoney(roundedMaxAmount),
|
||||
localBalance: formatAmountForDisplay(
|
||||
roundedMaxAmount,
|
||||
roundedMaxAmount * selectedMethodExchangeRate,
|
||||
selectedMethodCurrencyCode,
|
||||
selectedMethodExchangeRate,
|
||||
),
|
||||
@@ -273,7 +273,7 @@
|
||||
formatMessage(messages.balanceWorthHint, {
|
||||
usdBalance: formatMoney(roundedMaxAmount),
|
||||
localBalance: formatAmountForDisplay(
|
||||
roundedMaxAmount,
|
||||
roundedMaxAmount * selectedMethodExchangeRate,
|
||||
selectedMethodCurrencyCode,
|
||||
selectedMethodExchangeRate,
|
||||
),
|
||||
@@ -356,35 +356,28 @@ import {
|
||||
Checkbox,
|
||||
Chips,
|
||||
Combobox,
|
||||
defineMessages,
|
||||
financialMessages,
|
||||
formFieldLabels,
|
||||
formFieldPlaceholders,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
paymentMethodMessages,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { formatMoney } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { useDebounceFn } from '@vueuse/core'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import RevenueInputField from '@/components/ui/dashboard/RevenueInputField.vue'
|
||||
import WithdrawFeeBreakdown from '@/components/ui/dashboard/WithdrawFeeBreakdown.vue'
|
||||
import { useAuth } from '@/composables/auth.js'
|
||||
import { useBaseFetch } from '@/composables/fetch.js'
|
||||
import { type PayoutMethod, useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
import { useWithdrawContext } from '@/providers/creator-withdraw.ts'
|
||||
|
||||
const debug = useDebugLogger('TremendousDetailsStage')
|
||||
const {
|
||||
withdrawData,
|
||||
maxWithdrawAmount,
|
||||
availableMethods,
|
||||
paymentOptions,
|
||||
calculateFees,
|
||||
setStage,
|
||||
paymentMethodsCache,
|
||||
} = useWithdrawContext()
|
||||
const { withdrawData, maxWithdrawAmount, availableMethods, paymentOptions, calculateFees } =
|
||||
useWithdrawContext()
|
||||
const { formatMessage } = useVIntl()
|
||||
const auth = await useAuth()
|
||||
|
||||
@@ -409,12 +402,6 @@ const showPayPalCurrencySelector = computed(() => {
|
||||
return method === 'paypal'
|
||||
})
|
||||
|
||||
const shouldShowUsdWarning = computed(() => {
|
||||
const method = withdrawData.value.selection.method
|
||||
const currency = selectedCurrency.value
|
||||
return method === 'paypal' && currency === 'USD'
|
||||
})
|
||||
|
||||
const selectedMethodDisplay = computed(() => {
|
||||
const method = withdrawData.value.selection.method
|
||||
if (!method) return null
|
||||
@@ -607,14 +594,13 @@ const giftCardExchangeRate = computed(() => {
|
||||
})
|
||||
|
||||
function formatAmountForDisplay(
|
||||
usdAmount: number,
|
||||
localAmount: number,
|
||||
currencyCode: string | null | undefined,
|
||||
rate: number | null | undefined,
|
||||
): string {
|
||||
if (!currencyCode || currencyCode === 'USD' || !rate) {
|
||||
return formatMoney(usdAmount)
|
||||
return formatMoney(localAmount)
|
||||
}
|
||||
const localAmount = usdAmount * rate
|
||||
try {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
@@ -963,47 +949,6 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function switchToDirectPaypal() {
|
||||
withdrawData.value.selection.country = {
|
||||
id: 'US',
|
||||
name: 'United States',
|
||||
}
|
||||
|
||||
let usMethods = paymentMethodsCache.value['US']
|
||||
|
||||
if (!usMethods) {
|
||||
try {
|
||||
usMethods = (await useBaseFetch('payout/methods', {
|
||||
apiVersion: 3,
|
||||
query: { country: 'US' },
|
||||
})) as PayoutMethod[]
|
||||
|
||||
paymentMethodsCache.value['US'] = usMethods
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch US payment methods:', error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
availableMethods.value = usMethods
|
||||
|
||||
const directPaypal = usMethods.find((m) => m.type === 'paypal')
|
||||
|
||||
if (directPaypal) {
|
||||
withdrawData.value.selection.provider = 'paypal'
|
||||
withdrawData.value.selection.method = directPaypal.id
|
||||
withdrawData.value.selection.methodId = directPaypal.id
|
||||
|
||||
withdrawData.value.providerData = {
|
||||
type: 'paypal',
|
||||
}
|
||||
|
||||
await setStage('paypal-details', true)
|
||||
} else {
|
||||
console.error('An error occured - no paypal in US region??')
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
unverifiedEmailHeader: {
|
||||
id: 'dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ClipboardCopyIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import { ClipboardCopyIcon, DownloadIcon, LoaderCircleIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, CopyCode, NewModal } from '@modrinth/ui'
|
||||
import { ref, useTemplateRef } from 'vue'
|
||||
|
||||
@@ -38,15 +38,16 @@ async function fetchVersionHashes(versionIds: string[]) {
|
||||
// TODO: switch to api-client once truman's vers stuff is merged
|
||||
const version = (await useBaseFetch(`version/${versionId}`)) as {
|
||||
files: Array<{
|
||||
id?: string
|
||||
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)
|
||||
if (file.id) {
|
||||
filesMap.set(file.id, file.hashes.sha512)
|
||||
}
|
||||
}
|
||||
versionDataCache.value.set(versionId, { files: filesMap, loading: false })
|
||||
} catch (error) {
|
||||
@@ -60,8 +61,8 @@ async function fetchVersionHashes(versionIds: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
function getFileHash(versionId: string, fileName: string): string | undefined {
|
||||
return versionDataCache.value.get(versionId)?.files.get(fileName)
|
||||
function getFileHash(versionId: string, fileId: string): string | undefined {
|
||||
return versionDataCache.value.get(versionId)?.files.get(fileId)
|
||||
}
|
||||
|
||||
function isHashLoading(versionId: string): boolean {
|
||||
@@ -114,6 +115,7 @@ defineExpose({ show, hide })
|
||||
<th class="pb-2">Version ID</th>
|
||||
<th class="pb-2">File Name</th>
|
||||
<th class="pb-2">CDN Link</th>
|
||||
<th class="pb-2">Download</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -124,11 +126,11 @@ defineExpose({ show, hide })
|
||||
class="size-4 animate-spin text-secondary"
|
||||
/>
|
||||
<ButtonStyled
|
||||
v-else-if="getFileHash(item.file.version_id, item.file.file_name)"
|
||||
v-else-if="getFileHash(item.file.version_id, item.file.file_id)"
|
||||
size="small"
|
||||
type="standard"
|
||||
>
|
||||
<button @click="copy(getFileHash(item.file.version_id, item.file.file_name)!)">
|
||||
<button @click="copy(getFileHash(item.file.version_id, item.file.file_id)!)">
|
||||
<ClipboardCopyIcon class="size-4" />
|
||||
Copy
|
||||
</button>
|
||||
@@ -141,7 +143,7 @@ defineExpose({ show, hide })
|
||||
<td class="py-1 pr-2">
|
||||
<CopyCode :text="item.file.file_name" />
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<td class="py-1 pr-2">
|
||||
<ButtonStyled size="small" type="standard">
|
||||
<button @click="copy(item.file.download_url)">
|
||||
<ClipboardCopyIcon class="size-4" />
|
||||
@@ -149,6 +151,13 @@ defineExpose({ show, hide })
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</td>
|
||||
<td class="py-1">
|
||||
<ButtonStyled circular size="small">
|
||||
<a :href="item.file.download_url" :download="item.file.file_name" target="_blank">
|
||||
<DownloadIcon />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
AsteriskIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -89,9 +90,7 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import type { Nag, NagContext, NagStatus } from '@modrinth/moderation'
|
||||
import { nags } from '@modrinth/moderation'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import type { Project, User, Version } from '@modrinth/utils'
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { ButtonStyled, defineMessages, type MessageDescriptor, useVIntl } from '@modrinth/ui'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -99,16 +98,10 @@ interface Tags {
|
||||
rejectedStatuses: string[]
|
||||
}
|
||||
|
||||
interface Member {
|
||||
accepted?: boolean
|
||||
project_role?: string
|
||||
user?: Partial<User>
|
||||
}
|
||||
|
||||
interface Props {
|
||||
project: Project
|
||||
versions?: Version[]
|
||||
currentMember?: Member | null
|
||||
project: Labrinth.Projects.v2.Project
|
||||
versions?: Labrinth.Versions.v2.Version[]
|
||||
currentMember?: Labrinth.Projects.v3.TeamMember | null
|
||||
collapsed?: boolean
|
||||
routeName?: string
|
||||
tags: Tags
|
||||
@@ -180,7 +173,7 @@ const emit = defineEmits<{
|
||||
const nagContext = computed<NagContext>(() => ({
|
||||
project: props.project,
|
||||
versions: props.versions,
|
||||
currentMember: props.currentMember as User,
|
||||
currentMember: props.currentMember?.user as Labrinth.Users.v2.User,
|
||||
currentRoute: props.routeName,
|
||||
tags: props.tags,
|
||||
submitProject: submitForReview,
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions">
|
||||
<OverflowMenu :options="quickActions" :dropdown-id="`${baseId}-quick-actions`">
|
||||
<template #default>
|
||||
<EllipsisVerticalIcon class="size-4" />
|
||||
</template>
|
||||
@@ -127,16 +127,20 @@ import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ModerationProject } from '~/helpers/moderation'
|
||||
import { useModerationStore } from '~/store/moderation.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const moderationStore = useModerationStore()
|
||||
|
||||
const baseId = useId()
|
||||
|
||||
const props = defineProps<{
|
||||
queueEntry: ModerationProject
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
startFromProject: [projectId: string]
|
||||
}>()
|
||||
|
||||
function getDaysQueued(date: Date): number {
|
||||
const now = new Date()
|
||||
const diff = now.getTime() - date.getTime()
|
||||
@@ -199,16 +203,6 @@ const quickActions: OverflowMenuOption[] = [
|
||||
]
|
||||
|
||||
function openProjectForReview() {
|
||||
moderationStore.setSingleProject(props.queueEntry.project.id)
|
||||
navigateTo({
|
||||
name: 'type-id',
|
||||
params: {
|
||||
type: 'project',
|
||||
id: props.queueEntry.project.id,
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
},
|
||||
})
|
||||
emit('startFromProject', props.queueEntry.project.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -31,9 +31,12 @@
|
||||
</span>
|
||||
|
||||
<div class="flex flex-row items-center gap-2 self-end sm:self-auto">
|
||||
<span class="whitespace-nowrap text-sm text-secondary">{{
|
||||
formatRelativeTime(report.created)
|
||||
}}</span>
|
||||
<span
|
||||
v-tooltip="formatExactDate(report.created)"
|
||||
class="cursor-help whitespace-nowrap text-sm text-secondary"
|
||||
>
|
||||
{{ formatRelativeTime(report.created) }}
|
||||
</span>
|
||||
<ButtonStyled circular>
|
||||
<OverflowMenu :options="quickActions">
|
||||
<template #default>
|
||||
@@ -142,9 +145,9 @@
|
||||
>
|
||||
<div class="bg-surface-2 p-4 pt-2">
|
||||
<ThreadView
|
||||
v-if="report.thread"
|
||||
v-if="threadWithReportBody"
|
||||
ref="reportThread"
|
||||
:thread="report.thread"
|
||||
:thread="threadWithReportBody"
|
||||
:quick-replies="reportQuickReplies"
|
||||
:quick-reply-context="report"
|
||||
:closed="reportClosed"
|
||||
@@ -223,9 +226,34 @@ const reportClosed = computed(() => {
|
||||
return didCloseReport.value || props.report.closed
|
||||
})
|
||||
|
||||
const threadWithReportBody = computed(() => {
|
||||
if (!props.report.thread) return null
|
||||
|
||||
const reportBodyMessage = {
|
||||
id: `report-body-${props.report.id}`,
|
||||
author_id: props.report.reporter_user.id,
|
||||
body: {
|
||||
type: 'text' as const,
|
||||
body: props.report.body || 'Report opened.',
|
||||
private: false,
|
||||
replying_to: null,
|
||||
associated_images: [],
|
||||
},
|
||||
created: props.report.created,
|
||||
hide_identity: false,
|
||||
}
|
||||
|
||||
return {
|
||||
...props.report.thread,
|
||||
messages: [reportBodyMessage, ...props.report.thread.messages],
|
||||
members: [props.report.reporter_user, ...props.report.thread.members],
|
||||
}
|
||||
})
|
||||
|
||||
const remainingMessageCount = computed(() => {
|
||||
if (!props.report.thread?.messages) return 0
|
||||
return Math.max(0, props.report.thread.messages.length - 1)
|
||||
// Thread messages count (report body is injected separately)
|
||||
return props.report.thread.messages.length
|
||||
})
|
||||
|
||||
const expandText = computed(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LinkIcon,
|
||||
LoaderCircleIcon,
|
||||
ShieldCheckIcon,
|
||||
TimerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { type TechReviewContext, techReviewQuickReplies } from '@modrinth/moderation'
|
||||
import {
|
||||
@@ -113,8 +114,8 @@ const quickActions = computed<OverflowMenuOption[]>(() => {
|
||||
navigator.clipboard.writeText(props.item.project.id).then(() => {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Technical Report ID copied',
|
||||
text: 'The ID of this report has been copied to your clipboard.',
|
||||
title: 'Project ID copied',
|
||||
text: 'The ID of this project has been copied to your clipboard.',
|
||||
})
|
||||
})
|
||||
},
|
||||
@@ -265,6 +266,13 @@ const severityColor = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const isProjectApproved = computed(() => {
|
||||
const status = props.item.project.status
|
||||
return (
|
||||
status === 'approved' || status === 'archived' || status === 'unlisted' || status === 'private'
|
||||
)
|
||||
})
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
const dates = props.item.reports.map((r) => new Date(r.created))
|
||||
const earliest = new Date(Math.min(...dates.map((d) => d.getTime())))
|
||||
@@ -369,12 +377,16 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
if (detailKey) break
|
||||
}
|
||||
|
||||
let otherMatchedCount = 0
|
||||
if (detailKey) {
|
||||
for (const report of props.item.reports) {
|
||||
for (const issue of report.issues) {
|
||||
for (const detail of issue.details) {
|
||||
if (detail.key === detailKey) {
|
||||
detailDecisions.value.set(detail.id, decision)
|
||||
if (detail.id !== detailId) {
|
||||
otherMatchedCount++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,17 +403,31 @@ async function updateDetailStatus(detailId: string, verdict: 'safe' | 'unsafe')
|
||||
}
|
||||
}
|
||||
|
||||
// Jump back to Files tab when all flags in the current file are marked
|
||||
if (selectedFile.value) {
|
||||
const markedCount = getFileMarkedCount(selectedFile.value)
|
||||
const totalCount = getFileDetailCount(selectedFile.value)
|
||||
if (markedCount === totalCount) {
|
||||
backToFileList()
|
||||
}
|
||||
}
|
||||
|
||||
const otherText =
|
||||
otherMatchedCount > 0
|
||||
? ` (${otherMatchedCount} other trace${otherMatchedCount === 1 ? '' : 's'} also marked)`
|
||||
: ''
|
||||
|
||||
if (verdict === 'safe') {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Issue marked as pass',
|
||||
text: 'This issue has been marked as a false positive.',
|
||||
text: `This issue has been marked as a false positive.${otherText}`,
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Issue marked as fail',
|
||||
text: 'This issue has been flagged as malicious.',
|
||||
text: `This issue has been flagged as malicious.${otherText}`,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -472,6 +498,17 @@ const groupedByClass = computed<ClassGroup[]>(() => {
|
||||
})
|
||||
})
|
||||
|
||||
// Auto-expand if there's only one class in the file
|
||||
watch(
|
||||
groupedByClass,
|
||||
(classes) => {
|
||||
if (classes.length === 1) {
|
||||
expandedClasses.value.add(classes[0].filePath)
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getHighestSeverityInClass(
|
||||
flags: ClassGroup['flags'],
|
||||
): Labrinth.TechReview.Internal.DelphiSeverity {
|
||||
@@ -623,7 +660,7 @@ const threadWithPreview = computed(() => {
|
||||
body: {
|
||||
type: 'text',
|
||||
body: reviewSummaryPreview.value,
|
||||
private: false,
|
||||
private: true,
|
||||
replying_to: null,
|
||||
associated_images: [],
|
||||
},
|
||||
@@ -747,9 +784,21 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="rounded-full border border-solid border-surface-5 bg-surface-4 px-2.5 py-1"
|
||||
class="flex items-center gap-1 rounded-full border border-solid px-2.5 py-1"
|
||||
:class="
|
||||
isProjectApproved
|
||||
? 'border-green bg-highlight-green'
|
||||
: 'border-orange bg-highlight-orange'
|
||||
"
|
||||
>
|
||||
<span class="text-sm font-medium text-secondary">Auto-Flagged</span>
|
||||
<CheckIcon v-if="isProjectApproved" aria-hidden="true" class="h-4 w-4 text-green" />
|
||||
<TimerIcon v-else aria-hidden="true" class="h-4 w-4 text-orange" />
|
||||
<span
|
||||
class="text-sm font-medium"
|
||||
:class="isProjectApproved ? 'text-green' : 'text-orange'"
|
||||
>
|
||||
{{ isProjectApproved ? 'Live' : 'In review' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="rounded-full px-2.5 py-1" :class="severityColor">
|
||||
@@ -929,8 +978,6 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
:href="file.download_url"
|
||||
:title="`Download ${file.file_name}`"
|
||||
:download="file.file_name"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="!border-px !border-surface-4"
|
||||
tabindex="0"
|
||||
>
|
||||
@@ -1008,15 +1055,21 @@ async function handleSubmitReview(verdict: 'safe' | 'unsafe') {
|
||||
v-for="flag in classItem.flags"
|
||||
:key="`${flag.issueId}-${flag.detail.id}`"
|
||||
class="grid grid-cols-[1fr_auto_auto] items-center rounded-lg border-[1px] border-b border-solid border-surface-5 bg-surface-3 py-2 pl-4 last:border-b-0"
|
||||
:class="{
|
||||
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
|
||||
}"
|
||||
>
|
||||
<span class="text-base font-semibold text-contrast">{{
|
||||
flag.issueType.replace(/_/g, ' ')
|
||||
}}</span>
|
||||
<span
|
||||
class="text-base font-semibold text-contrast"
|
||||
:class="{
|
||||
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
|
||||
}"
|
||||
>{{ flag.issueType.replace(/_/g, ' ') }}</span
|
||||
>
|
||||
|
||||
<div class="flex w-20 justify-center">
|
||||
<div
|
||||
class="flex w-20 justify-center"
|
||||
:class="{
|
||||
'opacity-50': isPreReviewed(flag.detail.id, flag.detail.status),
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="rounded-full border-solid px-2.5 py-1"
|
||||
:class="getSeverityBadgeColor(flag.detail.severity)"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,7 @@
|
||||
<script setup lang="ts">
|
||||
import { NewspaperIcon } from '@modrinth/assets'
|
||||
import { articles as rawArticles } from '@modrinth/blog'
|
||||
import { ButtonStyled, NewsArticleCard } from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { ButtonStyled, defineMessages, NewsArticleCard, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Editing auto backup settings">
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Auto backup</div>
|
||||
<p class="m-0">
|
||||
Automatically create a backup of your server
|
||||
<strong>{{ backupIntervalsLabel.toLowerCase() }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoadingSettings" class="py-2 text-sm text-secondary">Loading settings...</div>
|
||||
<template v-else>
|
||||
<input
|
||||
id="auto-backup-toggle"
|
||||
v-model="autoBackupEnabled"
|
||||
class="switch stylized-toggle"
|
||||
type="checkbox"
|
||||
:disabled="isSaving"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Interval</div>
|
||||
<p class="m-0">
|
||||
The amount of time between each backup. This will only backup your server if it has been
|
||||
modified since the last backup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Combobox
|
||||
:id="'interval-field'"
|
||||
v-model="backupIntervalsLabel"
|
||||
:disabled="!autoBackupEnabled || isSaving"
|
||||
name="interval"
|
||||
:options="Object.keys(backupIntervals).map((k) => ({ value: k, label: k }))"
|
||||
:display-value="backupIntervalsLabel"
|
||||
/>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!hasChanges || isSaving" @click="saveSettings">
|
||||
<SaveIcon class="h-5 w-5" />
|
||||
{{ isSaving ? 'Saving...' : 'Save changes' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isSaving" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import { computed, 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 initialSettings = ref<{ interval: number; enabled: boolean } | null>(null)
|
||||
const autoBackupEnabled = ref(false)
|
||||
const isLoadingSettings = ref(true)
|
||||
const isSaving = ref(false)
|
||||
|
||||
const backupIntervals = {
|
||||
'Every 3 hours': 3,
|
||||
'Every 6 hours': 6,
|
||||
'Every 12 hours': 12,
|
||||
Daily: 24,
|
||||
}
|
||||
|
||||
const backupIntervalsLabel = ref<keyof typeof backupIntervals>('Every 6 hours')
|
||||
|
||||
const autoBackupInterval = computed({
|
||||
get: () => backupIntervals[backupIntervalsLabel.value],
|
||||
set: (value) => {
|
||||
const [label] =
|
||||
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || []
|
||||
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals
|
||||
},
|
||||
})
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!initialSettings.value) return false
|
||||
|
||||
return (
|
||||
autoBackupEnabled.value !== initialSettings.value.enabled ||
|
||||
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
|
||||
)
|
||||
})
|
||||
|
||||
const fetchSettings = async () => {
|
||||
isLoadingSettings.value = true
|
||||
try {
|
||||
const settings = await props.server.backups?.getAutoBackup()
|
||||
initialSettings.value = settings as { interval: number; enabled: boolean }
|
||||
autoBackupEnabled.value = settings?.enabled ?? false
|
||||
autoBackupInterval.value = settings?.interval || 6
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error fetching backup settings:', error)
|
||||
addNotification({
|
||||
title: 'Error',
|
||||
text: 'Failed to load backup settings',
|
||||
type: 'error',
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
isLoadingSettings.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveSettings = async () => {
|
||||
isSaving.value = true
|
||||
try {
|
||||
await props.server.backups?.updateAutoBackup(
|
||||
autoBackupEnabled.value ? 'enable' : 'disable',
|
||||
autoBackupInterval.value,
|
||||
)
|
||||
|
||||
initialSettings.value = {
|
||||
enabled: autoBackupEnabled.value,
|
||||
interval: autoBackupInterval.value,
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: 'Success',
|
||||
text: 'Backup settings updated successfully',
|
||||
type: 'success',
|
||||
})
|
||||
|
||||
modal.value?.hide()
|
||||
} catch (error) {
|
||||
console.error('Error saving backup settings:', error)
|
||||
addNotification({
|
||||
title: 'Error',
|
||||
text: 'Failed to save backup settings',
|
||||
type: 'error',
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show: async () => {
|
||||
const success = await fetchSettings()
|
||||
if (success) {
|
||||
modal.value?.show()
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<li
|
||||
role="button"
|
||||
data-pyro-file
|
||||
:class="[
|
||||
containerClasses,
|
||||
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
|
||||
@@ -12,6 +11,7 @@
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@@ -19,35 +19,32 @@
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<div
|
||||
data-pyro-file-metadata
|
||||
class="pointer-events-none flex w-full items-center gap-4 truncate"
|
||||
>
|
||||
<div
|
||||
class="pointer-events-none flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
|
||||
:class="isEditableFile ? 'group-active:scale-[0.8]' : ''"
|
||||
>
|
||||
<component :is="iconComponent" class="size-6" />
|
||||
<div class="pointer-events-none flex flex-1 items-center gap-3 truncate">
|
||||
<Checkbox
|
||||
class="pointer-events-auto"
|
||||
:model-value="selected"
|
||||
@click.stop
|
||||
@update:model-value="emit('toggle-select')"
|
||||
/>
|
||||
<div class="pointer-events-none flex size-5 items-center justify-center">
|
||||
<component :is="iconComponent" class="size-5" />
|
||||
</div>
|
||||
<div class="pointer-events-none flex w-full flex-col truncate">
|
||||
<div class="pointer-events-none flex flex-col truncate">
|
||||
<span
|
||||
class="pointer-events-none w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
|
||||
class="pointer-events-none truncate group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
<span class="pointer-events-none text-xs text-secondary group-hover:text-primary">
|
||||
{{ subText }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-pyro-file-actions
|
||||
class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12"
|
||||
>
|
||||
<span class="hidden w-[160px] text-nowrap font-mono text-sm text-secondary md:flex">
|
||||
<div class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
||||
<span class="hidden w-[100px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedSize }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedCreationDate }}
|
||||
</span>
|
||||
<span class="w-[160px] text-nowrap font-mono text-sm text-secondary">
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedModifiedDate }}
|
||||
</span>
|
||||
<ButtonStyled circular type="transparent">
|
||||
@@ -71,22 +68,23 @@ import {
|
||||
FolderOpenIcon,
|
||||
MoreHorizontalIcon,
|
||||
PackageOpenIcon,
|
||||
PaletteIcon,
|
||||
RightArrowIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CODE_EXTENSIONS,
|
||||
Checkbox,
|
||||
getFileExtension,
|
||||
getFileExtensionIcon,
|
||||
IMAGE_EXTENSIONS,
|
||||
TEXT_EXTENSIONS,
|
||||
isEditableFile as isEditableFileExt,
|
||||
isImageFile,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, h, ref, shallowRef } from 'vue'
|
||||
import { renderToString } from 'vue/server-renderer'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
|
||||
import PaletteIcon from '~/assets/icons/palette.svg?component'
|
||||
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
@@ -98,17 +96,21 @@ interface FileItemProps {
|
||||
modified: number
|
||||
created: number
|
||||
path: string
|
||||
index: number
|
||||
isLast: boolean
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<FileItemProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract',
|
||||
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover',
|
||||
item: { name: string; type: string; path: string },
|
||||
): void
|
||||
(e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void
|
||||
(e: 'contextmenu', x: number, y: number): void
|
||||
(e: 'toggle-select'): void
|
||||
}>()
|
||||
|
||||
const isDragOver = ref(false)
|
||||
@@ -120,12 +122,15 @@ const route = shallowRef(useRoute())
|
||||
const router = useRouter()
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
'group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised',
|
||||
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-3 px-4 py-3 focus:!outline-none',
|
||||
props.index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-3',
|
||||
props.isLast ? 'rounded-b-[20px] border-b' : '',
|
||||
isEditableFile.value ? 'cursor-pointer' : props.type === 'directory' ? 'cursor-pointer' : '',
|
||||
isDragOver.value ? 'bg-brand-highlight' : '',
|
||||
isDragOver.value ? '!bg-brand-highlight' : '',
|
||||
'hover:brightness-110 focus:brightness-110',
|
||||
])
|
||||
|
||||
const fileExtension = computed(() => props.name.split('.').pop()?.toLowerCase() || '')
|
||||
const fileExtension = computed(() => getFileExtension(props.name))
|
||||
|
||||
const isZip = computed(() => fileExtension.value === 'zip')
|
||||
|
||||
@@ -170,13 +175,6 @@ const iconComponent = computed(() => {
|
||||
return getFileExtensionIcon(fileExtension.value)
|
||||
})
|
||||
|
||||
const subText = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
|
||||
}
|
||||
return formattedSize.value
|
||||
})
|
||||
|
||||
const formattedModifiedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000)
|
||||
return `${date.toLocaleDateString('en-US', {
|
||||
@@ -206,17 +204,16 @@ const formattedCreationDate = computed(() => {
|
||||
const isEditableFile = computed(() => {
|
||||
if (props.type === 'file') {
|
||||
const ext = fileExtension.value
|
||||
return (
|
||||
!props.name.includes('.') ||
|
||||
TEXT_EXTENSIONS.includes(ext) ||
|
||||
CODE_EXTENSIONS.includes(ext) ||
|
||||
IMAGE_EXTENSIONS.includes(ext)
|
||||
)
|
||||
return !props.name.includes('.') || isEditableFileExt(ext) || isImageFile(ext)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
|
||||
}
|
||||
|
||||
if (props.size === undefined) return ''
|
||||
const bytes = props.size
|
||||
if (bytes === 0) return '0 B'
|
||||
@@ -226,22 +223,26 @@ const formattedSize = computed(() => {
|
||||
return `${size} ${units[exponent]}`
|
||||
})
|
||||
|
||||
const openContextMenu = (event: MouseEvent) => {
|
||||
function openContextMenu(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
emit('contextmenu', event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
const navigateToFolder = () => {
|
||||
function handleMouseEnter() {
|
||||
emit('hover', { name: props.name, type: props.type, path: props.path })
|
||||
}
|
||||
|
||||
function navigateToFolder() {
|
||||
const currentPath = route.value.query.path?.toString() || ''
|
||||
const newPath = currentPath.endsWith('/')
|
||||
? `${currentPath}${props.name}`
|
||||
: `${currentPath}/${props.name}`
|
||||
router.push({ query: { path: newPath, page: 1 } })
|
||||
router.push({ query: { path: newPath } })
|
||||
}
|
||||
|
||||
const isNavigating = ref(false)
|
||||
|
||||
const selectItem = () => {
|
||||
function selectItem() {
|
||||
if (isNavigating.value) return
|
||||
isNavigating.value = true
|
||||
|
||||
@@ -256,11 +257,12 @@ const selectItem = () => {
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const getDragIcon = async () => {
|
||||
async function getDragIcon() {
|
||||
// Reuse iconComponent computed for consistency
|
||||
return await renderToString(h(iconComponent.value))
|
||||
}
|
||||
|
||||
const handleDragStart = async (event: DragEvent) => {
|
||||
async function handleDragStart(event: DragEvent) {
|
||||
if (!event.dataTransfer) return
|
||||
isDragging.value = true
|
||||
|
||||
@@ -291,7 +293,7 @@ const handleDragStart = async (event: DragEvent) => {
|
||||
})
|
||||
|
||||
event.dataTransfer.setData(
|
||||
'application/pyro-file-move',
|
||||
'application/modrinth-file-move',
|
||||
JSON.stringify({
|
||||
name: props.name,
|
||||
type: props.type,
|
||||
@@ -301,34 +303,34 @@ const handleDragStart = async (event: DragEvent) => {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
const isChildPath = (parentPath: string, childPath: string) => {
|
||||
function isChildPath(parentPath: string, childPath: string) {
|
||||
return childPath.startsWith(parentPath + '/')
|
||||
}
|
||||
|
||||
const handleDragEnd = () => {
|
||||
function handleDragEnd() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
const handleDragEnter = () => {
|
||||
function handleDragEnter() {
|
||||
if (props.type !== 'directory') return
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
function handleDragOver(event: DragEvent) {
|
||||
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
const handleDragLeave = () => {
|
||||
function handleDragLeave() {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragOver.value = false
|
||||
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||
|
||||
try {
|
||||
const dragData = JSON.parse(event.dataTransfer.getData('application/pyro-file-move'))
|
||||
const dragData = JSON.parse(event.dataTransfer.getData('application/modrinth-file-move'))
|
||||
|
||||
if (dragData.path === props.path) return
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
|
||||
<FileIcon class="size-28" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-2xl font-bold text-red-500">{{ title }}</h3>
|
||||
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ message }}
|
||||
</p>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div ref="listContainer" data-pyro-files-virtual-list-root class="relative w-full">
|
||||
<div ref="listContainer" class="relative w-full">
|
||||
<div
|
||||
:style="{
|
||||
position: 'relative',
|
||||
minHeight: `${totalHeight}px`,
|
||||
}"
|
||||
data-pyro-files-virtual-height-watcher
|
||||
>
|
||||
<ul
|
||||
class="list-none"
|
||||
@@ -16,10 +15,9 @@
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}"
|
||||
data-pyro-files-virtual-list
|
||||
>
|
||||
<FileItem
|
||||
v-for="item in visibleItems"
|
||||
v-for="(item, idx) in visibleItems"
|
||||
:key="item.path"
|
||||
:count="item.count"
|
||||
:created="item.created"
|
||||
@@ -28,6 +26,9 @@
|
||||
:path="item.path"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
:index="visibleRange.start + idx"
|
||||
:is-last="visibleRange.start + idx === props.items.length - 1"
|
||||
:selected="selectedItems.has(item.path)"
|
||||
@delete="$emit('delete', item)"
|
||||
@rename="$emit('rename', item)"
|
||||
@extract="$emit('extract', item)"
|
||||
@@ -35,7 +36,9 @@
|
||||
@move="$emit('move', item)"
|
||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||
@edit="$emit('edit', item)"
|
||||
@hover="$emit('hover', item)"
|
||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||
@toggle-select="$emit('toggle-select', item.path)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -49,15 +52,17 @@ import FileItem from './FileItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: any[]
|
||||
selectedItems: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract',
|
||||
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract' | 'hover',
|
||||
item: any,
|
||||
): void
|
||||
(e: 'contextmenu', item: any, x: number, y: number): void
|
||||
(e: 'loadMore'): void
|
||||
(e: 'toggle-select', path: string): void
|
||||
}>()
|
||||
|
||||
const ITEM_HEIGHT = 61
|
||||
@@ -92,7 +97,7 @@ const visibleItems = computed(() => {
|
||||
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
|
||||
})
|
||||
|
||||
const handleScroll = () => {
|
||||
function handleScroll() {
|
||||
windowScrollY.value = window.scrollY
|
||||
|
||||
if (!listContainer.value) return
|
||||
@@ -105,7 +110,7 @@ const handleScroll = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleResize = () => {
|
||||
function handleResize() {
|
||||
windowHeight.value = window.innerHeight
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
<template>
|
||||
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
|
||||
<header
|
||||
:class="[
|
||||
'duration-20 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
|
||||
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
|
||||
]"
|
||||
data-pyro-files-state="browsing"
|
||||
class="flex select-none flex-col justify-between gap-2 sm:flex-row sm:items-center"
|
||||
aria-label="File navigation"
|
||||
>
|
||||
<nav
|
||||
@@ -13,20 +8,17 @@
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="-ml-1 flex-shrink-0">
|
||||
<ButtonStyled type="transparent">
|
||||
<li class="mr-4 flex-shrink-0">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="$emit('navigate', -1)"
|
||||
@mouseenter="$emit('prefetch-home')"
|
||||
>
|
||||
<span
|
||||
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Home</span>
|
||||
</span>
|
||||
<HomeIcon />
|
||||
<span class="sr-only">Home</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
@@ -70,58 +62,28 @@
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-1">
|
||||
<div class="flex w-full flex-row-reverse sm:flex-row">
|
||||
<ButtonStyled type="transparent">
|
||||
<TeleportOverflowMenu
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Filter view"
|
||||
:options="[
|
||||
{ id: 'all', action: () => $emit('filter', 'all') },
|
||||
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
|
||||
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
|
||||
]"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<FilterIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<span class="hidden text-sm font-medium sm:block">
|
||||
{{ filterLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #all>Show all</template>
|
||||
<template #filesOnly>Files only</template>
|
||||
<template #foldersOnly>Folders only</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 w-full text-sm sm:w-48">
|
||||
<label for="search-folder" class="sr-only">Search folder</label>
|
||||
<div class="relative">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id="search-folder"
|
||||
:value="searchQuery"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-divider bg-transparent py-2 pl-9"
|
||||
placeholder="Search..."
|
||||
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<div class="iconified-input w-full sm:w-[280px]">
|
||||
<SearchIcon aria-hidden="true" class="!text-secondary" />
|
||||
<input
|
||||
id="search-folder"
|
||||
:value="searchQuery"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
class="h-10 w-full rounded-[14px] border-0 bg-surface-4 text-sm"
|
||||
placeholder="Search files"
|
||||
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<ButtonStyled type="outlined">
|
||||
<OverflowMenu
|
||||
:dropdown-id="`create-new-${baseId}`"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Create new..."
|
||||
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
|
||||
:options="[
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||
@@ -132,8 +94,8 @@
|
||||
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<PlusIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||
@@ -159,7 +121,6 @@ import {
|
||||
CurseForgeIcon,
|
||||
DropdownIcon,
|
||||
FileArchiveIcon,
|
||||
FilterIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
@@ -168,12 +129,8 @@ import {
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
||||
import { useIntersectionObserver } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
breadcrumbSegments: string[]
|
||||
searchQuery: string
|
||||
currentFilter: string
|
||||
@@ -183,44 +140,13 @@ const props = defineProps<{
|
||||
defineEmits<{
|
||||
(e: 'navigate', index: number): void
|
||||
(e: 'create', type: 'file' | 'directory'): void
|
||||
(e: 'upload' | 'upload-zip'): void
|
||||
(e: 'upload' | 'upload-zip' | 'prefetch-home'): void
|
||||
(e: 'unzip-from-url', cf: boolean): void
|
||||
(e: 'update:searchQuery' | 'filter', value: string): void
|
||||
}>()
|
||||
|
||||
const pyroFilesSentinel = ref<HTMLElement | null>(null)
|
||||
const isStuck = ref(false)
|
||||
|
||||
useIntersectionObserver(
|
||||
pyroFilesSentinel,
|
||||
([{ isIntersecting }]) => {
|
||||
isStuck.value = !isIntersecting
|
||||
},
|
||||
{ threshold: [0, 1] },
|
||||
)
|
||||
|
||||
const filterLabel = computed(() => {
|
||||
switch (props.currentFilter) {
|
||||
case 'filesOnly':
|
||||
return 'Files only'
|
||||
case 'foldersOnly':
|
||||
return 'Folders only'
|
||||
default:
|
||||
return 'Show all'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sentinel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.breadcrumb-move,
|
||||
.breadcrumb-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div
|
||||
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-[#cb224436] bg-[#f57b7b0e] p-6 shadow-md dark:border-0 dark:bg-[#0e0e0ea4]"
|
||||
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-brand-red bg-bg-red p-6 shadow-md"
|
||||
>
|
||||
<div
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-[#3f1818a4] p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-highlight-red p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
|
||||
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<header
|
||||
data-pyro-files-state="editing"
|
||||
class="flex h-12 select-none items-center justify-between rounded-t-2xl bg-table-alternateRow p-3"
|
||||
class="flex select-none items-center justify-between gap-2 sm:flex-row"
|
||||
aria-label="File editor navigation"
|
||||
>
|
||||
<nav
|
||||
@@ -9,20 +9,16 @@
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="-ml-1 flex-shrink-0">
|
||||
<ButtonStyled type="transparent">
|
||||
<li class="mr-4 flex-shrink-0">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="'Back to home'"
|
||||
type="button"
|
||||
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="goHome"
|
||||
>
|
||||
<span
|
||||
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
<span class="sr-only">Home</span>
|
||||
</span>
|
||||
<HomeIcon />
|
||||
<span class="sr-only">Home</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
|
||||
260
apps/frontend/src/components/ui/servers/FilesEditor.vue
Normal file
260
apps/frontend/src/components/ui/servers/FilesEditor.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col gap-4">
|
||||
<FilesRenameItemModal ref="renameModal" :item="file" @rename="handleRenameItem" />
|
||||
|
||||
<FilesEditingNavbar
|
||||
:file-name="file?.name"
|
||||
:is-image="isEditingImage"
|
||||
:file-path="file?.path"
|
||||
class="-mt-2"
|
||||
:breadcrumb-segments="breadcrumbSegments"
|
||||
@cancel="handleCancel"
|
||||
@save="() => saveFileContent(true)"
|
||||
@save-as="saveFileContentAs"
|
||||
@save-restart="saveFileContentRestart"
|
||||
@share="requestShareLink"
|
||||
@navigate="(index) => emit('navigate', index)"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col shadow-md">
|
||||
<div class="h-full w-full flex-grow">
|
||||
<component
|
||||
:is="props.editorComponent"
|
||||
v-if="!isEditingImage && props.editorComponent"
|
||||
v-model:value="fileContent"
|
||||
:lang="editorLanguage"
|
||||
theme="modrinth"
|
||||
:print-margin="false"
|
||||
style="height: 750px; font-size: 1rem"
|
||||
class="ace-modrinth rounded-[20px]"
|
||||
@init="onEditorInit"
|
||||
/>
|
||||
<FilesImageViewer v-else-if="isEditingImage && imagePreview" :image-blob="imagePreview" />
|
||||
<div
|
||||
v-else-if="isLoading || !props.editorComponent"
|
||||
class="flex h-[750px] items-center justify-center rounded-[20px] bg-bg-raised"
|
||||
>
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
getEditorLanguage,
|
||||
getFileExtension,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
isImageFile,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import FilesEditingNavbar from '~/components/ui/servers/FilesEditingNavbar.vue'
|
||||
import FilesImageViewer from '~/components/ui/servers/FilesImageViewer.vue'
|
||||
import FilesRenameItemModal from '~/components/ui/servers/FilesRenameItemModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
file: { name: string; type: string; path: string } | null
|
||||
breadcrumbSegments: string[]
|
||||
editorComponent: any
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
navigate: [index: number]
|
||||
}>()
|
||||
|
||||
const notifications = injectNotificationManager()
|
||||
const { addNotification } = notifications
|
||||
const client = injectModrinthClient()
|
||||
const serverContext = injectModrinthServerContext()
|
||||
const { serverId } = serverContext
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
||||
|
||||
// Internal state
|
||||
const fileContent = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const imagePreview = ref<Blob | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const renameModal = ref()
|
||||
const closeAfterRename = ref(false)
|
||||
const editorInstance = ref<any>(null)
|
||||
|
||||
const editorLanguage = computed(() => {
|
||||
const ext = getFileExtension(props.file?.name ?? '')
|
||||
return getEditorLanguage(ext)
|
||||
})
|
||||
|
||||
// Load file content when file prop changes
|
||||
watch(
|
||||
() => props.file,
|
||||
async (newFile) => {
|
||||
if (newFile) {
|
||||
await loadFileContent(newFile)
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadFileContent(file: { name: string; type: string; path: string }) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
window.scrollTo(0, 0)
|
||||
const extension = getFileExtension(file.name)
|
||||
|
||||
if (file.type === 'file' && isImageFile(extension)) {
|
||||
// Images are not prefetched, fetch directly
|
||||
const content = await client.kyros.files_v0.downloadFile(file.path)
|
||||
isEditingImage.value = true
|
||||
imagePreview.value = content
|
||||
} else {
|
||||
isEditingImage.value = false
|
||||
// Check cache first for text files (may have been prefetched on hover)
|
||||
const cachedContent = queryClient.getQueryData<string>(['file-content', serverId, file.path])
|
||||
if (cachedContent) {
|
||||
fileContent.value = cachedContent
|
||||
} else {
|
||||
const content = await client.kyros.files_v0.downloadFile(file.path)
|
||||
fileContent.value = await content.text()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file content:', error)
|
||||
addNotification({
|
||||
title: 'Failed to open file',
|
||||
text: 'Could not load file contents.',
|
||||
type: 'error',
|
||||
})
|
||||
emit('close')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
fileContent.value = ''
|
||||
isEditingImage.value = false
|
||||
imagePreview.value = null
|
||||
closeAfterRename.value = false
|
||||
}
|
||||
|
||||
function onEditorInit(editor: any) {
|
||||
editorInstance.value = editor
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'save',
|
||||
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
|
||||
exec: () => saveFileContent(false),
|
||||
})
|
||||
}
|
||||
|
||||
async function saveFileContent(exit: boolean = true) {
|
||||
if (!props.file) return
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.updateFile(props.file.path, fileContent.value)
|
||||
|
||||
if (exit) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: 'File saved',
|
||||
text: 'Your file has been saved.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving file content:', error)
|
||||
addNotification({ title: 'Save failed', text: 'Could not save the file.', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveFileContentRestart() {
|
||||
await saveFileContent(false)
|
||||
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||
|
||||
addNotification({
|
||||
title: 'Server restarted',
|
||||
text: 'Your server has been restarted.',
|
||||
type: 'success',
|
||||
})
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function saveFileContentAs() {
|
||||
await saveFileContent(false)
|
||||
closeAfterRename.value = true
|
||||
renameModal.value?.show(props.file)
|
||||
}
|
||||
|
||||
async function handleRenameItem(newName: string) {
|
||||
if (!props.file) return
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.renameFileOrFolder(props.file.path, newName)
|
||||
|
||||
addNotification({ title: 'Renamed', text: `Renamed to ${newName}`, type: 'success' })
|
||||
|
||||
if (closeAfterRename.value) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
closeAfterRename.value = false
|
||||
emit('close')
|
||||
}
|
||||
} catch (err: any) {
|
||||
addNotification({ title: 'Rename failed', text: err.message, type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function requestShareLink() {
|
||||
try {
|
||||
const response = (await $fetch('https://api.mclo.gs/1/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ content: fileContent.value }),
|
||||
})) as any
|
||||
|
||||
if (response.success) {
|
||||
await navigator.clipboard.writeText(response.url)
|
||||
addNotification({
|
||||
title: 'Log URL copied',
|
||||
text: 'Your log file URL has been copied to your clipboard.',
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
throw new Error(response.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing file:', error)
|
||||
addNotification({
|
||||
title: 'Failed to share file',
|
||||
text: 'Could not upload to mclo.gs.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await modulesLoaded
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
editorInstance.value = null
|
||||
resetState()
|
||||
})
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||
<div
|
||||
ref="container"
|
||||
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-b-2xl bg-black active:cursor-grabbing"
|
||||
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-[20px] bg-black active:cursor-grabbing"
|
||||
@mousedown="startPan"
|
||||
@mousemove="handlePan"
|
||||
@mouseup="stopPan"
|
||||
|
||||
@@ -1,65 +1,102 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="sticky top-12 z-20 flex h-8 w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised bg-bg px-3 text-xs font-bold uppercase"
|
||||
class="sticky top-0 z-20 flex w-full select-none flex-row items-center justify-between border border-b-0 border-solid border-surface-3 bg-surface-3 p-4 text-sm font-medium transition-[border-radius] duration-100 before:pointer-events-none before:absolute before:inset-x-0 before:-top-5 before:h-5 before:bg-surface-3"
|
||||
:class="isStuck ? 'rounded-none' : 'rounded-t-[20px]'"
|
||||
>
|
||||
<div class="min-w-[48px]"></div>
|
||||
<button
|
||||
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
|
||||
@click="$emit('sort', 'name')"
|
||||
>
|
||||
<span>Name</span>
|
||||
<ChevronUpIcon v-if="sortField === 'name' && !sortDesc" class="h-3 w-3" aria-hidden="true" />
|
||||
<ChevronDownIcon v-if="sortField === 'name' && sortDesc" class="h-3 w-3" aria-hidden="true" />
|
||||
</button>
|
||||
<div class="flex shrink-0 gap-4 text-right md:gap-12">
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
@update:model-value="$emit('toggle-all')"
|
||||
/>
|
||||
<button
|
||||
class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
|
||||
class="flex appearance-none items-center gap-1.5 bg-transparent text-contrast hover:text-brand"
|
||||
@click="$emit('sort', 'name')"
|
||||
>
|
||||
<span>Name</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'name' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'name' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-4 md:gap-12">
|
||||
<button
|
||||
class="hidden w-[100px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'size')"
|
||||
>
|
||||
<span>Size</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'size' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'size' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'created')"
|
||||
>
|
||||
<span>Created</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'created' && !sortDesc"
|
||||
class="h-3 w-3"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'created' && sortDesc"
|
||||
class="h-3 w-3"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="mr-4 hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
|
||||
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'modified')"
|
||||
>
|
||||
<span>Modified</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'modified' && !sortDesc"
|
||||
class="h-3 w-3"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'modified' && sortDesc"
|
||||
class="h-3 w-3"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<div class="min-w-[24px]"></div>
|
||||
<span class="w-[51px] text-right text-primary">Actions</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Checkbox } from '@modrinth/ui'
|
||||
|
||||
import ChevronDownIcon from './icons/ChevronDownIcon.vue'
|
||||
import ChevronUpIcon from './icons/ChevronUpIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
sortField: string
|
||||
sortDesc: boolean
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
isStuck: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
(e: 'sort', field: string): void
|
||||
(e: 'toggle-all'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
<div
|
||||
v-if="isDragging"
|
||||
:class="[
|
||||
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white',
|
||||
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black/60 text-contrast shadow',
|
||||
overlayClass,
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UploadIcon class="mx-auto h-16 w-16" />
|
||||
<UploadIcon class="mx-auto h-16 w-16 shadow-2xl" />
|
||||
<p class="mt-2 text-xl">
|
||||
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
|
||||
</p>
|
||||
@@ -41,7 +41,7 @@ const dragCounter = ref(0)
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
if (!event.dataTransfer?.types.includes('application/pyro-file-move')) {
|
||||
if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
|
||||
dragCounter.value++
|
||||
isDragging.value = true
|
||||
}
|
||||
@@ -64,7 +64,7 @@ const handleDrop = (event: DragEvent) => {
|
||||
isDragging.value = false
|
||||
dragCounter.value = 0
|
||||
|
||||
const isInternalMove = event.dataTransfer?.types.includes('application/pyro-file-move')
|
||||
const isInternalMove = event.dataTransfer?.types.includes('application/modrinth-file-move')
|
||||
if (isInternalMove) return
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
|
||||
@@ -102,14 +102,13 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import type { FSModule } from '~/composables/servers/modules/fs.ts'
|
||||
|
||||
import PanelSpinner from './PanelSpinner.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
interface UploadItem {
|
||||
file: File
|
||||
@@ -123,7 +122,7 @@ interface UploadItem {
|
||||
| 'cancelled'
|
||||
| 'incorrect-type'
|
||||
size: string
|
||||
uploader?: any
|
||||
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
|
||||
error?: Error
|
||||
}
|
||||
|
||||
@@ -132,7 +131,6 @@ interface Props {
|
||||
fileType?: string
|
||||
marginBottom?: number
|
||||
acceptedTypes?: Array<string>
|
||||
fs: FSModule
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
@@ -208,6 +206,7 @@ const cancelUpload = (item: UploadItem) => {
|
||||
}
|
||||
|
||||
const badFileTypeMsg = 'Upload had incorrect file type'
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const uploadItem: UploadItem = {
|
||||
file,
|
||||
@@ -229,19 +228,18 @@ const uploadFile = async (file: File) => {
|
||||
|
||||
uploadItem.status = 'uploading'
|
||||
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
|
||||
const uploader = await props.fs.uploadFile(filePath, file)
|
||||
uploadItem.uploader = uploader
|
||||
|
||||
if (uploader?.onProgress) {
|
||||
uploader.onProgress(({ progress }: { progress: number }) => {
|
||||
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
|
||||
onProgress: ({ progress }) => {
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (index !== -1) {
|
||||
uploadQueue.value[index].progress = Math.round(progress)
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
uploadItem.uploader = uploader
|
||||
|
||||
await uploader?.promise
|
||||
await uploader.promise
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
||||
uploadQueue.value[index].status = 'completed'
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<BackupWarning :backup-link="`/hosting/manage/${props.server.serverId}/backups`" />
|
||||
<BackupWarning :backup-link="`/hosting/manage/${serverId}/backups`" />
|
||||
<div class="flex justify-start gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
|
||||
@@ -74,21 +74,25 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { BackupWarning, ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
|
||||
import {
|
||||
BackupWarning,
|
||||
ButtonStyled,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
} from '@modrinth/ui'
|
||||
import { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { handleServersError } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const notifications = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
|
||||
const cf = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
server: ModrinthServer
|
||||
}>()
|
||||
|
||||
const modal = ref<typeof NewModal>()
|
||||
const urlInput = ref<HTMLInputElement | null>(null)
|
||||
const url = ref('')
|
||||
@@ -115,10 +119,10 @@ const handleSubmit = async () => {
|
||||
if (!error.value) {
|
||||
// hide();
|
||||
try {
|
||||
const dry = await props.server.fs.extractFile(trimmedUrl.value, true, true)
|
||||
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
|
||||
|
||||
if (!cf.value || dry.modpack_name) {
|
||||
await props.server.fs.extractFile(trimmedUrl.value, true, false, true)
|
||||
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
|
||||
hide()
|
||||
} else {
|
||||
submitted.value = false
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
>
|
||||
<LoaderIcon
|
||||
:loader="loader.name"
|
||||
class="[&&]:size-6"
|
||||
class="size-6"
|
||||
:class="isCurrentLoader ? 'text-brand' : ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
||||
@@ -20,20 +21,16 @@
|
||||
v-if="isCurrentLoader"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
Current
|
||||
<CheckIcon class="h-4 w-4" /> Current
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">
|
||||
{{ loaderVersion }}
|
||||
</p>
|
||||
|
||||
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">{{ loaderVersion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ButtonStyled>
|
||||
<button :disabled="isInstalling" @click="onSelect">
|
||||
<DownloadIcon class="h-5 w-5" />
|
||||
{{ isCurrentLoader ? 'Reinstall' : 'Install' }}
|
||||
<DownloadIcon class="h-5 w-5" /> {{ isCurrentLoader ? 'Reinstall' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -41,9 +38,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
import LoaderIcon from './icons/LoaderIcon.vue'
|
||||
import { ButtonStyled, LoaderIcon } from '@modrinth/ui'
|
||||
|
||||
interface LoaderInfo {
|
||||
name: 'Vanilla' | 'Fabric' | 'Forge' | 'Quilt' | 'Paper' | 'NeoForge' | 'Purpur'
|
||||
|
||||
@@ -120,7 +120,7 @@ import {
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels } from '@modrinth/ui'
|
||||
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels, useVIntl } from '@modrinth/ui'
|
||||
import type { PowerAction as ServerPowerAction, ServerState } from '@modrinth/utils'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
@@ -213,6 +213,7 @@ import {
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { $fetch } from 'ofetch'
|
||||
@@ -314,7 +315,7 @@ const fetchLoaderVersions = async () => {
|
||||
|
||||
const fetchPaperVersions = async (mcVersion: string) => {
|
||||
try {
|
||||
const res = await $fetch(`https://api.papermc.io/v2/projects/paper/versions/${mcVersion}`)
|
||||
const res = await $fetch(`https://fill.papermc.io/v3/projects/paper/versions/${mcVersion}`)
|
||||
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a)
|
||||
return res
|
||||
} catch (e) {
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CompassIcon, InfoIcon, SettingsIcon, TransferIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewProjectCard } from '@modrinth/ui'
|
||||
import { ButtonStyled, NewProjectCard, useVIntl } from '@modrinth/ui'
|
||||
import type { Loaders } from '@modrinth/utils'
|
||||
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
@@ -10,12 +10,14 @@
|
||||
<MedalIcon class="h-8 w-auto text-contrast md:h-10" />
|
||||
<div class="flex flex-col items-start gap-1">
|
||||
<span>
|
||||
Try a free
|
||||
<span class="text-medal-orange">3GB server</span> for 5 days powered by
|
||||
<span class="text-medal-orange">Medal</span>
|
||||
<IntlFormatted :message-id="messages.info">
|
||||
<template #orange="{ children }">
|
||||
<span class="text-medal-orange"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
<span class="text-xs font-medium text-secondary md:text-sm">
|
||||
Limited-time offer. No credit card required. Available for US servers.
|
||||
{{ formatMessage(messages.textSecondary) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,7 +25,7 @@
|
||||
<nuxt-link
|
||||
to="https://medal.tv/modrinth"
|
||||
class="z-10 flex w-full items-center justify-center gap-1 md:mt-0 md:w-auto"
|
||||
>Learn more <ExternalIcon
|
||||
>{{ formatMessage(messages.learnMoreButton) }} <ExternalIcon
|
||||
/></nuxt-link>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -31,9 +33,33 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, MedalBackgroundImage } from '@modrinth/ui'
|
||||
import {
|
||||
ButtonStyled,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
MedalBackgroundImage,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
import MedalIcon from '~/assets/images/illustrations/medal_icon.svg?component'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
info: {
|
||||
id: 'hosting-marketing.medal.info',
|
||||
defaultMessage:
|
||||
'Try a free <orange>3GB server</orange> for 5 days powered by <orange>Medal</orange>',
|
||||
},
|
||||
textSecondary: {
|
||||
id: 'hosting-marketing.medal.text-secondary',
|
||||
defaultMessage: 'Limited-time offer. No credit card required. Available for US servers.',
|
||||
},
|
||||
learnMoreButton: {
|
||||
id: 'hosting-marketing.medal.learn-more',
|
||||
defaultMessage: 'Learn more',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, ServersSpecs } from '@modrinth/ui'
|
||||
import type { MessageDescriptor } from '@modrinth/ui'
|
||||
import { ButtonStyled, defineMessage, defineMessages, ServersSpecs, useVIntl } from '@modrinth/ui'
|
||||
import { formatPrice } from '@modrinth/utils'
|
||||
import type { MessageDescriptor } from '@vintl/vintl'
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
@@ -11,6 +11,17 @@ const emit = defineEmits<{
|
||||
|
||||
type Plan = 'small' | 'medium' | 'large'
|
||||
|
||||
const messages = defineMessages({
|
||||
outOfStock: {
|
||||
id: 'hosting.plan.out-of-stock',
|
||||
defaultMessage: 'Out of stock',
|
||||
},
|
||||
selectPlanButton: {
|
||||
id: 'hosting.plan.select-plan',
|
||||
defaultMessage: 'Select plan',
|
||||
},
|
||||
})
|
||||
|
||||
const plans: Record<
|
||||
Plan,
|
||||
{
|
||||
@@ -134,8 +145,12 @@ const billingMonths = computed(() => {
|
||||
:type="plans[plan].mostPopular ? 'standard' : 'highlight-colored-text'"
|
||||
size="large"
|
||||
>
|
||||
<span v-if="outOfStock" class="button-like disabled"> Out of Stock </span>
|
||||
<button v-else @click="() => emit('select')">Select plan</button>
|
||||
<span v-if="outOfStock" class="button-like disabled">{{
|
||||
formatMessage(messages.outOfStock)
|
||||
}}</span>
|
||||
<button v-else @click="() => emit('select')">
|
||||
{{ formatMessage(messages.selectPlanButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ServersSpecs
|
||||
:ram="ram"
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
ServerNotice,
|
||||
TagItem,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
export const scopeMessages = defineMessages({
|
||||
userReadEmailLabel: {
|
||||
id: 'scopes.userReadEmail.label',
|
||||
|
||||
@@ -41,22 +41,8 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
newProjectEnvironmentSettings: true,
|
||||
hideRussiaCensorshipBanner: false,
|
||||
serverDiscovery: false,
|
||||
// advancedRendering: true,
|
||||
// externalLinksNewTab: true,
|
||||
// notUsingBlockers: false,
|
||||
// hideModrinthAppPromos: false,
|
||||
// preferredDarkTheme: 'dark',
|
||||
// hideStagingBanner: false,
|
||||
|
||||
// Project display modes
|
||||
// modSearchDisplayMode: 'list',
|
||||
// pluginSearchDisplayMode: 'list',
|
||||
// resourcePackSearchDisplayMode: 'gallery',
|
||||
// modpackSearchDisplayMode: 'list',
|
||||
// shaderSearchDisplayMode: 'gallery',
|
||||
// dataPackSearchDisplayMode: 'list',
|
||||
// userProjectDisplayMode: 'list',
|
||||
// collectionProjectDisplayMode: 'list',
|
||||
disablePrettyProjectUrlRedirects: false,
|
||||
hidePreviewBanner: false,
|
||||
} as const)
|
||||
|
||||
export type FeatureFlag = keyof typeof DEFAULT_FEATURE_FLAGS
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
let cachedRateLimitKey = undefined
|
||||
let rateLimitKeyPromise = undefined
|
||||
|
||||
async function getRateLimitKey(config) {
|
||||
if (config.rateLimitKey) return config.rateLimitKey
|
||||
if (cachedRateLimitKey !== undefined) return cachedRateLimitKey
|
||||
|
||||
if (!rateLimitKeyPromise) {
|
||||
rateLimitKeyPromise = (async () => {
|
||||
try {
|
||||
const mod = 'cloudflare:workers'
|
||||
const { env } = await import(/* @vite-ignore */ mod)
|
||||
return await env.RATE_LIMIT_IGNORE_KEY?.get()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
cachedRateLimitKey = await rateLimitKeyPromise
|
||||
return cachedRateLimitKey
|
||||
}
|
||||
|
||||
export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
|
||||
const config = useRuntimeConfig()
|
||||
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
@@ -7,7 +30,7 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
|
||||
}
|
||||
|
||||
if (import.meta.server) {
|
||||
options.headers['x-ratelimit-key'] = config.rateLimitKey
|
||||
options.headers['x-ratelimit-key'] = await getRateLimitKey(config)
|
||||
}
|
||||
|
||||
if (!skipAuth) {
|
||||
@@ -32,5 +55,8 @@ export const useBaseFetch = async (url, options = {}, skipAuth = false) => {
|
||||
delete options.apiVersion
|
||||
}
|
||||
|
||||
return await $fetch(`${base}${url}`, options)
|
||||
return await $fetch(`${base}${url}`, {
|
||||
timeout: import.meta.server ? 10000 : undefined,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface GeneratedState extends Labrinth.State.GeneratedState {
|
||||
// Metadata
|
||||
lastGenerated?: string
|
||||
apiUrl?: string
|
||||
buildYear: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,6 +54,9 @@ export const useGeneratedState = () =>
|
||||
muralBankDetails: generatedState.muralBankDetails as
|
||||
| Record<string, { bankNames: string[] }>
|
||||
| undefined,
|
||||
tremendousIdMap: generatedState.tremendousIdMap as
|
||||
| Record<string, { name: string; image_url: string | null }>
|
||||
| undefined,
|
||||
countries: (generatedState.countries ?? []) as ISO3166.Country[],
|
||||
subdivisions: (generatedState.subdivisions ?? {}) as Record<string, ISO3166.Subdivision[]>,
|
||||
|
||||
@@ -121,4 +125,6 @@ export const useGeneratedState = () =>
|
||||
lastGenerated: generatedState.lastGenerated,
|
||||
apiUrl: generatedState.apiUrl,
|
||||
errors: generatedState.errors,
|
||||
|
||||
buildYear: new Date().getFullYear(),
|
||||
}))
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Cosmetics } from '~/plugins/cosmetics.ts'
|
||||
|
||||
export function useTheme() {
|
||||
return useNuxtApp().$theme
|
||||
}
|
||||
|
||||
export function useCosmetics() {
|
||||
return useNuxtApp().$cosmetics
|
||||
return useNuxtApp().$cosmetics as Ref<Cosmetics>
|
||||
}
|
||||
|
||||
52
apps/frontend/src/composables/queries/project.ts
Normal file
52
apps/frontend/src/composables/queries/project.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { AbstractModrinthClient } from '@modrinth/api-client'
|
||||
|
||||
const STALE_TIME = 1000 * 60 * 5 // 5 minutes
|
||||
|
||||
export const projectQueryOptions = {
|
||||
v2: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', 'v2', projectId] as const,
|
||||
queryFn: () => client.labrinth.projects_v2.get(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
v3: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', 'v3', projectId] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.get(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
members: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'members'] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.getMembers(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
dependencies: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'dependencies'] as const,
|
||||
queryFn: () => client.labrinth.projects_v2.getDependencies(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
versionsV2: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'versions', 'v2'] as const,
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(projectId, { include_changelog: false }),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
versionsV3: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'versions', 'v3'] as const,
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v3.getProjectVersions(projectId, {
|
||||
include_changelog: false,
|
||||
apiVersion: 3,
|
||||
}),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
|
||||
organization: (projectId: string, client: AbstractModrinthClient) => ({
|
||||
queryKey: ['project', projectId, 'organization'] as const,
|
||||
queryFn: () => client.labrinth.projects_v3.getOrganization(projectId),
|
||||
staleTime: STALE_TIME,
|
||||
}),
|
||||
}
|
||||
14
apps/frontend/src/composables/query-client.ts
Normal file
14
apps/frontend/src/composables/query-client.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { QueryClient } from '@tanstack/vue-query'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
|
||||
export function useAppQueryClient(): QueryClient {
|
||||
// In components, use the standard composable
|
||||
if (getCurrentInstance()) {
|
||||
return useQueryClient()
|
||||
}
|
||||
|
||||
// In middleware/server context, use the provided instance
|
||||
const nuxtApp = useNuxtApp()
|
||||
return nuxtApp.$queryClient as QueryClient
|
||||
}
|
||||
@@ -2,15 +2,7 @@ import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
||||
import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
|
||||
import { ModrinthServerError } from '@modrinth/utils'
|
||||
|
||||
import {
|
||||
BackupsModule,
|
||||
ContentModule,
|
||||
FSModule,
|
||||
GeneralModule,
|
||||
NetworkModule,
|
||||
StartupModule,
|
||||
WSModule,
|
||||
} from './modules/index.ts'
|
||||
import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
|
||||
import { useServersFetch } from './servers-fetch.ts'
|
||||
|
||||
export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
|
||||
@@ -36,39 +28,16 @@ export class ModrinthServer {
|
||||
|
||||
readonly general: GeneralModule
|
||||
readonly content: ContentModule
|
||||
readonly backups: BackupsModule
|
||||
readonly network: NetworkModule
|
||||
readonly startup: StartupModule
|
||||
readonly ws: WSModule
|
||||
readonly fs: FSModule
|
||||
|
||||
constructor(serverId: string) {
|
||||
this.serverId = serverId
|
||||
|
||||
this.general = new GeneralModule(this)
|
||||
this.content = new ContentModule(this)
|
||||
this.backups = new BackupsModule(this)
|
||||
this.network = new NetworkModule(this)
|
||||
this.startup = new StartupModule(this)
|
||||
this.ws = new WSModule(this)
|
||||
this.fs = new FSModule(this)
|
||||
}
|
||||
|
||||
async createMissingFolders(path: string): Promise<void> {
|
||||
if (path.startsWith('/')) {
|
||||
path = path.substring(1)
|
||||
}
|
||||
const folders = path.split('/')
|
||||
let currentPath = ''
|
||||
|
||||
for (const folder of folders) {
|
||||
currentPath += '/' + folder
|
||||
try {
|
||||
await this.fs.createFileOrFolder(currentPath, 'directory')
|
||||
} catch {
|
||||
// Folder might already exist, ignore error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fetchConfigFile(fileName: string): Promise<any> {
|
||||
@@ -240,9 +209,7 @@ export class ModrinthServer {
|
||||
},
|
||||
): Promise<void> {
|
||||
const modulesToRefresh =
|
||||
modules.length > 0
|
||||
? modules
|
||||
: (['general', 'content', 'backups', 'network', 'startup', 'ws', 'fs'] as ModuleName[])
|
||||
modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
|
||||
|
||||
for (const module of modulesToRefresh) {
|
||||
this.errors[module] = undefined
|
||||
@@ -274,25 +241,16 @@ export class ModrinthServer {
|
||||
case 'content':
|
||||
await this.content.fetch()
|
||||
break
|
||||
case 'backups':
|
||||
await this.backups.fetch()
|
||||
break
|
||||
case 'network':
|
||||
await this.network.fetch()
|
||||
break
|
||||
case 'startup':
|
||||
await this.startup.fetch()
|
||||
break
|
||||
case 'ws':
|
||||
await this.ws.fetch()
|
||||
break
|
||||
case 'fs':
|
||||
await this.fs.fetch()
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError) {
|
||||
if (error.statusCode === 404 && ['fs', 'content'].includes(module)) {
|
||||
if (error.statusCode === 404 && module === 'content') {
|
||||
console.debug(`Optional ${module} resource not found:`, error.message)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import type {
|
||||
DirectoryResponse,
|
||||
FilesystemOp,
|
||||
FileUploadQuery,
|
||||
FSQueuedOp,
|
||||
JWTAuth,
|
||||
} from '@modrinth/utils'
|
||||
import { ModrinthServerError } from '@modrinth/utils'
|
||||
|
||||
import { useServersFetch } from '../servers-fetch.ts'
|
||||
import { ServerModule } from './base.ts'
|
||||
|
||||
export class FSModule extends ServerModule {
|
||||
auth!: JWTAuth
|
||||
ops: FilesystemOp[] = []
|
||||
queuedOps: FSQueuedOp[] = []
|
||||
opsQueuedForModification: string[] = []
|
||||
|
||||
async fetch(): Promise<void> {
|
||||
this.auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`, {}, 'fs')
|
||||
this.ops = []
|
||||
this.queuedOps = []
|
||||
this.opsQueuedForModification = []
|
||||
}
|
||||
|
||||
private async retryWithAuth<T>(
|
||||
requestFn: () => Promise<T>,
|
||||
ignoreFailure: boolean = false,
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await requestFn()
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServerError && error.statusCode === 401) {
|
||||
console.debug('Auth failed, refreshing JWT and retrying')
|
||||
await this.fetch() // Refresh auth
|
||||
return await requestFn()
|
||||
}
|
||||
|
||||
const available = await this.server.testNodeReachability()
|
||||
if (!available && !ignoreFailure) {
|
||||
this.server.moduleErrors.general = {
|
||||
error: new ModrinthServerError(
|
||||
'Unable to reach node. FS operation failed and subsequent ping test failed.',
|
||||
500,
|
||||
error as Error,
|
||||
'fs',
|
||||
),
|
||||
timestamp: Date.now(),
|
||||
}
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
listDirContents(
|
||||
path: string,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
ignoreFailure: boolean = false,
|
||||
): Promise<DirectoryResponse> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
|
||||
override: this.auth,
|
||||
retry: false,
|
||||
})
|
||||
}, ignoreFailure)
|
||||
}
|
||||
|
||||
createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
await useServersFetch(`/create?path=${encodedPath}&type=${type}`, {
|
||||
method: 'POST',
|
||||
contentType: 'application/octet-stream',
|
||||
override: this.auth,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
uploadFile(path: string, file: File): FileUploadQuery {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
const progressSubject = new EventTarget()
|
||||
const abortController = new AbortController()
|
||||
|
||||
const uploadPromise = new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
const progress = (e.loaded / e.total) * 100
|
||||
progressSubject.dispatchEvent(
|
||||
new CustomEvent('progress', {
|
||||
detail: { loaded: e.loaded, total: e.total, progress },
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.response)
|
||||
} else {
|
||||
reject(new Error(`Upload failed with status ${xhr.status}`))
|
||||
}
|
||||
}
|
||||
|
||||
xhr.onerror = () => reject(new Error('Upload failed'))
|
||||
xhr.onabort = () => reject(new Error('Upload cancelled'))
|
||||
|
||||
xhr.open('POST', `https://${this.auth.url}/create?path=${encodedPath}&type=file`)
|
||||
xhr.setRequestHeader('Authorization', `Bearer ${this.auth.token}`)
|
||||
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
|
||||
xhr.send(file)
|
||||
|
||||
abortController.signal.addEventListener('abort', () => xhr.abort())
|
||||
})
|
||||
|
||||
return {
|
||||
promise: uploadPromise,
|
||||
onProgress: (
|
||||
callback: (progress: { loaded: number; total: number; progress: number }) => void,
|
||||
) => {
|
||||
progressSubject.addEventListener('progress', ((e: CustomEvent) => {
|
||||
callback(e.detail)
|
||||
}) as EventListener)
|
||||
},
|
||||
cancel: () => abortController.abort(),
|
||||
} as FileUploadQuery
|
||||
}
|
||||
|
||||
renameFileOrFolder(path: string, name: string): Promise<void> {
|
||||
const pathName = path.split('/').slice(0, -1).join('/') + '/' + name
|
||||
return this.retryWithAuth(async () => {
|
||||
await useServersFetch(`/move`, {
|
||||
method: 'POST',
|
||||
override: this.auth,
|
||||
body: { source: path, destination: pathName },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
updateFile(path: string, content: string): Promise<void> {
|
||||
const octetStream = new Blob([content], { type: 'application/octet-stream' })
|
||||
return this.retryWithAuth(async () => {
|
||||
await useServersFetch(`/update?path=${path}`, {
|
||||
method: 'PUT',
|
||||
contentType: 'application/octet-stream',
|
||||
body: octetStream,
|
||||
override: this.auth,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
moveFileOrFolder(path: string, newPath: string): Promise<void> {
|
||||
return this.retryWithAuth(async () => {
|
||||
await this.server.createMissingFolders(newPath.substring(0, newPath.lastIndexOf('/')))
|
||||
await useServersFetch(`/move`, {
|
||||
method: 'POST',
|
||||
override: this.auth,
|
||||
body: { source: path, destination: newPath },
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
return this.retryWithAuth(async () => {
|
||||
await useServersFetch(`/delete?path=${encodedPath}&recursive=${recursive}`, {
|
||||
method: 'DELETE',
|
||||
override: this.auth,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
|
||||
override: this.auth,
|
||||
})
|
||||
|
||||
if (fileData instanceof Blob) {
|
||||
return raw ? fileData : await fileData.text()
|
||||
}
|
||||
return fileData
|
||||
}, ignoreFailure)
|
||||
}
|
||||
|
||||
extractFile(
|
||||
path: string,
|
||||
override = true,
|
||||
dry = false,
|
||||
silentQueue = false,
|
||||
): Promise<{ modpack_name: string | null; conflicting_files: string[] }> {
|
||||
return this.retryWithAuth(async () => {
|
||||
const encodedPath = encodeURIComponent(path)
|
||||
|
||||
if (!silentQueue) {
|
||||
this.queuedOps.push({ op: 'unarchive', src: path })
|
||||
setTimeout(() => this.removeQueuedOp('unarchive', path), 4000)
|
||||
}
|
||||
|
||||
try {
|
||||
return await useServersFetch(
|
||||
`/unarchive?src=${encodedPath}&trg=/&override=${override}&dry=${dry}`,
|
||||
{
|
||||
method: 'POST',
|
||||
override: this.auth,
|
||||
version: 1,
|
||||
},
|
||||
undefined,
|
||||
'Error extracting file',
|
||||
)
|
||||
} catch (err) {
|
||||
this.removeQueuedOp('unarchive', path)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
modifyOp(id: string, action: 'dismiss' | 'cancel'): Promise<void> {
|
||||
return this.retryWithAuth(async () => {
|
||||
await useServersFetch(
|
||||
`/ops/${action}?id=${id}`,
|
||||
{
|
||||
method: 'POST',
|
||||
override: this.auth,
|
||||
version: 1,
|
||||
},
|
||||
undefined,
|
||||
`Error ${action === 'dismiss' ? 'dismissing' : 'cancelling'} filesystem operation`,
|
||||
)
|
||||
|
||||
this.opsQueuedForModification = this.opsQueuedForModification.filter((x: string) => x !== id)
|
||||
this.ops = this.ops.filter((x: FilesystemOp) => x.id !== id)
|
||||
})
|
||||
}
|
||||
|
||||
removeQueuedOp(op: FSQueuedOp['op'], src: string): void {
|
||||
this.queuedOps = this.queuedOps.filter((x: FSQueuedOp) => x.op !== op || x.src !== src)
|
||||
}
|
||||
|
||||
clearQueuedOps(): void {
|
||||
this.queuedOps = []
|
||||
}
|
||||
}
|
||||
@@ -50,19 +50,6 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined
|
||||
}
|
||||
|
||||
try {
|
||||
const motd = await this.getMotd()
|
||||
if (motd === 'A Minecraft Server') {
|
||||
await this.setMotd(
|
||||
`§b${data.project?.title || data.loader + ' ' + data.mc_version} §f♦ §aModrinth Hosting`,
|
||||
)
|
||||
}
|
||||
data.motd = motd
|
||||
} catch {
|
||||
console.error('[Modrinth Hosting] [General] Failed to fetch MOTD.')
|
||||
data.motd = undefined
|
||||
}
|
||||
|
||||
// Copy data to this module
|
||||
Object.assign(this, data)
|
||||
}
|
||||
@@ -189,23 +176,6 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
||||
await this.fetch() // Refresh this module
|
||||
}
|
||||
|
||||
async getMotd(): Promise<string | undefined> {
|
||||
try {
|
||||
const props = await this.server.fs.downloadFile('/server.properties', false, true)
|
||||
if (props) {
|
||||
const lines = props.split('\n')
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('motd=')) {
|
||||
return line.slice(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async setMotd(motd: string): Promise<void> {
|
||||
try {
|
||||
const props = (await this.server.fetchConfigFile('ServerProperties')) as any
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export * from './backups.ts'
|
||||
export * from './base.ts'
|
||||
export * from './content.ts'
|
||||
export * from './fs.ts'
|
||||
export * from './general.ts'
|
||||
export * from './network.ts'
|
||||
export * from './startup.ts'
|
||||
|
||||
@@ -24,17 +24,17 @@
|
||||
<IntlFormatted :message-id="item">
|
||||
<template #status-link="{ children }">
|
||||
<a href="https://status.modrinth.com" target="_blank" rel="noopener">
|
||||
<component :is="() => children" />
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</a>
|
||||
</template>
|
||||
<template #discord-link="{ children }">
|
||||
<a href="https://discord.modrinth.com" target="_blank" rel="noopener">
|
||||
<component :is="() => children" />
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</a>
|
||||
</template>
|
||||
<template #tou-link="{ children }">
|
||||
<nuxt-link :to="`/legal/terms`" target="_blank" rel="noopener">
|
||||
<component :is="() => children" />
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
@@ -53,13 +53,15 @@
|
||||
<script setup>
|
||||
import { SadRinthbot } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessage,
|
||||
IntlFormatted,
|
||||
normalizeChildren,
|
||||
NotificationPanel,
|
||||
provideModrinthClient,
|
||||
provideNotificationManager,
|
||||
providePageContext,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
|
||||
import Logo404 from '~/assets/images/404.svg'
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
type AuthConfig,
|
||||
AuthFeature,
|
||||
CircuitBreakerFeature,
|
||||
NodeAuthFeature,
|
||||
nodeAuthState,
|
||||
NuxtCircuitBreakerStorage,
|
||||
type NuxtClientConfig,
|
||||
NuxtModrinthClient,
|
||||
@@ -11,6 +13,17 @@ import {
|
||||
} from '@modrinth/api-client'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
async function getRateLimitKeyFromSecretsStore(): Promise<string | undefined> {
|
||||
try {
|
||||
const mod = 'cloudflare:workers'
|
||||
const { env } = await import(/* @vite-ignore */ mod)
|
||||
return await env.RATE_LIMIT_IGNORE_KEY?.get()
|
||||
} catch {
|
||||
// Not running in Cloudflare Workers environment
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function createModrinthClient(
|
||||
auth: Ref<{ token: string | undefined }>,
|
||||
config: { apiBaseUrl: string; archonBaseUrl: string; rateLimitKey?: string },
|
||||
@@ -22,8 +35,18 @@ export function createModrinthClient(
|
||||
const clientConfig: NuxtClientConfig = {
|
||||
labrinthBaseUrl: config.apiBaseUrl,
|
||||
archonBaseUrl: config.archonBaseUrl,
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
rateLimitKey: config.rateLimitKey || getRateLimitKeyFromSecretsStore,
|
||||
features: [
|
||||
// for modrinth hosting
|
||||
// is skipped for normal reqs
|
||||
new NodeAuthFeature({
|
||||
getAuth: () => nodeAuthState.getAuth?.() ?? null,
|
||||
refreshAuth: async () => {
|
||||
if (nodeAuthState.refreshAuth) {
|
||||
await nodeAuthState.refreshAuth()
|
||||
}
|
||||
},
|
||||
}),
|
||||
new AuthFeature({
|
||||
token: async () => auth.value.token,
|
||||
} as AuthConfig),
|
||||
|
||||
@@ -1,514 +0,0 @@
|
||||
import { parse as parseTOML } from '@ltd/j-toml'
|
||||
import yaml from 'js-yaml'
|
||||
import JSZip from 'jszip'
|
||||
import { satisfies } from 'semver'
|
||||
|
||||
export const inferVersionInfo = async function (rawFile, project, gameVersions) {
|
||||
function versionType(number) {
|
||||
if (number.includes('alpha')) {
|
||||
return 'alpha'
|
||||
} else if (
|
||||
number.includes('beta') ||
|
||||
number.match(/[^A-z](rc)[^A-z]/) || // includes `rc`
|
||||
number.match(/[^A-z](pre)[^A-z]/) // includes `pre`
|
||||
) {
|
||||
return 'beta'
|
||||
} else {
|
||||
return 'release'
|
||||
}
|
||||
}
|
||||
|
||||
function getGameVersionsMatchingSemverRange(range, gameVersions) {
|
||||
if (!range) {
|
||||
return []
|
||||
}
|
||||
const ranges = Array.isArray(range) ? range : [range]
|
||||
return gameVersions.filter((version) => {
|
||||
const semverVersion = version.split('.').length === 2 ? `${version}.0` : version // add patch version if missing (e.g. 1.16 -> 1.16.0)
|
||||
return ranges.some((v) => satisfies(semverVersion, v))
|
||||
})
|
||||
}
|
||||
|
||||
function getGameVersionsMatchingMavenRange(range, gameVersions) {
|
||||
if (!range) {
|
||||
return []
|
||||
}
|
||||
const ranges = []
|
||||
|
||||
while (range.startsWith('[') || range.startsWith('(')) {
|
||||
let index = range.indexOf(')')
|
||||
const index2 = range.indexOf(']')
|
||||
if (index === -1 || (index2 !== -1 && index2 < index)) {
|
||||
index = index2
|
||||
}
|
||||
if (index === -1) break
|
||||
ranges.push(range.substring(0, index + 1))
|
||||
range = range.substring(index + 1).trim()
|
||||
if (range.startsWith(',')) {
|
||||
range = range.substring(1).trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (range) {
|
||||
ranges.push(range)
|
||||
}
|
||||
|
||||
const LESS_THAN_EQUAL = /^\(,(.*)]$/
|
||||
const LESS_THAN = /^\(,(.*)\)$/
|
||||
const EQUAL = /^\[(.*)]$/
|
||||
const GREATER_THAN_EQUAL = /^\[(.*),\)$/
|
||||
const GREATER_THAN = /^\((.*),\)$/
|
||||
const BETWEEN = /^\((.*),(.*)\)$/
|
||||
const BETWEEN_EQUAL = /^\[(.*),(.*)]$/
|
||||
const BETWEEN_LESS_THAN_EQUAL = /^\((.*),(.*)]$/
|
||||
const BETWEEN_GREATER_THAN_EQUAL = /^\[(.*),(.*)\)$/
|
||||
|
||||
const semverRanges = []
|
||||
|
||||
for (const range of ranges) {
|
||||
let result
|
||||
if ((result = range.match(LESS_THAN_EQUAL))) {
|
||||
semverRanges.push(`<=${result[1]}`)
|
||||
} else if ((result = range.match(LESS_THAN))) {
|
||||
semverRanges.push(`<${result[1]}`)
|
||||
} else if ((result = range.match(EQUAL))) {
|
||||
semverRanges.push(`${result[1]}`)
|
||||
} else if ((result = range.match(GREATER_THAN_EQUAL))) {
|
||||
semverRanges.push(`>=${result[1]}`)
|
||||
} else if ((result = range.match(GREATER_THAN))) {
|
||||
semverRanges.push(`>${result[1]}`)
|
||||
} else if ((result = range.match(BETWEEN))) {
|
||||
semverRanges.push(`>${result[1]} <${result[2]}`)
|
||||
} else if ((result = range.match(BETWEEN_EQUAL))) {
|
||||
semverRanges.push(`>=${result[1]} <=${result[2]}`)
|
||||
} else if ((result = range.match(BETWEEN_LESS_THAN_EQUAL))) {
|
||||
semverRanges.push(`>${result[1]} <=${result[2]}`)
|
||||
} else if ((result = range.match(BETWEEN_GREATER_THAN_EQUAL))) {
|
||||
semverRanges.push(`>=${result[1]} <${result[2]}`)
|
||||
}
|
||||
}
|
||||
return getGameVersionsMatchingSemverRange(semverRanges, gameVersions)
|
||||
}
|
||||
|
||||
const simplifiedGameVersions = gameVersions
|
||||
.filter((it) => it.version_type === 'release')
|
||||
.map((it) => it.version)
|
||||
|
||||
const inferFunctions = {
|
||||
// NeoForge
|
||||
'META-INF/neoforge.mods.toml': (file) => {
|
||||
const metadata = parseTOML(file, { joiner: '\n' })
|
||||
if (!metadata.mods || metadata.mods.length === 0) {
|
||||
return {}
|
||||
}
|
||||
|
||||
const neoForgeDependency = Object.values(metadata.dependencies)
|
||||
.flat()
|
||||
.find((dependency) => dependency.modId === 'neoforge')
|
||||
if (!neoForgeDependency) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// https://docs.neoforged.net/docs/gettingstarted/versioning/#neoforge
|
||||
const mcVersionRange = neoForgeDependency.versionRange
|
||||
.replace('-beta', '')
|
||||
.replace(/(\d+)(?:\.(\d+))?(?:\.(\d+)?)?/g, (_match, major, minor) => {
|
||||
return `1.${major}${minor ? '.' + minor : ''}`
|
||||
})
|
||||
const gameVersions = getGameVersionsMatchingMavenRange(mcVersionRange, simplifiedGameVersions)
|
||||
|
||||
const versionNum = metadata.mods[0].version
|
||||
return {
|
||||
name: `${project.title} ${versionNum}`,
|
||||
version_number: versionNum,
|
||||
loaders: ['neoforge'],
|
||||
version_type: versionType(versionNum),
|
||||
game_versions: gameVersions,
|
||||
}
|
||||
},
|
||||
// Forge 1.13+
|
||||
'META-INF/mods.toml': async (file, zip) => {
|
||||
const metadata = parseTOML(file, { joiner: '\n' })
|
||||
|
||||
if (metadata.mods && metadata.mods.length > 0) {
|
||||
let versionNum = metadata.mods[0].version
|
||||
|
||||
// ${file.jarVersion} -> Implementation-Version from manifest
|
||||
const manifestFile = zip.file('META-INF/MANIFEST.MF')
|
||||
if (metadata.mods[0].version.includes('${file.jarVersion}') && manifestFile !== null) {
|
||||
const manifestText = await manifestFile.async('text')
|
||||
const regex = /Implementation-Version: (.*)$/m
|
||||
const match = manifestText.match(regex)
|
||||
if (match) {
|
||||
versionNum = versionNum.replace('${file.jarVersion}', match[1])
|
||||
}
|
||||
}
|
||||
|
||||
let gameVersions = []
|
||||
const mcDependencies = Object.values(metadata.dependencies)
|
||||
.flat()
|
||||
.filter((dependency) => dependency.modId === 'minecraft')
|
||||
|
||||
if (mcDependencies.length > 0) {
|
||||
gameVersions = getGameVersionsMatchingMavenRange(
|
||||
mcDependencies[0].versionRange,
|
||||
simplifiedGameVersions,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${project.title} ${versionNum}`,
|
||||
version_number: versionNum,
|
||||
version_type: versionType(versionNum),
|
||||
loaders: ['forge'],
|
||||
game_versions: gameVersions,
|
||||
}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
},
|
||||
// Old Forge
|
||||
'mcmod.info': (file) => {
|
||||
const metadata = JSON.parse(file)
|
||||
|
||||
return {
|
||||
name: metadata.version ? `${project.title} ${metadata.version}` : '',
|
||||
version_number: metadata.version,
|
||||
version_type: versionType(metadata.version),
|
||||
loaders: ['forge'],
|
||||
game_versions: simplifiedGameVersions.filter((version) =>
|
||||
version.startsWith(metadata.mcversion),
|
||||
),
|
||||
}
|
||||
},
|
||||
// Fabric
|
||||
'fabric.mod.json': (file) => {
|
||||
const metadata = JSON.parse(file)
|
||||
|
||||
return {
|
||||
name: `${project.title} ${metadata.version}`,
|
||||
version_number: metadata.version,
|
||||
loaders: ['fabric'],
|
||||
version_type: versionType(metadata.version),
|
||||
game_versions: metadata.depends
|
||||
? getGameVersionsMatchingSemverRange(metadata.depends.minecraft, simplifiedGameVersions)
|
||||
: [],
|
||||
}
|
||||
},
|
||||
// Quilt
|
||||
'quilt.mod.json': (file) => {
|
||||
const metadata = JSON.parse(file)
|
||||
|
||||
return {
|
||||
name: `${project.title} ${metadata.quilt_loader.version}`,
|
||||
version_number: metadata.quilt_loader.version,
|
||||
loaders: ['quilt'],
|
||||
version_type: versionType(metadata.quilt_loader.version),
|
||||
game_versions: metadata.quilt_loader.depends
|
||||
? getGameVersionsMatchingSemverRange(
|
||||
metadata.quilt_loader.depends.find((x) => x.id === 'minecraft')
|
||||
? metadata.quilt_loader.depends.find((x) => x.id === 'minecraft').versions
|
||||
: [],
|
||||
simplifiedGameVersions,
|
||||
)
|
||||
: [],
|
||||
}
|
||||
},
|
||||
// Bukkit + Other Forks
|
||||
'plugin.yml': (file) => {
|
||||
const metadata = yaml.load(file)
|
||||
|
||||
return {
|
||||
name: `${project.title} ${metadata.version}`,
|
||||
version_number: metadata.version,
|
||||
version_type: versionType(metadata.version),
|
||||
// We don't know which fork of Bukkit users are using
|
||||
loaders: [],
|
||||
game_versions: gameVersions
|
||||
.filter(
|
||||
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
|
||||
)
|
||||
.map((x) => x.version),
|
||||
}
|
||||
},
|
||||
// Paper 1.19.3+
|
||||
'paper-plugin.yml': (file) => {
|
||||
const metadata = yaml.load(file)
|
||||
|
||||
return {
|
||||
name: `${project.title} ${metadata.version}`,
|
||||
version_number: metadata.version,
|
||||
version_type: versionType(metadata.version),
|
||||
loaders: ['paper'],
|
||||
game_versions: gameVersions
|
||||
.filter(
|
||||
(x) => x.version.startsWith(metadata['api-version']) && x.version_type === 'release',
|
||||
)
|
||||
.map((x) => x.version),
|
||||
}
|
||||
},
|
||||
// Bungeecord + Waterfall
|
||||
'bungee.yml': (file) => {
|
||||
const metadata = yaml.load(file)
|
||||
|
||||
return {
|
||||
name: `${project.title} ${metadata.version}`,
|
||||
version_number: metadata.version,
|
||||
version_type: versionType(metadata.version),
|
||||
loaders: ['bungeecord'],
|
||||
}
|
||||
},
|
||||
// Velocity
|
||||
'velocity-plugin.json': (file) => {
|
||||
const metadata = JSON.parse(file)
|
||||
|
||||
return {
|
||||
name: `${project.title} ${metadata.version}`,
|
||||
version_number: metadata.version,
|
||||
version_type: versionType(metadata.version),
|
||||
loaders: ['velocity'],
|
||||
}
|
||||
},
|
||||
// Modpacks
|
||||
'modrinth.index.json': (file) => {
|
||||
const metadata = JSON.parse(file)
|
||||
|
||||
const loaders = []
|
||||
if ('forge' in metadata.dependencies) {
|
||||
loaders.push('forge')
|
||||
}
|
||||
if ('neoforge' in metadata.dependencies) {
|
||||
loaders.push('neoforge')
|
||||
}
|
||||
if ('fabric-loader' in metadata.dependencies) {
|
||||
loaders.push('fabric')
|
||||
}
|
||||
if ('quilt-loader' in metadata.dependencies) {
|
||||
loaders.push('quilt')
|
||||
}
|
||||
|
||||
return {
|
||||
name: `${project.title} ${metadata.versionId}`,
|
||||
version_number: metadata.versionId,
|
||||
version_type: versionType(metadata.versionId),
|
||||
loaders,
|
||||
game_versions: gameVersions
|
||||
.filter((x) => x.version === metadata.dependencies.minecraft)
|
||||
.map((x) => x.version),
|
||||
}
|
||||
},
|
||||
// Resource Packs + Data Packs
|
||||
'pack.mcmeta': (file) => {
|
||||
const metadata = JSON.parse(file)
|
||||
|
||||
function getRange(versionA, versionB) {
|
||||
const startingIndex = gameVersions.findIndex((x) => x.version === versionA)
|
||||
const endingIndex = gameVersions.findIndex((x) => x.version === versionB)
|
||||
|
||||
const final = []
|
||||
const filterOnlyRelease = gameVersions[startingIndex].version_type === 'release'
|
||||
|
||||
for (let i = startingIndex; i >= endingIndex; i--) {
|
||||
if (gameVersions[i].version_type === 'release' || !filterOnlyRelease) {
|
||||
final.push(gameVersions[i].version)
|
||||
}
|
||||
}
|
||||
|
||||
return final
|
||||
}
|
||||
|
||||
const loaders = []
|
||||
let newGameVersions = []
|
||||
|
||||
if (project.actualProjectType === 'mod') {
|
||||
loaders.push('datapack')
|
||||
|
||||
switch (metadata.pack.pack_format) {
|
||||
case 4:
|
||||
newGameVersions = getRange('1.13', '1.14.4')
|
||||
break
|
||||
case 5:
|
||||
newGameVersions = getRange('1.15', '1.16.1')
|
||||
break
|
||||
case 6:
|
||||
newGameVersions = getRange('1.16.2', '1.16.5')
|
||||
break
|
||||
case 7:
|
||||
newGameVersions = getRange('1.17', '1.17.1')
|
||||
break
|
||||
case 8:
|
||||
newGameVersions = getRange('1.18', '1.18.1')
|
||||
break
|
||||
case 9:
|
||||
newGameVersions.push('1.18.2')
|
||||
break
|
||||
case 10:
|
||||
newGameVersions = getRange('1.19', '1.19.3')
|
||||
break
|
||||
case 11:
|
||||
newGameVersions = getRange('23w03a', '23w05a')
|
||||
break
|
||||
case 12:
|
||||
newGameVersions.push('1.19.4')
|
||||
break
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
if (project.actualProjectType === 'resourcepack') {
|
||||
loaders.push('minecraft')
|
||||
|
||||
switch (metadata.pack.pack_format) {
|
||||
case 1:
|
||||
newGameVersions = getRange('1.6.1', '1.8.9')
|
||||
break
|
||||
case 2:
|
||||
newGameVersions = getRange('1.9', '1.10.2')
|
||||
break
|
||||
case 3:
|
||||
newGameVersions = getRange('1.11', '1.12.2')
|
||||
break
|
||||
case 4:
|
||||
newGameVersions = getRange('1.13', '1.14.4')
|
||||
break
|
||||
case 5:
|
||||
newGameVersions = getRange('1.15', '1.16.1')
|
||||
break
|
||||
case 6:
|
||||
newGameVersions = getRange('1.16.2', '1.16.5')
|
||||
break
|
||||
case 7:
|
||||
newGameVersions = getRange('1.17', '1.17.1')
|
||||
break
|
||||
case 8:
|
||||
newGameVersions = getRange('1.18', '1.18.2')
|
||||
break
|
||||
case 9:
|
||||
newGameVersions = getRange('1.19', '1.19.2')
|
||||
break
|
||||
case 11:
|
||||
newGameVersions = getRange('22w42a', '22w44a')
|
||||
break
|
||||
case 12:
|
||||
newGameVersions.push('1.19.3')
|
||||
break
|
||||
case 13:
|
||||
newGameVersions.push('1.19.4')
|
||||
break
|
||||
case 14:
|
||||
newGameVersions = getRange('23w14a', '23w16a')
|
||||
break
|
||||
case 15:
|
||||
newGameVersions = getRange('1.20', '1.20.1')
|
||||
break
|
||||
case 16:
|
||||
newGameVersions.push('23w31a')
|
||||
break
|
||||
case 17:
|
||||
newGameVersions = getRange('23w32a', '1.20.2-pre1')
|
||||
break
|
||||
case 18:
|
||||
newGameVersions.push('1.20.2')
|
||||
break
|
||||
case 19:
|
||||
newGameVersions.push('23w42a')
|
||||
break
|
||||
case 20:
|
||||
newGameVersions = getRange('23w43a', '23w44a')
|
||||
break
|
||||
case 21:
|
||||
newGameVersions = getRange('23w45a', '23w46a')
|
||||
break
|
||||
case 22:
|
||||
newGameVersions = getRange('1.20.3', '1.20.4')
|
||||
break
|
||||
case 24:
|
||||
newGameVersions = getRange('24w03a', '24w04a')
|
||||
break
|
||||
case 25:
|
||||
newGameVersions = getRange('24w05a', '24w05b')
|
||||
break
|
||||
case 26:
|
||||
newGameVersions = getRange('24w06a', '24w07a')
|
||||
break
|
||||
case 28:
|
||||
newGameVersions = getRange('24w09a', '24w10a')
|
||||
break
|
||||
case 29:
|
||||
newGameVersions.push('24w11a')
|
||||
break
|
||||
case 30:
|
||||
newGameVersions.push('24w12a')
|
||||
break
|
||||
case 31:
|
||||
newGameVersions = getRange('24w13a', '1.20.5-pre3')
|
||||
break
|
||||
case 32:
|
||||
newGameVersions = getRange('1.20.5', '1.20.6')
|
||||
break
|
||||
case 33:
|
||||
newGameVersions = getRange('24w18a', '24w20a')
|
||||
break
|
||||
case 34:
|
||||
newGameVersions = getRange('1.21', '1.21.1')
|
||||
break
|
||||
case 35:
|
||||
newGameVersions.push('24w33a')
|
||||
break
|
||||
case 36:
|
||||
newGameVersions = getRange('24w34a', '24w35a')
|
||||
break
|
||||
case 37:
|
||||
newGameVersions.push('24w36a')
|
||||
break
|
||||
case 38:
|
||||
newGameVersions.push('24w37a')
|
||||
break
|
||||
case 39:
|
||||
newGameVersions = getRange('24w38a', '24w39a')
|
||||
break
|
||||
case 40:
|
||||
newGameVersions.push('24w40a')
|
||||
break
|
||||
case 41:
|
||||
newGameVersions = getRange('1.21.2-pre1', '1.21.2-pre2')
|
||||
break
|
||||
case 42:
|
||||
newGameVersions = getRange('1.21.2', '1.21.3')
|
||||
break
|
||||
case 43:
|
||||
newGameVersions.push('24w44a')
|
||||
break
|
||||
case 44:
|
||||
newGameVersions.push('24w45a')
|
||||
break
|
||||
case 45:
|
||||
newGameVersions.push('24w46a')
|
||||
break
|
||||
case 46:
|
||||
newGameVersions.push('1.21.4')
|
||||
break
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
loaders,
|
||||
game_versions: newGameVersions,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const zipReader = new JSZip()
|
||||
|
||||
const zip = await zipReader.loadAsync(rawFile)
|
||||
|
||||
for (const fileName in inferFunctions) {
|
||||
const file = zip.file(fileName)
|
||||
|
||||
if (file !== null) {
|
||||
const text = await file.async('text')
|
||||
return inferFunctions[fileName](text, zip)
|
||||
}
|
||||
}
|
||||
}
|
||||
123
apps/frontend/src/helpers/infer/constants.ts
Normal file
123
apps/frontend/src/helpers/infer/constants.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Pack format to Minecraft version mappings
|
||||
// See: https://minecraft.wiki/w/Pack_format
|
||||
|
||||
// NOTE: This needs to be continuously updated as new versions are released.
|
||||
|
||||
// Resource pack format history (full table including development versions)
|
||||
export const RESOURCE_PACK_FORMATS = {
|
||||
1: { min: '1.6.1', max: '1.8.9' },
|
||||
2: { min: '1.9', max: '1.10.2' },
|
||||
3: { min: '1.11', max: '1.12.2' },
|
||||
4: { min: '1.13', max: '1.14.4' },
|
||||
5: { min: '1.15', max: '1.16.1' },
|
||||
6: { min: '1.16.2', max: '1.16.5' },
|
||||
7: { min: '1.17', max: '1.17.1' },
|
||||
8: { min: '1.18', max: '1.18.2' },
|
||||
9: { min: '1.19', max: '1.19.2' },
|
||||
11: { min: '22w42a', max: '22w44a' },
|
||||
12: { min: '1.19.3', max: '1.19.3' },
|
||||
13: { min: '1.19.4', max: '1.19.4' },
|
||||
14: { min: '23w14a', max: '23w16a' },
|
||||
15: { min: '1.20', max: '1.20.1' },
|
||||
16: { min: '23w31a', max: '23w31a' },
|
||||
17: { min: '23w32a', max: '1.20.2-pre1' },
|
||||
18: { min: '1.20.2', max: '1.20.2' },
|
||||
19: { min: '23w42a', max: '23w42a' },
|
||||
20: { min: '23w43a', max: '23w44a' },
|
||||
21: { min: '23w45a', max: '23w46a' },
|
||||
22: { min: '1.20.3', max: '1.20.4' },
|
||||
24: { min: '24w03a', max: '24w04a' },
|
||||
25: { min: '24w05a', max: '24w05b' },
|
||||
26: { min: '24w06a', max: '24w07a' },
|
||||
28: { min: '24w09a', max: '24w10a' },
|
||||
29: { min: '24w11a', max: '24w11a' },
|
||||
30: { min: '24w12a', max: '24w12a' },
|
||||
31: { min: '24w13a', max: '1.20.5-pre3' },
|
||||
32: { min: '1.20.5', max: '1.20.6' },
|
||||
33: { min: '24w18a', max: '24w20a' },
|
||||
34: { min: '1.21', max: '1.21.1' },
|
||||
35: { min: '24w33a', max: '24w33a' },
|
||||
36: { min: '24w34a', max: '24w35a' },
|
||||
37: { min: '24w36a', max: '24w36a' },
|
||||
38: { min: '24w37a', max: '24w37a' },
|
||||
39: { min: '24w38a', max: '24w39a' },
|
||||
40: { min: '24w40a', max: '24w40a' },
|
||||
41: { min: '1.21.2-pre1', max: '1.21.2-pre2' },
|
||||
42: { min: '1.21.2', max: '1.21.3' },
|
||||
43: { min: '24w44a', max: '24w44a' },
|
||||
44: { min: '24w45a', max: '24w45a' },
|
||||
45: { min: '24w46a', max: '24w46a' },
|
||||
46: { min: '1.21.4', max: '1.21.4' },
|
||||
55: { min: '1.21.5', max: '1.21.5' },
|
||||
63: { min: '1.21.6', max: '1.21.6' },
|
||||
64: { min: '1.21.7', max: '1.21.8' },
|
||||
69.0: { min: '1.21.9', max: '1.21.10' },
|
||||
75: { min: '1.21.11', max: '1.21.11' },
|
||||
} as const
|
||||
|
||||
// Data pack format history (full table including development versions)
|
||||
export const DATA_PACK_FORMATS = {
|
||||
4: { min: '1.13', max: '1.14.4' },
|
||||
5: { min: '1.15', max: '1.16.1' },
|
||||
6: { min: '1.16.2', max: '1.16.5' },
|
||||
7: { min: '1.17', max: '1.17.1' },
|
||||
8: { min: '1.18', max: '1.18.1' },
|
||||
9: { min: '1.18.2', max: '1.18.2' },
|
||||
10: { min: '1.19', max: '1.19.3' },
|
||||
11: { min: '23w03a', max: '23w05a' },
|
||||
12: { min: '1.19.4', max: '1.19.4' },
|
||||
13: { min: '23w12a', max: '23w14a' },
|
||||
14: { min: '23w16a', max: '23w17a' },
|
||||
15: { min: '1.20', max: '1.20.1' },
|
||||
16: { min: '23w31a', max: '23w31a' },
|
||||
17: { min: '23w32a', max: '1.20.2-pre1' },
|
||||
18: { min: '1.20.2', max: '1.20.2' },
|
||||
19: { min: '23w40a', max: '23w40a' },
|
||||
20: { min: '23w41a', max: '23w41a' },
|
||||
21: { min: '23w42a', max: '23w42a' },
|
||||
22: { min: '23w43a', max: '23w44a' },
|
||||
23: { min: '23w45a', max: '23w46a' },
|
||||
24: { min: '1.20.3-pre1', max: '1.20.3-pre1' },
|
||||
25: { min: '1.20.3-pre2', max: '1.20.3-pre4' },
|
||||
26: { min: '1.20.3', max: '1.20.4' },
|
||||
27: { min: '23w51a', max: '23w51b' },
|
||||
28: { min: '24w03a', max: '24w04a' },
|
||||
29: { min: '24w05a', max: '24w05b' },
|
||||
30: { min: '24w06a', max: '24w06a' },
|
||||
31: { min: '24w07a', max: '24w07a' },
|
||||
32: { min: '24w09a', max: '24w10a' },
|
||||
33: { min: '24w11a', max: '24w11a' },
|
||||
34: { min: '24w12a', max: '24w12a' },
|
||||
35: { min: '24w13a', max: '24w13a' },
|
||||
36: { min: '24w14a', max: '24w14a' },
|
||||
37: { min: '1.20.5-pre1', max: '1.20.5-pre1' },
|
||||
38: { min: '1.20.5-pre2', max: '1.20.5-pre3' },
|
||||
39: { min: '1.20.5-pre4', max: '1.20.5-rc3' },
|
||||
40: { min: '1.20.5-rc4', max: '1.20.5-rc4' },
|
||||
41: { min: '1.20.5', max: '1.20.6' },
|
||||
42: { min: '24w18a', max: '24w19b' },
|
||||
43: { min: '24w20a', max: '24w20a' },
|
||||
44: { min: '24w21a', max: '24w21b' },
|
||||
45: { min: '1.21-pre1', max: '1.21-pre1' },
|
||||
46: { min: '1.21-pre2', max: '1.21-pre4' },
|
||||
47: { min: '1.21-rc1', max: '1.21-rc1' },
|
||||
48: { min: '1.21', max: '1.21.1' },
|
||||
49: { min: '24w33a', max: '24w33a' },
|
||||
50: { min: '24w34a', max: '24w35a' },
|
||||
51: { min: '24w36a', max: '24w36a' },
|
||||
52: { min: '24w37a', max: '24w37a' },
|
||||
53: { min: '24w38a', max: '24w38a' },
|
||||
54: { min: '24w39a', max: '24w39a' },
|
||||
55: { min: '24w40a', max: '24w40a' },
|
||||
56: { min: '1.21.2-pre1', max: '1.21.2-pre2' },
|
||||
57: { min: '1.21.2', max: '1.21.3' },
|
||||
58: { min: '24w44a', max: '24w44a' },
|
||||
59: { min: '24w45a', max: '24w45a' },
|
||||
60: { min: '24w46a', max: '24w46a' },
|
||||
61: { min: '1.21.4', max: '1.21.4' },
|
||||
71: { min: '1.21.5', max: '1.21.5' },
|
||||
80: { min: '1.21.6', max: '1.21.6' },
|
||||
81: { min: '1.21.7', max: '1.21.8' },
|
||||
88.0: { min: '1.21.9', max: '1.21.10' },
|
||||
94.1: { min: '1.21.11', max: '1.21.11' },
|
||||
} as const
|
||||
3
apps/frontend/src/helpers/infer/index.ts
Normal file
3
apps/frontend/src/helpers/infer/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export type { InferredVersionInfo } from './infer'
|
||||
export { inferVersionInfo } from './infer'
|
||||
export { extractVersionDetailsFromFilename } from './version-utils'
|
||||
132
apps/frontend/src/helpers/infer/infer.ts
Normal file
132
apps/frontend/src/helpers/infer/infer.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
import { createLoaderParsers } from './loader-parsers'
|
||||
import { createMultiFileDetectors } from './multi-file-detectors'
|
||||
import { createPackParser } from './pack-parsers'
|
||||
import { extractVersionDetailsFromFilename } from './version-utils'
|
||||
|
||||
export type GameVersion = { version: string; version_type: string }
|
||||
|
||||
export type Project = { title: string; actualProjectType?: string }
|
||||
|
||||
export type RawFile = File | (Blob & { name: string })
|
||||
|
||||
export interface InferredVersionInfo {
|
||||
name?: string
|
||||
version_number?: string
|
||||
version_type?: 'alpha' | 'beta' | 'release'
|
||||
loaders?: string[]
|
||||
game_versions?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills in missing version information from the filename if not already present.
|
||||
*/
|
||||
function fillMissingFromFilename(
|
||||
result: InferredVersionInfo,
|
||||
filename: string,
|
||||
projectTitle: string,
|
||||
): InferredVersionInfo {
|
||||
const filenameDetails = extractVersionDetailsFromFilename(filename)
|
||||
|
||||
if (!result.version_number && filenameDetails.versionNumber) {
|
||||
result.version_number = filenameDetails.versionNumber
|
||||
}
|
||||
|
||||
if (!result.version_type) {
|
||||
result.version_type = filenameDetails.versionType
|
||||
}
|
||||
|
||||
if (!result.name && result.version_number) {
|
||||
result.name = `${projectTitle} ${result.version_number}`
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to infer version information from a file.
|
||||
* Analyzes mod loaders, packs, and other Minecraft-related file formats.
|
||||
*/
|
||||
export const inferVersionInfo = async function (
|
||||
rawFile: RawFile,
|
||||
project: Project,
|
||||
gameVersions: GameVersion[],
|
||||
): Promise<InferredVersionInfo> {
|
||||
const simplifiedGameVersions = gameVersions
|
||||
.filter((it) => it.version_type === 'release')
|
||||
.map((it) => it.version)
|
||||
|
||||
const zipReader = new JSZip()
|
||||
const zip = await zipReader.loadAsync(rawFile)
|
||||
|
||||
const loaderParsers = createLoaderParsers(project, gameVersions, simplifiedGameVersions)
|
||||
const packParser = createPackParser(project, gameVersions, rawFile)
|
||||
const multiFileDetectors = createMultiFileDetectors(project, gameVersions, rawFile)
|
||||
|
||||
const inferFunctions = {
|
||||
...loaderParsers,
|
||||
'pack.mcmeta': packParser,
|
||||
}
|
||||
|
||||
// Multi-loader detection
|
||||
const multiLoaderFiles = [
|
||||
'META-INF/neoforge.mods.toml',
|
||||
'META-INF/mods.toml',
|
||||
'fabric.mod.json',
|
||||
'quilt.mod.json',
|
||||
]
|
||||
const detectedLoaderFiles = multiLoaderFiles.filter((fileName) => zip.file(fileName) !== null)
|
||||
if (detectedLoaderFiles.length > 1) {
|
||||
const results: InferredVersionInfo[] = []
|
||||
for (const fileName of detectedLoaderFiles) {
|
||||
const file = zip.file(fileName)
|
||||
if (file !== null) {
|
||||
const text = await file.async('text')
|
||||
const parser = inferFunctions[fileName as keyof typeof inferFunctions]
|
||||
if (parser) {
|
||||
const result = await parser(text, zip)
|
||||
if (result && Object.keys(result).length > 0) results.push(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (results.length > 0) {
|
||||
const combinedLoaders = [...new Set(results.flatMap((r) => r.loaders || []))]
|
||||
const allGameVersions = [...new Set(results.flatMap((r) => r.game_versions || []))]
|
||||
const primaryResult = results.find((r) => r.version_number) || results[0]
|
||||
|
||||
const mergedResult = {
|
||||
name: primaryResult.name,
|
||||
version_number: primaryResult.version_number,
|
||||
version_type: primaryResult.version_type,
|
||||
loaders: combinedLoaders,
|
||||
game_versions: allGameVersions,
|
||||
}
|
||||
return fillMissingFromFilename(mergedResult, rawFile.name, project.title)
|
||||
}
|
||||
}
|
||||
|
||||
// Standard single-loader detection
|
||||
for (const fileName in inferFunctions) {
|
||||
const file = zip.file(fileName)
|
||||
|
||||
if (file !== null) {
|
||||
const text = await file.async('text')
|
||||
const parser = inferFunctions[fileName as keyof typeof inferFunctions]
|
||||
if (parser) {
|
||||
const result = await parser(text, zip)
|
||||
return fillMissingFromFilename(result, rawFile.name, project.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Multi-file detection functions
|
||||
for (const detector of Object.values(multiFileDetectors)) {
|
||||
const result = await detector(zip)
|
||||
if (result !== null) {
|
||||
return fillMissingFromFilename(result, rawFile.name, project.title)
|
||||
}
|
||||
}
|
||||
|
||||
return fillMissingFromFilename({}, rawFile.name, project.title)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user