Merge tag 'v0.10.27' into beta

This commit is contained in:
2026-01-27 23:03:46 +03:00
804 changed files with 69201 additions and 21982 deletions

View File

@@ -2,7 +2,9 @@
**/dist
**/.output
**/.data
**/.wrangler
src/generated/**
src/locales/**
src/public/news/feed
src/assets/**/*.svg
**/.wrangler

View File

@@ -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/\*

View File

@@ -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

View File

@@ -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",

View File

@@ -1,5 +1,6 @@
<template>
<NuxtLayout>
<NuxtRouteAnnouncer />
<ModrinthLoadingIndicator />
<NotificationPanel />
<NuxtPage />

View File

@@ -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

View File

@@ -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>

View 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>

View File

@@ -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>

View File

@@ -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'

View 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&#10;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>

View File

@@ -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>

View 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>

View 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>

View 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: 'Youre viewing Modrinths 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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>

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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({

View File

@@ -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({

View File

@@ -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() {

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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')

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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>

View File

@@ -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'

View File

@@ -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"

View File

@@ -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">

View File

@@ -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>

View File

@@ -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'

View File

@@ -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()

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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>

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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',

View File

@@ -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'

View File

@@ -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'

View File

@@ -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'

View File

@@ -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>

View File

@@ -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'

View File

@@ -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'

View File

@@ -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',

View File

@@ -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>

View File

@@ -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,

View File

@@ -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>

View File

@@ -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(() => {

View File

@@ -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)"

View File

@@ -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()

View File

@@ -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>

View File

@@ -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

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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" />

View File

@@ -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>

View 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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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

View File

@@ -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'

View File

@@ -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

View File

@@ -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'

View File

@@ -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'

View File

@@ -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) {

View File

@@ -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'

View File

@@ -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">

View File

@@ -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"

View File

@@ -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()

View File

@@ -1,3 +1,5 @@
import { defineMessages, useVIntl } from '@modrinth/ui'
export const scopeMessages = defineMessages({
userReadEmailLabel: {
id: 'scopes.userReadEmail.label',

View File

@@ -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

View File

@@ -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,
})
}

View File

@@ -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(),
}))

View File

@@ -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>
}

View 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,
}),
}

View 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
}

View File

@@ -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
}

View File

@@ -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 = []
}
}

View File

@@ -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

View File

@@ -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'

View File

@@ -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'

View File

@@ -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),

View File

@@ -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)
}
}
}

View 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

View File

@@ -0,0 +1,3 @@
export type { InferredVersionInfo } from './infer'
export { inferVersionInfo } from './infer'
export { extractVersionDetailsFromFilename } from './version-utils'

View 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