forked from didirus/AstralRinth
feat: start of cross platform page system (#4731)
* feat: abstract api-client DI into ui package * feat: cross platform page system * feat: tanstack as cross platform useAsyncData * feat: archon servers routes + labrinth billing routes * fix: dont use partial * feat: migrate server list page to tanstack + api-client + re-enabled broken features! * feat: migrate servers manage page to api-client before page system * feat: migrate manage page to page system * fix: type issues * fix: upgrade wrapper bugs * refactor: move state types into api-client * feat: disable financial stuff on app frontend * feat: finalize cross platform page system for now * fix: lint * fix: build issues * feat: remove papaparse * fix: lint * fix: interface error --------- Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -13,11 +13,13 @@
|
||||
"test": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sfirew/minecraft-motd-parser": "^1.1.6",
|
||||
"@modrinth/api-client": "workspace:^",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@sentry/vue": "^8.27.0",
|
||||
"@sfirew/minecraft-motd-parser": "^1.1.6",
|
||||
"@tanstack/vue-query": "^5.90.7",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.1",
|
||||
"@tauri-apps/plugin-http": "^2.5.0",
|
||||
@@ -41,9 +43,9 @@
|
||||
"vue-virtual-scroller": "v2.0.0-beta.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@eslint/compat": "^1.1.1",
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@nuxt/eslint-config": "^0.5.6",
|
||||
"@taijased/vue-render-tracker": "^1.0.7",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { AuthFeature, TauriModrinthClient } from '@modrinth/api-client'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
ChangeSkinIcon,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
RefreshCwIcon,
|
||||
RestoreIcon,
|
||||
RightArrowIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
UserIcon,
|
||||
WorldIcon,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
NotificationPanel,
|
||||
OverflowMenu,
|
||||
ProgressSpinner,
|
||||
provideModrinthClient,
|
||||
provideNotificationManager,
|
||||
} from '@modrinth/ui'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
@@ -102,6 +105,16 @@ const notificationManager = new AppNotificationManager()
|
||||
provideNotificationManager(notificationManager)
|
||||
const { handleError, addNotification } = notificationManager
|
||||
|
||||
const tauriApiClient = new TauriModrinthClient({
|
||||
userAgent: `modrinth/theseus/${getVersion()} (support@modrinth.com)`,
|
||||
features: [
|
||||
new AuthFeature({
|
||||
token: async () => (await getCreds()).session,
|
||||
}),
|
||||
],
|
||||
})
|
||||
provideModrinthClient(tauriApiClient)
|
||||
|
||||
const news = ref([])
|
||||
const availableSurvey = ref(false)
|
||||
|
||||
@@ -742,6 +755,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<NavButton v-if="themeStore.featureFlags.worlds_tab" v-tooltip.right="'Worlds'" to="/worlds">
|
||||
<WorldIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-if="themeStore.featureFlags.servers_in_app"
|
||||
v-tooltip.right="'Servers'"
|
||||
to="/servers/manage"
|
||||
>
|
||||
<ServerIcon />
|
||||
</NavButton>
|
||||
<NavButton
|
||||
v-tooltip.right="'Discover content'"
|
||||
to="/browse/modpack"
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'floating-vue/dist/style.css'
|
||||
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import { VueScanPlugin } from '@taijased/vue-render-tracker'
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query'
|
||||
import { createPlugin } from '@vintl/vintl/plugin'
|
||||
import FloatingVue from 'floating-vue'
|
||||
import { createPinia } from 'pinia'
|
||||
@@ -45,6 +46,7 @@ Sentry.init({
|
||||
tracesSampleRate: 0.1,
|
||||
})
|
||||
|
||||
app.use(VueQueryPlugin)
|
||||
app.use(vueScan)
|
||||
app.use(router)
|
||||
app.use(pinia)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ServersManagePageIndex } from '@modrinth/ui'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
import * as Pages from '@/pages'
|
||||
@@ -27,6 +28,14 @@ export default new createRouter({
|
||||
breadcrumb: [{ name: 'Worlds' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/servers/manage/',
|
||||
name: 'Servers',
|
||||
component: ServersManagePageIndex,
|
||||
meta: {
|
||||
breadcrumb: [{ name: 'Servers' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/browse/:projectType',
|
||||
name: 'Discover content',
|
||||
|
||||
@@ -5,6 +5,7 @@ export const DEFAULT_FEATURE_FLAGS = {
|
||||
page_path: false,
|
||||
worlds_tab: false,
|
||||
worlds_in_home: true,
|
||||
servers_in_app: false,
|
||||
}
|
||||
|
||||
export const THEME_OPTIONS = ['dark', 'light', 'oled', 'system'] as const
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
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 { defineNuxtConfig } from 'nuxt/config'
|
||||
import { $fetch } from 'ofetch'
|
||||
import Papa from 'papaparse'
|
||||
import { basename, relative, resolve } from 'pathe'
|
||||
import svgLoader from 'vite-svg-loader'
|
||||
|
||||
import type { GeneratedState } from './src/composables/generated'
|
||||
|
||||
const STAGING_API_URL = 'https://staging-api.modrinth.com/v2/'
|
||||
// ISO 3166 data from https://github.com/ipregistry/iso3166
|
||||
// Licensed under CC BY-SA 4.0
|
||||
const ISO3166_REPO = 'https://raw.githubusercontent.com/ipregistry/iso3166/master'
|
||||
|
||||
const preloadedFonts = [
|
||||
'inter/Inter-Regular.woff2',
|
||||
@@ -139,7 +133,7 @@ export default defineNuxtConfig({
|
||||
// 30 minutes
|
||||
const TTL = 30 * 60 * 1000
|
||||
|
||||
let state: Partial<GeneratedState> = {}
|
||||
let state: Partial<Labrinth.State.GeneratedState & Record<string, any>> = {}
|
||||
|
||||
try {
|
||||
state = JSON.parse(await fs.readFile('./src/generated/state.json', 'utf8'))
|
||||
@@ -165,124 +159,19 @@ export default defineNuxtConfig({
|
||||
return
|
||||
}
|
||||
|
||||
const client = new GenericModrinthClient({
|
||||
labrinthBaseUrl: API_URL.replace('/v2/', ''),
|
||||
userAgent: 'Knossos generator (support@modrinth.com)',
|
||||
})
|
||||
|
||||
const generatedState = await client.labrinth.state.build()
|
||||
state.lastGenerated = new Date().toISOString()
|
||||
|
||||
state.apiUrl = API_URL
|
||||
|
||||
const headers = {
|
||||
headers: {
|
||||
'user-agent': 'Knossos generator (support@modrinth.com)',
|
||||
},
|
||||
state = {
|
||||
...state,
|
||||
...generatedState,
|
||||
}
|
||||
|
||||
const caughtErrorCodes = new Set<number>()
|
||||
|
||||
function handleFetchError(err: any, defaultValue: any) {
|
||||
console.error('Error generating state: ', err)
|
||||
caughtErrorCodes.add(err.status)
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
const [
|
||||
categories,
|
||||
loaders,
|
||||
gameVersions,
|
||||
donationPlatforms,
|
||||
reportTypes,
|
||||
homePageProjects,
|
||||
homePageSearch,
|
||||
homePageNotifs,
|
||||
products,
|
||||
muralBankDetails,
|
||||
countriesCSV,
|
||||
subdivisionsCSV,
|
||||
] = await Promise.all([
|
||||
$fetch(`${API_URL}tag/category`, headers).catch((err) => handleFetchError(err, [])),
|
||||
$fetch(`${API_URL}tag/loader`, headers).catch((err) => handleFetchError(err, [])),
|
||||
$fetch(`${API_URL}tag/game_version`, headers).catch((err) => handleFetchError(err, [])),
|
||||
$fetch(`${API_URL}tag/donation_platform`, headers).catch((err) =>
|
||||
handleFetchError(err, []),
|
||||
),
|
||||
$fetch(`${API_URL}tag/report_type`, headers).catch((err) => handleFetchError(err, [])),
|
||||
$fetch(`${API_URL}projects_random?count=60`, headers).catch((err) =>
|
||||
handleFetchError(err, []),
|
||||
),
|
||||
$fetch(`${API_URL}search?limit=3&query=leave&index=relevance`, headers).catch((err) =>
|
||||
handleFetchError(err, {}),
|
||||
),
|
||||
$fetch(`${API_URL}search?limit=3&query=&index=updated`, headers).catch((err) =>
|
||||
handleFetchError(err, {}),
|
||||
),
|
||||
$fetch(`${API_URL.replace('/v2/', '/_internal/')}billing/products`, headers).catch((err) =>
|
||||
handleFetchError(err, []),
|
||||
),
|
||||
$fetch(`${API_URL.replace('/v2/', '/_internal/')}mural/bank-details`, headers).catch(
|
||||
(err) => handleFetchError(err, null),
|
||||
),
|
||||
$fetch<string>(`${ISO3166_REPO}/countries.csv`, {
|
||||
...headers,
|
||||
responseType: 'text',
|
||||
}).catch((err) => handleFetchError(err, '')),
|
||||
$fetch<string>(`${ISO3166_REPO}/subdivisions.csv`, {
|
||||
...headers,
|
||||
responseType: 'text',
|
||||
}).catch((err) => handleFetchError(err, '')),
|
||||
])
|
||||
|
||||
const countriesData = Papa.parse(countriesCSV, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
transformHeader: (header) => (header.startsWith('#') ? header.slice(1) : header),
|
||||
}).data
|
||||
const subdivisionsData = Papa.parse(subdivisionsCSV, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
transformHeader: (header) => (header.startsWith('#') ? header.slice(1) : header),
|
||||
}).data
|
||||
|
||||
const subdivisionsByCountry = (subdivisionsData as any[]).reduce(
|
||||
(acc, sub) => {
|
||||
const countryCode = sub.country_code_alpha2
|
||||
|
||||
if (!countryCode || typeof countryCode !== 'string' || countryCode.trim() === '') {
|
||||
return acc
|
||||
}
|
||||
|
||||
if (!acc[countryCode]) acc[countryCode] = []
|
||||
|
||||
acc[countryCode].push({
|
||||
code: sub['subdivision_code_iso3166-2'],
|
||||
name: sub.subdivision_name,
|
||||
localVariant: sub.localVariant || null,
|
||||
category: sub.category,
|
||||
parent: sub.parent_subdivision || null,
|
||||
language: sub.language_code,
|
||||
})
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any[]>,
|
||||
)
|
||||
|
||||
state.categories = categories
|
||||
state.loaders = loaders
|
||||
state.gameVersions = gameVersions
|
||||
state.donationPlatforms = donationPlatforms
|
||||
state.reportTypes = reportTypes
|
||||
state.homePageProjects = homePageProjects
|
||||
state.homePageSearch = homePageSearch
|
||||
state.homePageNotifs = homePageNotifs
|
||||
state.products = products
|
||||
state.muralBankDetails = muralBankDetails.bankDetails
|
||||
state.countries = (countriesData as any[]).map((c) => ({
|
||||
alpha2: c.country_code_alpha2,
|
||||
alpha3: c.country_code_alpha3,
|
||||
numeric: c.numeric_code,
|
||||
nameShort: c.name_short,
|
||||
nameLong: c.name_long,
|
||||
}))
|
||||
state.subdivisions = subdivisionsByCountry
|
||||
state.errors = [...caughtErrorCodes]
|
||||
|
||||
await fs.writeFile('./src/generated/state.json', JSON.stringify(state))
|
||||
|
||||
console.log('Tags generated!')
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
"@nuxt/devtools": "^1.3.3",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/node": "^20.1.0",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"@vintl/compact-number": "^2.0.5",
|
||||
"@vintl/how-ago": "^3.0.1",
|
||||
"@vintl/nuxt": "^1.9.2",
|
||||
@@ -38,13 +37,14 @@
|
||||
"@formatjs/intl-localematcher": "^0.5.4",
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@modrinth/moderation": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@tanstack/vue-query": "^5.90.7",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
@@ -61,7 +61,6 @@
|
||||
"js-yaml": "^4.1.0",
|
||||
"jszip": "^3.10.1",
|
||||
"markdown-it": "14.1.0",
|
||||
"papaparse": "^5.4.1",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"pinia-plugin-persistedstate": "^4.4.1",
|
||||
|
||||
@@ -6,12 +6,11 @@
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { NotificationPanel, provideNotificationManager } from '@modrinth/ui'
|
||||
import { NotificationPanel, provideModrinthClient, provideNotificationManager } from '@modrinth/ui'
|
||||
|
||||
import ModrinthLoadingIndicator from '~/components/ui/modrinth-loading-indicator.ts'
|
||||
|
||||
import { createModrinthClient, provideModrinthClient } from './providers/api-client.ts'
|
||||
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
|
||||
import { createModrinthClient } from '~/helpers/api.ts'
|
||||
import { FrontendNotificationManager } from '~/providers/frontend-notifications.ts'
|
||||
|
||||
const auth = await useAuth()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
@@ -120,7 +120,7 @@ import {
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { ButtonStyled, Checkbox, NewModal } from '@modrinth/ui'
|
||||
import { ButtonStyled, Checkbox, NewModal, ServerInfoLabels } from '@modrinth/ui'
|
||||
import type { PowerAction as ServerPowerAction, ServerState } from '@modrinth/utils'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
@@ -130,7 +130,6 @@ import type { BackupInProgressReason } from '~/pages/servers/manage/[id].vue'
|
||||
|
||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||
import PanelSpinner from './PanelSpinner.vue'
|
||||
import ServerInfoLabels from './ServerInfoLabels.vue'
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-8 overflow-x-hidden rounded-3xl bg-bg-raised p-4">
|
||||
<div class="relative grid place-content-center">
|
||||
<img
|
||||
no-shadow
|
||||
size="lg"
|
||||
alt="Server Icon"
|
||||
class="size-[96px] rounded-xl bg-bg-raised opacity-50"
|
||||
src="~/assets/images/servers/minecraft_server_icon.png"
|
||||
/>
|
||||
<div class="absolute inset-0 grid place-content-center">
|
||||
<LoadingIcon class="size-8 animate-spin text-contrast" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="m-0 text-contrast">Your new server is being prepared.</h2>
|
||||
<p class="m-0">It'll appear here once it's ready.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||
</script>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<div class="flex h-full flex-col items-center justify-center gap-8">
|
||||
<img
|
||||
src="https://cdn.modrinth.com/servers/excitement.webp"
|
||||
alt=""
|
||||
class="max-w-[360px]"
|
||||
style="mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%)"
|
||||
/>
|
||||
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
|
||||
<p class="m-0">Modrinth Servers is a new way to play modded Minecraft with your friends.</p>
|
||||
<ButtonStyled size="large" type="standard" color="brand">
|
||||
<NuxtLink to="/servers#plan">Create a Server</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
</script>
|
||||
@@ -1,270 +1,24 @@
|
||||
<template>
|
||||
<ModrinthServersPurchaseModal
|
||||
v-if="customer"
|
||||
ref="purchaseModal"
|
||||
:publishable-key="config.public.stripePublishableKey"
|
||||
:initiate-payment="async (body) => await initiatePayment(body)"
|
||||
:available-products="pyroProducts"
|
||||
:on-error="handleError"
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods"
|
||||
:currency="selectedCurrency"
|
||||
:return-url="`${config.public.siteUrl}/servers/manage`"
|
||||
:pings="regionPings"
|
||||
:regions="regions"
|
||||
:refresh-payment-methods="fetchPaymentData"
|
||||
:fetch-stock="fetchStock"
|
||||
:plan-stage="true"
|
||||
:existing-plan="currentPlanFromSubscription"
|
||||
:existing-subscription="subscription || undefined"
|
||||
:on-finalize-no-payment-change="finalizeDowngrade"
|
||||
@hide="
|
||||
() => {
|
||||
subscription = null
|
||||
}
|
||||
"
|
||||
<ServersUpgradeModalWrapperBase
|
||||
ref="wrapperRef"
|
||||
:stripe-publishable-key="config.public.stripePublishableKey"
|
||||
:site-url="config.public.siteUrl"
|
||||
:products="generatedState.products || []"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { injectNotificationManager, ModrinthServersPurchaseModal } from '@modrinth/ui'
|
||||
import type { ServerPlan } from '@modrinth/ui/src/utils/billing'
|
||||
import type { UserSubscription } from '@modrinth/utils'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
// TODO: Remove this wrapper when we figure out how to do cross platform state + stripe
|
||||
import { ServersUpgradeModalWrapper as ServersUpgradeModalWrapperBase } from '@modrinth/ui'
|
||||
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
import { products } from '~/generated/state.json'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
|
||||
const customer = ref<any>(null)
|
||||
const paymentMethods = ref<any[]>([])
|
||||
const selectedCurrency = ref<string>('USD')
|
||||
const regions = ref<any[]>([])
|
||||
const regionPings = ref<any[]>([])
|
||||
const generatedState = useGeneratedState()
|
||||
|
||||
const pyroProducts = (products as any[])
|
||||
.filter((p) => p?.metadata?.type === 'pyro')
|
||||
.sort((a, b) => (a?.metadata?.ram ?? 0) - (b?.metadata?.ram ?? 0))
|
||||
|
||||
function handleError(err: any) {
|
||||
console.error('Purchase modal error:', err)
|
||||
}
|
||||
|
||||
async function fetchPaymentData() {
|
||||
try {
|
||||
const [customerData, paymentMethodsData] = await Promise.all([
|
||||
useBaseFetch('billing/customer', { internal: true }),
|
||||
useBaseFetch('billing/payment_methods', { internal: true }),
|
||||
])
|
||||
customer.value = customerData as any
|
||||
paymentMethods.value = paymentMethodsData as any[]
|
||||
} catch (error) {
|
||||
console.error('Error fetching payment data:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function fetchStock(region: any, request: any) {
|
||||
return useServersFetch(`stock?region=${region.shortcode}`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...request,
|
||||
},
|
||||
bypassAuth: true,
|
||||
}).then((res: any) => res.available as number)
|
||||
}
|
||||
|
||||
function pingRegions() {
|
||||
useServersFetch('regions', {
|
||||
method: 'GET',
|
||||
version: 1,
|
||||
bypassAuth: true,
|
||||
}).then((res: any) => {
|
||||
regions.value = res as any[]
|
||||
;(regions.value as any[]).forEach((region: any) => {
|
||||
runPingTest(region)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const PING_COUNT = 20
|
||||
const PING_INTERVAL = 200
|
||||
const MAX_PING_TIME = 1000
|
||||
|
||||
function runPingTest(region: any, index = 1) {
|
||||
if (index > 10) {
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: -1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
|
||||
try {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const pings: number[] = []
|
||||
|
||||
socket.onopen = () => {
|
||||
for (let i = 0; i < PING_COUNT; i++) {
|
||||
setTimeout(() => {
|
||||
socket.send(String(performance.now()))
|
||||
}, i * PING_INTERVAL)
|
||||
}
|
||||
setTimeout(
|
||||
() => {
|
||||
socket.close()
|
||||
const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
|
||||
if (median) {
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: median,
|
||||
})
|
||||
}
|
||||
},
|
||||
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const start = Number(event.data)
|
||||
pings.push(performance.now() - start)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
runPingTest(region, index + 1)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = ref<UserSubscription | null>(null)
|
||||
// Dry run state
|
||||
const dryRunResponse = ref<{
|
||||
requires_payment: boolean
|
||||
required_payment_is_proration: boolean
|
||||
} | null>(null)
|
||||
const pendingDowngradeBody = ref<any | null>(null)
|
||||
const currentPlanFromSubscription = computed<ServerPlan | undefined>(() => {
|
||||
return subscription.value
|
||||
? (pyroProducts.find(
|
||||
(p) =>
|
||||
p.prices.filter((price: { id: string }) => price.id === subscription.value?.price_id)
|
||||
.length > 0,
|
||||
) ?? undefined)
|
||||
: undefined
|
||||
})
|
||||
|
||||
const currentInterval = computed(() => {
|
||||
const interval = subscription.value?.interval
|
||||
|
||||
if (interval === 'monthly' || interval === 'quarterly') {
|
||||
return interval
|
||||
}
|
||||
return 'monthly'
|
||||
})
|
||||
|
||||
async function initiatePayment(body: any): Promise<any> {
|
||||
if (subscription.value) {
|
||||
const transformedBody = {
|
||||
interval: body.charge?.interval,
|
||||
payment_method: body.type === 'confirmation_token' ? body.token : body.id,
|
||||
product: body.charge?.product_id,
|
||||
region: body.metadata?.server_region,
|
||||
}
|
||||
|
||||
try {
|
||||
const dry = await useBaseFetch(`billing/subscription/${subscription.value.id}?dry=true`, {
|
||||
internal: true,
|
||||
method: 'PATCH',
|
||||
body: transformedBody,
|
||||
})
|
||||
|
||||
if (dry && typeof dry === 'object' && 'requires_payment' in dry) {
|
||||
dryRunResponse.value = dry as any
|
||||
pendingDowngradeBody.value = transformedBody
|
||||
if (dry.requires_payment) {
|
||||
return await finalizeImmediate(transformedBody)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// Fallback if dry run not supported
|
||||
return await finalizeImmediate(transformedBody)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Dry run failed, attempting immediate patch', e)
|
||||
return await finalizeImmediate(transformedBody)
|
||||
}
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Unable to determine subscription ID.',
|
||||
text: 'Please contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
return Promise.reject(new Error('Unable to determine subscription ID.'))
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeImmediate(body: any) {
|
||||
const result = await useBaseFetch(`billing/subscription/${subscription.value?.id}`, {
|
||||
internal: true,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async function finalizeDowngrade() {
|
||||
if (!subscription.value || !pendingDowngradeBody.value) return
|
||||
try {
|
||||
await finalizeImmediate(pendingDowngradeBody.value)
|
||||
addNotification({
|
||||
title: 'Subscription updated',
|
||||
text: 'Your plan has been downgraded and will take effect next billing cycle.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: 'Failed to apply subscription changes',
|
||||
text: 'Please try again or contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
throw e
|
||||
} finally {
|
||||
dryRunResponse.value = null
|
||||
pendingDowngradeBody.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function open(id?: string) {
|
||||
if (id) {
|
||||
const subscriptions = (await useBaseFetch(`billing/subscriptions`, {
|
||||
internal: true,
|
||||
})) as any[]
|
||||
for (const sub of subscriptions) {
|
||||
if (sub?.metadata?.id === id) {
|
||||
subscription.value = sub
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
subscription.value = null
|
||||
}
|
||||
|
||||
purchaseModal.value?.show(currentInterval.value)
|
||||
}
|
||||
const wrapperRef = ref<InstanceType<typeof ServersUpgradeModalWrapperBase> | null>(null)
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
fetchPaymentData()
|
||||
pingRegions()
|
||||
open: (id?: string) => wrapperRef.value?.open(id),
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -31,10 +31,9 @@
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ExternalIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { ButtonStyled, MedalBackgroundImage } from '@modrinth/ui'
|
||||
|
||||
import MedalIcon from '~/assets/images/illustrations/medal_icon.svg?component'
|
||||
import MedalBackgroundImage from '~/components/ui/servers/marketing/MedalBackgroundImage.vue'
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -39,14 +39,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClockIcon, RocketIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { ButtonStyled, MedalBackgroundImage } from '@modrinth/ui'
|
||||
import type { UserSubscription } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjsDuration from 'dayjs/plugin/duration'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import MedalBackgroundImage from '~/components/ui/servers/marketing/MedalBackgroundImage.vue'
|
||||
|
||||
import ServersUpgradeModalWrapper from '../ServersUpgradeModalWrapper.vue'
|
||||
|
||||
dayjs.extend(dayjsDuration)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ISO3166, Labrinth } from '@modrinth/api-client'
|
||||
|
||||
import generatedState from '~/generated/state.json'
|
||||
|
||||
export interface ProjectType {
|
||||
@@ -15,38 +17,12 @@ export interface LoaderData {
|
||||
hiddenModLoaders: string[]
|
||||
}
|
||||
|
||||
export interface Country {
|
||||
alpha2: string
|
||||
alpha3: string
|
||||
numeric: string
|
||||
nameShort: string
|
||||
nameLong: string
|
||||
}
|
||||
|
||||
export interface Subdivision {
|
||||
code: string // Full ISO 3166-2 code (e.g., "US-NY")
|
||||
name: string // Official name in local language
|
||||
localVariant: string | null // English variant if different
|
||||
category: string // STATE, PROVINCE, REGION, etc.
|
||||
parent: string | null // Parent subdivision code
|
||||
language: string // Language code
|
||||
}
|
||||
|
||||
export interface GeneratedState {
|
||||
categories: any[]
|
||||
loaders: any[]
|
||||
gameVersions: any[]
|
||||
donationPlatforms: any[]
|
||||
reportTypes: any[]
|
||||
muralBankDetails: Record<
|
||||
string,
|
||||
{
|
||||
bankNames: string[]
|
||||
}
|
||||
>
|
||||
countries: Country[]
|
||||
subdivisions: Record<string, Subdivision[]>
|
||||
// Re-export types from api-client for convenience
|
||||
export type Country = ISO3166.Country
|
||||
export type Subdivision = ISO3166.Subdivision
|
||||
|
||||
export interface GeneratedState extends Labrinth.State.GeneratedState {
|
||||
// Additional runtime-defined fields not from the API
|
||||
projectTypes: ProjectType[]
|
||||
loaderData: LoaderData
|
||||
projectViewModes: string[]
|
||||
@@ -54,15 +30,9 @@ export interface GeneratedState {
|
||||
rejectedStatuses: string[]
|
||||
staffRoles: string[]
|
||||
|
||||
homePageProjects?: any[]
|
||||
homePageSearch?: any
|
||||
homePageNotifs?: any
|
||||
products?: any[]
|
||||
|
||||
// Metadata
|
||||
lastGenerated?: string
|
||||
apiUrl?: string
|
||||
errors?: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,14 +41,18 @@ export interface GeneratedState {
|
||||
*/
|
||||
export const useGeneratedState = () =>
|
||||
useState<GeneratedState>('generatedState', () => ({
|
||||
categories: generatedState.categories ?? [],
|
||||
loaders: generatedState.loaders ?? [],
|
||||
gameVersions: generatedState.gameVersions ?? [],
|
||||
donationPlatforms: generatedState.donationPlatforms ?? [],
|
||||
reportTypes: generatedState.reportTypes ?? [],
|
||||
muralBankDetails: generatedState.muralBankDetails ?? null,
|
||||
countries: generatedState.countries ?? [],
|
||||
subdivisions: generatedState.subdivisions ?? {},
|
||||
// Cast JSON data to typed API responses
|
||||
categories: (generatedState.categories ?? []) as Labrinth.Tags.v2.Category[],
|
||||
loaders: (generatedState.loaders ?? []) as Labrinth.Tags.v2.Loader[],
|
||||
gameVersions: (generatedState.gameVersions ?? []) as Labrinth.Tags.v2.GameVersion[],
|
||||
donationPlatforms: (generatedState.donationPlatforms ??
|
||||
[]) as Labrinth.Tags.v2.DonationPlatform[],
|
||||
reportTypes: (generatedState.reportTypes ?? []) as string[],
|
||||
muralBankDetails: generatedState.muralBankDetails as
|
||||
| Record<string, { bankNames: string[] }>
|
||||
| undefined,
|
||||
countries: (generatedState.countries ?? []) as ISO3166.Country[],
|
||||
subdivisions: (generatedState.subdivisions ?? {}) as Record<string, ISO3166.Subdivision[]>,
|
||||
|
||||
projectTypes: [
|
||||
{
|
||||
@@ -135,10 +109,12 @@ export const useGeneratedState = () =>
|
||||
rejectedStatuses: ['rejected', 'withheld'],
|
||||
staffRoles: ['moderator', 'admin'],
|
||||
|
||||
homePageProjects: generatedState.homePageProjects,
|
||||
homePageSearch: generatedState.homePageSearch,
|
||||
homePageNotifs: generatedState.homePageNotifs,
|
||||
products: generatedState.products,
|
||||
homePageProjects: generatedState.homePageProjects as unknown as
|
||||
| Labrinth.Projects.v2.Project[]
|
||||
| undefined,
|
||||
homePageSearch: generatedState.homePageSearch as Labrinth.Search.v2.SearchResults | undefined,
|
||||
homePageNotifs: generatedState.homePageNotifs as Labrinth.Search.v2.SearchResults | undefined,
|
||||
products: generatedState.products as Labrinth.Billing.Internal.Product[] | undefined,
|
||||
|
||||
lastGenerated: generatedState.lastGenerated,
|
||||
apiUrl: generatedState.apiUrl,
|
||||
|
||||
@@ -52,14 +52,14 @@
|
||||
|
||||
<script setup>
|
||||
import { SadRinthbot } from '@modrinth/assets'
|
||||
import { NotificationPanel, provideNotificationManager } from '@modrinth/ui'
|
||||
import { NotificationPanel, provideModrinthClient, provideNotificationManager } from '@modrinth/ui'
|
||||
import { defineMessage, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
|
||||
import Logo404 from '~/assets/images/404.svg'
|
||||
|
||||
import ModrinthLoadingIndicator from './components/ui/modrinth-loading-indicator.ts'
|
||||
import { createModrinthClient, provideModrinthClient } from './providers/api-client.ts'
|
||||
import { createModrinthClient } from './helpers/api.ts'
|
||||
import { FrontendNotificationManager } from './providers/frontend-notifications.ts'
|
||||
|
||||
const auth = await useAuth()
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { AbstractFeature, AuthConfig, NuxtClientConfig } from '@modrinth/api-client'
|
||||
import {
|
||||
type AbstractFeature,
|
||||
type AuthConfig,
|
||||
AuthFeature,
|
||||
CircuitBreakerFeature,
|
||||
NuxtCircuitBreakerStorage,
|
||||
type NuxtClientConfig,
|
||||
NuxtModrinthClient,
|
||||
VerboseLoggingFeature,
|
||||
} from '@modrinth/api-client'
|
||||
import { createContext } from '@modrinth/ui'
|
||||
|
||||
export function createModrinthClient(
|
||||
auth: { token: string | undefined },
|
||||
@@ -35,8 +36,3 @@ export function createModrinthClient(
|
||||
|
||||
return new NuxtModrinthClient(clientConfig)
|
||||
}
|
||||
|
||||
export const [injectModrinthClient, provideModrinthClient] = createContext<NuxtModrinthClient>(
|
||||
'root',
|
||||
'modrinthClient',
|
||||
)
|
||||
@@ -3,6 +3,7 @@ import { CheckIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
commonProjectSettingsMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
ProjectSettingsEnvSelector,
|
||||
@@ -11,8 +12,6 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
|
||||
import { injectModrinthClient } from '~/providers/api-client.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { currentMember, projectV2, projectV3, refreshProject } = injectProjectPageContext()
|
||||
@@ -28,7 +27,7 @@ const supportsEnvironment = computed(() =>
|
||||
const needsToVerify = computed(
|
||||
() =>
|
||||
projectV3.value.side_types_migration_review_status === 'pending' &&
|
||||
projectV3.value.environment?.length > 0 &&
|
||||
(projectV3.value.environment?.length ?? 0) > 0 &&
|
||||
projectV3.value.environment?.[0] !== 'unknown' &&
|
||||
supportsEnvironment.value,
|
||||
)
|
||||
@@ -157,12 +156,12 @@ const messages = defineMessages({
|
||||
/>
|
||||
<ProjectSettingsEnvSelector
|
||||
v-model="current.environment"
|
||||
:disabled="!hasPermission || projectV3?.environment?.length > 1"
|
||||
:disabled="!hasPermission || (projectV3?.environment?.length ?? 0) > 1"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<UnsavedChangesPopup
|
||||
v-if="supportsEnvironment && hasPermission && projectV3?.environment?.length <= 1"
|
||||
v-if="supportsEnvironment && hasPermission && (projectV3?.environment?.length ?? 0) <= 1"
|
||||
:original="saved"
|
||||
:modified="current"
|
||||
:saving="saving"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
IconSelect,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
injectProjectPageContext,
|
||||
SettingsLabel,
|
||||
@@ -9,8 +10,6 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { defineMessages, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
|
||||
import { injectModrinthClient } from '~/providers/api-client.ts'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { projectV2: project, refreshProject } = injectProjectPageContext()
|
||||
|
||||
@@ -377,6 +377,8 @@ import {
|
||||
ButtonStyled,
|
||||
ErrorInformationCard,
|
||||
injectNotificationManager,
|
||||
ServerIcon,
|
||||
ServerInfoLabels,
|
||||
ServerNotice,
|
||||
} from '@modrinth/ui'
|
||||
import type {
|
||||
@@ -398,8 +400,6 @@ import InstallingTicker from '~/components/ui/servers/InstallingTicker.vue'
|
||||
import MedalServerCountdown from '~/components/ui/servers/marketing/MedalServerCountdown.vue'
|
||||
import PanelServerActionButton from '~/components/ui/servers/PanelServerActionButton.vue'
|
||||
import PanelSpinner from '~/components/ui/servers/PanelSpinner.vue'
|
||||
import ServerIcon from '~/components/ui/servers/ServerIcon.vue'
|
||||
import ServerInfoLabels from '~/components/ui/servers/ServerInfoLabels.vue'
|
||||
import ServerInstallation from '~/components/ui/servers/ServerInstallation.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
@@ -112,12 +112,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, TransferIcon } from '@modrinth/assets'
|
||||
import { EditIcon, ServerIcon, TransferIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
|
||||
|
||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||
import ServerIcon from '~/components/ui/servers/ServerIcon.vue'
|
||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
@@ -1,133 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-list-root
|
||||
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||
>
|
||||
<ServersUpgradeModalWrapper ref="upgradeModal" />
|
||||
<div
|
||||
v-if="hasError || fetchError"
|
||||
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<HammerIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
|
||||
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
|
||||
<li>
|
||||
Our systems automatically alert our team when there's an issue. We are already working
|
||||
on getting them back online.
|
||||
</li>
|
||||
<li>
|
||||
If you recently purchased your Modrinth Server, it is currently in a queue and will
|
||||
appear here as soon as it's ready. <br />
|
||||
<span class="font-medium text-contrast"
|
||||
>Do not attempt to purchase a new server.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
If you require personalized support regarding the status of your server, please
|
||||
contact Modrinth Support.
|
||||
</li>
|
||||
|
||||
<li v-if="fetchError" class="text-red">
|
||||
<p>Error details:</p>
|
||||
<CopyCode
|
||||
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
|
||||
:copyable="false"
|
||||
:selectable="false"
|
||||
:language="'json'"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ButtonStyled size="large" type="standard" color="brand">
|
||||
<a class="mt-6 !w-full" href="https://support.modrinth.com">Contact Modrinth Support</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" @click="() => reloadNuxtApp()">
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ServerManageEmptyState
|
||||
v-else-if="serverList.length === 0 && !isPollingForNewServers && !hasError"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
|
||||
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
|
||||
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
|
||||
<div class="relative w-full text-sm md:w-72">
|
||||
<label class="sr-only" for="search">Search</label>
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<input
|
||||
id="search"
|
||||
v-model="searchInput"
|
||||
class="w-full border-[1px] border-solid border-button-border pl-9"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
placeholder="Search servers..."
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled type="standard">
|
||||
<NuxtLink
|
||||
class="!h-10 whitespace-pre !border-[1px] !border-solid !border-button-border text-sm !font-medium"
|
||||
:to="{ path: '/servers', hash: '#plan' }"
|
||||
>
|
||||
<PlusIcon class="size-4" />
|
||||
New server
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||
class="m-0 flex flex-col gap-4 p-0"
|
||||
>
|
||||
<MedalServerListing
|
||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
@upgrade="openUpgradeModal(server.server_id)"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
/>
|
||||
</ul>
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<p class="text-contrast">No servers found.</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HammerIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, CopyCode } from '@modrinth/ui'
|
||||
import type { ModrinthServersFetchError, Server } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { ServersManagePageIndex } from '@modrinth/ui'
|
||||
|
||||
import { reloadNuxtApp } from '#app'
|
||||
import MedalServerListing from '~/components/ui/servers/marketing/MedalServerListing.vue'
|
||||
import ServerListing from '~/components/ui/servers/ServerListing.vue'
|
||||
import ServerManageEmptyState from '~/components/ui/servers/ServerManageEmptyState.vue'
|
||||
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
@@ -137,121 +11,14 @@ useHead({
|
||||
title: 'Servers - Modrinth',
|
||||
})
|
||||
|
||||
interface ServerResponse {
|
||||
servers: Server[]
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const hasError = ref(false)
|
||||
const isPollingForNewServers = ref(false)
|
||||
|
||||
const {
|
||||
data: serverResponse,
|
||||
error: fetchError,
|
||||
refresh,
|
||||
} = await useAsyncData<ServerResponse>('ServerList', async () => {
|
||||
const serverResponse = await useServersFetch<ServerResponse>('servers')
|
||||
|
||||
let subscriptions: any[] | undefined
|
||||
|
||||
for (const server of serverResponse.servers) {
|
||||
if (server.is_medal) {
|
||||
// Inject end date into server object.
|
||||
const serverID = server.server_id
|
||||
|
||||
if (!subscriptions) {
|
||||
subscriptions = (await useBaseFetch(`billing/subscriptions`, {
|
||||
internal: true,
|
||||
})) as any[]
|
||||
}
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
if (subscription.metadata?.id === serverID) {
|
||||
server.medal_expires = dayjs(subscription.created as string)
|
||||
.add(5, 'days')
|
||||
.toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return serverResponse
|
||||
})
|
||||
|
||||
watch([fetchError, serverResponse], ([error, response]) => {
|
||||
hasError.value = !!error || !response
|
||||
})
|
||||
|
||||
const serverList = computed<Server[]>(() => {
|
||||
if (!serverResponse.value) return []
|
||||
return serverResponse.value.servers
|
||||
})
|
||||
|
||||
const searchInput = ref('')
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (serverList.value.length === 0) return null
|
||||
return new Fuse(serverList.value, {
|
||||
keys: ['name', 'loader', 'mc_version', 'game', 'state'],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
})
|
||||
})
|
||||
|
||||
function introToTop(array: Server[]): Server[] {
|
||||
return array.slice().sort((a, b) => {
|
||||
return Number(b.flows?.intro) - Number(a.flows?.intro)
|
||||
})
|
||||
}
|
||||
|
||||
const filteredData = computed<Server[]>(() => {
|
||||
if (!searchInput.value.trim()) {
|
||||
return introToTop(serverList.value)
|
||||
}
|
||||
return fuse.value
|
||||
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
|
||||
: []
|
||||
})
|
||||
|
||||
const previousServerList = ref<Server[]>([])
|
||||
const refreshCount = ref(0)
|
||||
|
||||
const checkForNewServers = async () => {
|
||||
await refresh()
|
||||
refreshCount.value += 1
|
||||
if (JSON.stringify(previousServerList.value) !== JSON.stringify(serverList.value)) {
|
||||
isPollingForNewServers.value = false
|
||||
clearInterval(intervalId)
|
||||
router.replace({ query: {} })
|
||||
} else if (refreshCount.value >= 5) {
|
||||
isPollingForNewServers.value = false
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
}
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.redirect_status === 'succeeded') {
|
||||
isPollingForNewServers.value = true
|
||||
previousServerList.value = [...serverList.value]
|
||||
intervalId = setInterval(checkForNewServers, 5000)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
|
||||
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
|
||||
open: (id: string) => void | Promise<void>
|
||||
}>
|
||||
|
||||
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
|
||||
function openUpgradeModal(serverId: string) {
|
||||
upgradeModal.value?.open(serverId)
|
||||
}
|
||||
const config = useRuntimeConfig()
|
||||
const generatedState = useGeneratedState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ServersManagePageIndex
|
||||
:stripe-publishable-key="config.public.stripePublishableKey"
|
||||
:site-url="config.public.siteUrl"
|
||||
:products="generatedState.products || []"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -625,13 +625,13 @@ import {
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
PurchaseModal,
|
||||
ServerListing,
|
||||
} from '@modrinth/ui'
|
||||
import { calculateSavings, formatPrice, getCurrency } from '@modrinth/utils'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useBaseFetch } from '@/composables/fetch.js'
|
||||
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
|
||||
import ServerListing from '~/components/ui/servers/ServerListing.vue'
|
||||
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
|
||||
import { useServersFetch } from '~/composables/servers/servers-fetch.ts'
|
||||
import { products } from '~/generated/state.json'
|
||||
|
||||
28
apps/frontend/src/plugins/tanstack.ts
Normal file
28
apps/frontend/src/plugins/tanstack.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// https://tanstack.com/query/v5/docs/framework/vue/examples/nuxt3
|
||||
import type { DehydratedState, VueQueryPluginOptions } from '@tanstack/vue-query'
|
||||
import { dehydrate, hydrate, QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
|
||||
|
||||
import { defineNuxtPlugin, useState } from '#imports'
|
||||
|
||||
export default defineNuxtPlugin((nuxt) => {
|
||||
const vueQueryState = useState<DehydratedState | null>('vue-query')
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { staleTime: 5000 } },
|
||||
})
|
||||
const options: VueQueryPluginOptions = { queryClient }
|
||||
|
||||
nuxt.vueApp.use(VueQueryPlugin, options)
|
||||
|
||||
if (import.meta.server) {
|
||||
nuxt.hooks.hook('app:rendered', () => {
|
||||
vueQueryState.value = dehydrate(queryClient)
|
||||
})
|
||||
}
|
||||
|
||||
if (import.meta.client) {
|
||||
nuxt.hooks.hook('app:created', () => {
|
||||
hydrate(queryClient, vueQueryState.value)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -23,8 +23,10 @@ export abstract class AbstractModrinthClient {
|
||||
*/
|
||||
private _moduleNamespaces: Map<string, Record<string, AbstractModule>> = new Map()
|
||||
|
||||
// TODO: When adding kyros/archon add readonly fields for those too.
|
||||
public readonly labrinth!: InferredClientModules['labrinth']
|
||||
public readonly archon!: InferredClientModules['archon']
|
||||
public readonly kyros!: InferredClientModules['kyros']
|
||||
public readonly iso3166!: InferredClientModules['iso3166']
|
||||
|
||||
constructor(config: ClientConfig) {
|
||||
this.config = {
|
||||
@@ -171,7 +173,7 @@ export abstract class AbstractModrinthClient {
|
||||
/**
|
||||
* Build the full URL for a request
|
||||
*/
|
||||
protected buildUrl(path: string, baseUrl: string, version: number | 'internal'): string {
|
||||
protected buildUrl(path: string, baseUrl: string, version: number | 'internal' | string): string {
|
||||
// Remove trailing slash from base URL
|
||||
const base = baseUrl.replace(/\/$/, '')
|
||||
|
||||
@@ -181,6 +183,9 @@ export abstract class AbstractModrinthClient {
|
||||
versionPath = '/_internal'
|
||||
} else if (typeof version === 'number') {
|
||||
versionPath = `/v${version}`
|
||||
} else if (typeof version === 'string') {
|
||||
// Custom version string (e.g., 'v0', 'modrinth/v0')
|
||||
versionPath = `/${version}`
|
||||
}
|
||||
|
||||
const cleanPath = path.startsWith('/') ? path : `/${path}`
|
||||
|
||||
@@ -69,6 +69,12 @@ export class AuthFeature extends AbstractFeature {
|
||||
return false
|
||||
}
|
||||
|
||||
// Skip if Authorization header is already explicitly set
|
||||
const headerName = this.config.headerName ?? 'Authorization'
|
||||
if (context.options.headers?.[headerName]) {
|
||||
return false
|
||||
}
|
||||
|
||||
return super.shouldApply(context)
|
||||
}
|
||||
|
||||
|
||||
3
packages/api-client/src/modules/archon/index.ts
Normal file
3
packages/api-client/src/modules/archon/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './servers/types'
|
||||
export * from './servers/v0'
|
||||
export * from './servers/v1'
|
||||
57
packages/api-client/src/modules/archon/servers/v0.ts
Normal file
57
packages/api-client/src/modules/archon/servers/v0.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonServersV0Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_servers_v0'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of servers for the authenticated user
|
||||
* GET /modrinth/v0/servers
|
||||
*/
|
||||
public async list(
|
||||
options?: Archon.Servers.v0.GetServersOptions,
|
||||
): Promise<Archon.Servers.v0.ServerGetResponse> {
|
||||
const params = new URLSearchParams()
|
||||
if (options?.limit) params.set('limit', options.limit.toString())
|
||||
if (options?.offset) params.set('offset', options.offset.toString())
|
||||
|
||||
const query = params.toString() ? `?${params.toString()}` : ''
|
||||
|
||||
return this.client.request<Archon.Servers.v0.ServerGetResponse>(`servers${query}`, {
|
||||
api: 'archon',
|
||||
method: 'GET',
|
||||
version: 'modrinth/v0',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Check stock availability for a region
|
||||
* POST /modrinth/v0/stock
|
||||
*/
|
||||
public async checkStock(
|
||||
region: string,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
): Promise<Archon.Servers.v0.StockResponse> {
|
||||
return this.client.request<Archon.Servers.v0.StockResponse>(`/stock?region=${region}`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get filesystem authentication credentials for a server
|
||||
* Returns URL and JWT token for accessing the server's filesystem via Kyros
|
||||
* GET /modrinth/v0/servers/:id/fs
|
||||
*/
|
||||
public async getFilesystemAuth(serverId: string): Promise<Archon.Servers.v0.JWTAuth> {
|
||||
return this.client.request<Archon.Servers.v0.JWTAuth>(`/servers/${serverId}/fs`, {
|
||||
api: 'archon',
|
||||
version: 'modrinth/v0',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
}
|
||||
20
packages/api-client/src/modules/archon/servers/v1.ts
Normal file
20
packages/api-client/src/modules/archon/servers/v1.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Archon } from '../types'
|
||||
|
||||
export class ArchonServersV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'archon_servers_v1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available regions
|
||||
* GET /v1/regions
|
||||
*/
|
||||
public async getRegions(): Promise<Archon.Servers.v1.Region[]> {
|
||||
return this.client.request<Archon.Servers.v1.Region[]>('/regions', {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
}
|
||||
128
packages/api-client/src/modules/archon/types.ts
Normal file
128
packages/api-client/src/modules/archon/types.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export namespace Archon {
|
||||
export namespace Servers {
|
||||
export namespace v0 {
|
||||
export type ServerGetResponse = {
|
||||
servers: Server[]
|
||||
pagination: Pagination
|
||||
}
|
||||
|
||||
export type Pagination = {
|
||||
current_page: number
|
||||
page_size: number
|
||||
total_pages: number
|
||||
total_items: number
|
||||
}
|
||||
|
||||
export type Status = 'installing' | 'broken' | 'available' | 'suspended'
|
||||
|
||||
export type SuspensionReason =
|
||||
| 'moderated'
|
||||
| 'paymentfailed'
|
||||
| 'cancelled'
|
||||
| 'upgrading'
|
||||
| 'other'
|
||||
|
||||
export type Loader =
|
||||
| 'Forge'
|
||||
| 'NeoForge'
|
||||
| 'Fabric'
|
||||
| 'Quilt'
|
||||
| 'Purpur'
|
||||
| 'Spigot'
|
||||
| 'Vanilla'
|
||||
| 'Paper'
|
||||
|
||||
export type Game = 'Minecraft'
|
||||
|
||||
export type UpstreamKind = 'modpack' | 'none'
|
||||
|
||||
export type Server = {
|
||||
server_id: string
|
||||
name: string
|
||||
owner_id: string
|
||||
net: Net
|
||||
game: Game
|
||||
backup_quota: number
|
||||
used_backup_quota: number
|
||||
status: Status
|
||||
suspension_reason: SuspensionReason | null
|
||||
loader: Loader | null
|
||||
loader_version: string | null
|
||||
mc_version: string | null
|
||||
upstream: Upstream | null
|
||||
sftp_username: string
|
||||
sftp_password: string
|
||||
sftp_host: string
|
||||
datacenter: string
|
||||
notices: Notice[]
|
||||
node: NodeInfo | null
|
||||
flows: Flows
|
||||
is_medal: boolean
|
||||
|
||||
medal_expires?: string
|
||||
}
|
||||
|
||||
export type Net = {
|
||||
ip: string
|
||||
port: number
|
||||
domain: string
|
||||
}
|
||||
|
||||
export type Upstream = {
|
||||
kind: UpstreamKind
|
||||
version_id: string
|
||||
project_id: string
|
||||
}
|
||||
|
||||
export type Notice = {
|
||||
id: number
|
||||
dismissable: boolean
|
||||
title: string
|
||||
message: string
|
||||
level: string
|
||||
announced: string
|
||||
}
|
||||
|
||||
export type NodeInfo = {
|
||||
token: string
|
||||
instance: string
|
||||
}
|
||||
|
||||
export type Flows = {
|
||||
intro: boolean
|
||||
}
|
||||
|
||||
export type GetServersOptions = {
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export type StockRequest = {
|
||||
cpu?: number
|
||||
memory_mb?: number
|
||||
swap_mb?: number
|
||||
storage_mb?: number
|
||||
}
|
||||
|
||||
export type StockResponse = {
|
||||
available: number
|
||||
}
|
||||
|
||||
export type JWTAuth = {
|
||||
url: string // e.g., "node-xyz.modrinth.com/modrinth/v0/fs"
|
||||
token: string // JWT token for filesystem access
|
||||
}
|
||||
}
|
||||
|
||||
export namespace v1 {
|
||||
export type Region = {
|
||||
shortcode: string
|
||||
country_code: string
|
||||
display_name: string
|
||||
lat: number
|
||||
lon: number
|
||||
zone: string
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { AbstractModrinthClient } from '../core/abstract-client'
|
||||
import type { AbstractModule } from '../core/abstract-module'
|
||||
import { ArchonServersV0Module } from './archon/servers/v0'
|
||||
import { ArchonServersV1Module } from './archon/servers/v1'
|
||||
import { ISO3166Module } from './iso3166'
|
||||
import { KyrosFilesV0Module } from './kyros/files/v0'
|
||||
import { LabrinthBillingInternalModule } from './labrinth/billing/internal'
|
||||
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
|
||||
import { LabrinthProjectsV3Module } from './labrinth/projects/v3'
|
||||
import { LabrinthStateModule } from './labrinth/state'
|
||||
|
||||
type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
|
||||
|
||||
@@ -15,8 +21,14 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
|
||||
* TODO: Better way? Probably not
|
||||
*/
|
||||
export const MODULE_REGISTRY = {
|
||||
archon_servers_v0: ArchonServersV0Module,
|
||||
archon_servers_v1: ArchonServersV1Module,
|
||||
iso3166_data: ISO3166Module,
|
||||
kyros_files_v0: KyrosFilesV0Module,
|
||||
labrinth_billing_internal: LabrinthBillingInternalModule,
|
||||
labrinth_projects_v2: LabrinthProjectsV2Module,
|
||||
labrinth_projects_v3: LabrinthProjectsV3Module,
|
||||
labrinth_state: LabrinthStateModule,
|
||||
} as const satisfies Record<string, ModuleConstructor>
|
||||
|
||||
export type ModuleID = keyof typeof MODULE_REGISTRY
|
||||
|
||||
121
packages/api-client/src/modules/iso3166/index.ts
Normal file
121
packages/api-client/src/modules/iso3166/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
import { AbstractModule } from '../../core/abstract-module'
|
||||
import type { ISO3166 } from './types'
|
||||
|
||||
export type { ISO3166 } from './types'
|
||||
|
||||
const ISO3166_REPO = 'https://raw.githubusercontent.com/ipregistry/iso3166/master'
|
||||
|
||||
/**
|
||||
* Parse CSV string into array of objects
|
||||
* @param csv - CSV string to parse
|
||||
* @returns Array of objects with header keys mapped to row values
|
||||
*/
|
||||
function parseCSV(csv: string): Record<string, string>[] {
|
||||
const lines = csv
|
||||
.trim()
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
|
||||
if (lines.length === 0) return []
|
||||
|
||||
const headerLine = lines[0]
|
||||
const headers = (headerLine.startsWith('#') ? headerLine.slice(1) : headerLine).split(',')
|
||||
|
||||
return lines.slice(1).map((line) => {
|
||||
const values = line.split(',')
|
||||
const row: Record<string, string> = {}
|
||||
|
||||
headers.forEach((header, index) => {
|
||||
row[header] = values[index] || ''
|
||||
})
|
||||
|
||||
return row
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Module for fetching ISO 3166 country and subdivision data
|
||||
* Data from https://github.com/ipregistry/iso3166 (Licensed under CC BY-SA 4.0)
|
||||
* @platform Not for use in Tauri or Nuxt environments, only node.
|
||||
*/
|
||||
export class ISO3166Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'iso3166_data'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build ISO 3166 country and subdivision data from the ipregistry repository
|
||||
*
|
||||
* @returns Promise resolving to countries and subdivisions data
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const data = await client.iso3166.data.build()
|
||||
* console.log(data.countries) // Array of country data
|
||||
* console.log(data.subdivisions['US']) // Array of US state data
|
||||
* ```
|
||||
*/
|
||||
public async build(): Promise<ISO3166.State> {
|
||||
try {
|
||||
const [countriesCSV, subdivisionsCSV] = await Promise.all([
|
||||
$fetch<string>(`${ISO3166_REPO}/countries.csv`, {
|
||||
// @ts-expect-error supports text
|
||||
responseType: 'text',
|
||||
}),
|
||||
$fetch<string>(`${ISO3166_REPO}/subdivisions.csv`, {
|
||||
// @ts-expect-error supports text
|
||||
responseType: 'text',
|
||||
}),
|
||||
])
|
||||
|
||||
const countriesData = parseCSV(countriesCSV)
|
||||
const subdivisionsData = parseCSV(subdivisionsCSV)
|
||||
|
||||
const countries: ISO3166.Country[] = countriesData.map((c) => ({
|
||||
alpha2: c.country_code_alpha2,
|
||||
alpha3: c.country_code_alpha3,
|
||||
numeric: c.numeric_code,
|
||||
nameShort: c.name_short,
|
||||
nameLong: c.name_long,
|
||||
}))
|
||||
|
||||
// Group subdivisions by country code
|
||||
const subdivisions: Record<string, ISO3166.Subdivision[]> = subdivisionsData.reduce(
|
||||
(acc, sub) => {
|
||||
const countryCode = sub.country_code_alpha2
|
||||
|
||||
if (!countryCode || typeof countryCode !== 'string' || countryCode.trim() === '') {
|
||||
return acc
|
||||
}
|
||||
|
||||
if (!acc[countryCode]) acc[countryCode] = []
|
||||
|
||||
acc[countryCode].push({
|
||||
code: sub['subdivision_code_iso3166-2'],
|
||||
name: sub.subdivision_name,
|
||||
localVariant: sub.localVariant || null,
|
||||
category: sub.category,
|
||||
parent: sub.parent_subdivision || null,
|
||||
language: sub.language_code,
|
||||
})
|
||||
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, ISO3166.Subdivision[]>,
|
||||
)
|
||||
|
||||
return {
|
||||
countries,
|
||||
subdivisions,
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching ISO3166 data:', err)
|
||||
return {
|
||||
countries: [],
|
||||
subdivisions: {},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/api-client/src/modules/iso3166/types.ts
Normal file
23
packages/api-client/src/modules/iso3166/types.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export namespace ISO3166 {
|
||||
export interface Country {
|
||||
alpha2: string
|
||||
alpha3: string
|
||||
numeric: string
|
||||
nameShort: string
|
||||
nameLong: string
|
||||
}
|
||||
|
||||
export interface Subdivision {
|
||||
code: string // Full ISO 3166-2 code (e.g., "US-NY")
|
||||
name: string // Official name in local language
|
||||
localVariant: string | null // English variant if different
|
||||
category: string // STATE, PROVINCE, REGION, etc.
|
||||
parent: string | null // Parent subdivision code
|
||||
language: string // Language code
|
||||
}
|
||||
|
||||
export interface State {
|
||||
countries: Country[]
|
||||
subdivisions: Record<string, Subdivision[]>
|
||||
}
|
||||
}
|
||||
52
packages/api-client/src/modules/kyros/files/v0.ts
Normal file
52
packages/api-client/src/modules/kyros/files/v0.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
|
||||
export class KyrosFilesV0Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'kyros_files_v0'
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a server's filesystem
|
||||
*
|
||||
* @param nodeInstance - Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs")
|
||||
* @param nodeToken - JWT token from getFilesystemAuth
|
||||
* @param path - File path (e.g., "/server-icon-original.png")
|
||||
* @returns Promise resolving to file Blob
|
||||
*/
|
||||
public async downloadFile(nodeInstance: string, nodeToken: string, path: string): Promise<Blob> {
|
||||
return this.client.request<Blob>(`/fs/download`, {
|
||||
api: `https://${nodeInstance.replace('v0/fs', '')}`,
|
||||
method: 'GET',
|
||||
version: 'v0',
|
||||
params: { path },
|
||||
headers: { Authorization: `Bearer ${nodeToken}` },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to a server's filesystem
|
||||
*
|
||||
* @param nodeInstance - Node instance URL
|
||||
* @param nodeToken - JWT token from getFilesystemAuth
|
||||
* @param path - Destination path (e.g., "/server-icon.png")
|
||||
* @param file - File to upload
|
||||
*/
|
||||
public async uploadFile(
|
||||
nodeInstance: string,
|
||||
nodeToken: string,
|
||||
path: string,
|
||||
file: File,
|
||||
): Promise<void> {
|
||||
return this.client.request<void>(`/fs/create`, {
|
||||
api: `https://${nodeInstance.replace('v0/fs', '')}`,
|
||||
method: 'POST',
|
||||
version: 'v0',
|
||||
params: { path, type: 'file' },
|
||||
headers: {
|
||||
Authorization: `Bearer ${nodeToken}`,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: file,
|
||||
})
|
||||
}
|
||||
}
|
||||
7
packages/api-client/src/modules/kyros/types.ts
Normal file
7
packages/api-client/src/modules/kyros/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export namespace Kyros {
|
||||
export namespace Files {
|
||||
export namespace v0 {
|
||||
// Empty for now
|
||||
}
|
||||
}
|
||||
}
|
||||
189
packages/api-client/src/modules/labrinth/billing/internal.ts
Normal file
189
packages/api-client/src/modules/labrinth/billing/internal.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthBillingInternalModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_billing_internal'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's subscriptions
|
||||
* GET /_internal/billing/subscriptions
|
||||
*/
|
||||
public async getSubscriptions(
|
||||
userId?: string,
|
||||
): Promise<Labrinth.Billing.Internal.UserSubscription[]> {
|
||||
const params = userId ? `?user_id=${userId}` : ''
|
||||
|
||||
return this.client.request<Labrinth.Billing.Internal.UserSubscription[]>(
|
||||
`/billing/subscriptions${params}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available products for purchase
|
||||
* GET /_internal/billing/products
|
||||
*/
|
||||
public async getProducts(): Promise<Labrinth.Billing.Internal.Product[]> {
|
||||
return this.client.request<Labrinth.Billing.Internal.Product[]>('/billing/products', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Stripe customer information
|
||||
* GET /_internal/billing/customer
|
||||
*/
|
||||
public async getCustomer(): Promise<unknown> {
|
||||
return this.client.request<unknown>('/billing/customer', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a subscription (change product, interval, cancel, etc.)
|
||||
* PATCH /_internal/billing/subscription/{id}
|
||||
*/
|
||||
public async editSubscription(
|
||||
id: string,
|
||||
edit: Labrinth.Billing.Internal.EditSubscriptionRequest,
|
||||
dry?: boolean,
|
||||
): Promise<Labrinth.Billing.Internal.EditSubscriptionResponse | void> {
|
||||
const params = dry ? '?dry=true' : ''
|
||||
|
||||
return this.client.request<Labrinth.Billing.Internal.EditSubscriptionResponse | void>(
|
||||
`/billing/subscription/${id}${params}`,
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body: edit,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's payment methods
|
||||
* GET /_internal/billing/payment_methods
|
||||
*/
|
||||
public async getPaymentMethods(): Promise<unknown[]> {
|
||||
return this.client.request<unknown[]>('/billing/payment_methods', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate flow to add a new payment method
|
||||
* POST /_internal/billing/payment_method
|
||||
*/
|
||||
public async addPaymentMethodFlow(): Promise<Labrinth.Billing.Internal.AddPaymentMethodFlowResponse> {
|
||||
return this.client.request<Labrinth.Billing.Internal.AddPaymentMethodFlowResponse>(
|
||||
'/billing/payment_method',
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit a payment method (set as primary)
|
||||
* PATCH /_internal/billing/payment_method/{id}
|
||||
*/
|
||||
public async editPaymentMethod(
|
||||
id: string,
|
||||
body: Labrinth.Billing.Internal.EditPaymentMethodRequest,
|
||||
): Promise<void> {
|
||||
return this.client.request<void>(`/billing/payment_method/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'PATCH',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a payment method
|
||||
* DELETE /_internal/billing/payment_method/{id}
|
||||
*/
|
||||
public async removePaymentMethod(id: string): Promise<void> {
|
||||
return this.client.request<void>(`/billing/payment_method/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment history (charges)
|
||||
* GET /_internal/billing/payments
|
||||
*/
|
||||
public async getPayments(userId?: string): Promise<Labrinth.Billing.Internal.Charge[]> {
|
||||
const params = userId ? `?user_id=${userId}` : ''
|
||||
|
||||
return this.client.request<Labrinth.Billing.Internal.Charge[]>(`/billing/payments${params}`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate a payment
|
||||
* POST /_internal/billing/payment
|
||||
*/
|
||||
public async initiatePayment(
|
||||
request: Labrinth.Billing.Internal.InitiatePaymentRequest,
|
||||
): Promise<Labrinth.Billing.Internal.InitiatePaymentResponse> {
|
||||
return this.client.request<Labrinth.Billing.Internal.InitiatePaymentResponse>(
|
||||
'/billing/payment',
|
||||
{
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Refund a charge (Admin only)
|
||||
* POST /_internal/billing/charge/{id}/refund
|
||||
*/
|
||||
public async refundCharge(
|
||||
id: string,
|
||||
refund: Labrinth.Billing.Internal.RefundChargeRequest,
|
||||
): Promise<void> {
|
||||
return this.client.request<void>(`/billing/charge/${id}/refund`, {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: refund,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit subscriptions (Admin only)
|
||||
* POST /_internal/billing/credit
|
||||
*/
|
||||
public async credit(request: Labrinth.Billing.Internal.CreditRequest): Promise<void> {
|
||||
return this.client.request<void>('/billing/credit', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './billing/internal'
|
||||
export * from './projects/v2'
|
||||
export * from './projects/v3'
|
||||
export * from './state'
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
export type Environment = 'required' | 'optional' | 'unsupported' | 'unknown'
|
||||
|
||||
export type ProjectStatus =
|
||||
| 'approved'
|
||||
| 'archived'
|
||||
| 'rejected'
|
||||
| 'draft'
|
||||
| 'unlisted'
|
||||
| 'processing'
|
||||
| 'withheld'
|
||||
| 'scheduled'
|
||||
| 'private'
|
||||
| 'unknown'
|
||||
|
||||
export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
|
||||
|
||||
export type ProjectType = 'mod' | 'modpack' | 'resourcepack' | 'shader' | 'plugin' | 'datapack'
|
||||
|
||||
export type GalleryImageV2 = {
|
||||
url: string
|
||||
featured: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
created: string
|
||||
ordering: number
|
||||
}
|
||||
|
||||
export type DonationLinkV2 = {
|
||||
id: string
|
||||
platform: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ProjectV2 = {
|
||||
id: string
|
||||
slug: string
|
||||
project_type: ProjectType
|
||||
team: string
|
||||
title: string
|
||||
description: string
|
||||
body: string
|
||||
published: string
|
||||
updated: string
|
||||
approved?: string
|
||||
queued?: string
|
||||
status: ProjectStatus
|
||||
requested_status?: ProjectStatus
|
||||
moderator_message?: {
|
||||
message: string
|
||||
body?: string
|
||||
}
|
||||
license: {
|
||||
id: string
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
downloads: number
|
||||
followers: number
|
||||
categories: string[]
|
||||
additional_categories: string[]
|
||||
game_versions: string[]
|
||||
loaders: string[]
|
||||
versions: string[]
|
||||
icon_url?: string
|
||||
issues_url?: string
|
||||
source_url?: string
|
||||
wiki_url?: string
|
||||
discord_url?: string
|
||||
donation_urls?: DonationLinkV2[]
|
||||
gallery?: GalleryImageV2[]
|
||||
color?: number
|
||||
thread_id: string
|
||||
monetization_status: MonetizationStatus
|
||||
}
|
||||
|
||||
export type SearchResultHit = {
|
||||
project_id: string
|
||||
project_type: ProjectType
|
||||
slug: string
|
||||
author: string
|
||||
title: string
|
||||
description: string
|
||||
categories: string[]
|
||||
display_categories: string[]
|
||||
versions: string[]
|
||||
downloads: number
|
||||
follows: number
|
||||
icon_url: string
|
||||
date_created: string
|
||||
date_modified: string
|
||||
latest_version?: string
|
||||
license: string
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
gallery: string[]
|
||||
color?: number
|
||||
}
|
||||
|
||||
export type SearchResult = {
|
||||
hits: SearchResultHit[]
|
||||
offset: number
|
||||
limit: number
|
||||
total_hits: number
|
||||
}
|
||||
|
||||
export type ProjectSearchParams = {
|
||||
query?: string
|
||||
facets?: string[][]
|
||||
filters?: string
|
||||
index?: 'relevance' | 'downloads' | 'follows' | 'newest' | 'updated'
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import type { MonetizationStatus, ProjectStatus } from './v2'
|
||||
|
||||
export type GalleryItemV3 = {
|
||||
url: string
|
||||
raw_url: string
|
||||
featured: boolean
|
||||
name?: string
|
||||
description?: string
|
||||
created: string
|
||||
ordering: number
|
||||
}
|
||||
|
||||
export type LinkV3 = {
|
||||
platform: string
|
||||
donation: boolean
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ProjectV3 = {
|
||||
id: string
|
||||
slug?: string
|
||||
project_types: string[]
|
||||
games: string[]
|
||||
team_id: string
|
||||
organization?: string
|
||||
name: string
|
||||
summary: string
|
||||
description: string
|
||||
published: string
|
||||
updated: string
|
||||
approved?: string
|
||||
queued?: string
|
||||
status: ProjectStatus
|
||||
requested_status?: ProjectStatus
|
||||
license: {
|
||||
id: string
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
downloads: number
|
||||
followers: number
|
||||
categories: string[]
|
||||
additional_categories: string[]
|
||||
loaders: string[]
|
||||
versions: string[]
|
||||
icon_url?: string
|
||||
link_urls: Record<string, LinkV3>
|
||||
gallery: GalleryItemV3[]
|
||||
color?: number
|
||||
thread_id: string
|
||||
monetization_status: MonetizationStatus
|
||||
side_types_migration_review_status: 'reviewed' | 'pending'
|
||||
[key: string]: unknown
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { ProjectSearchParams, ProjectV2, SearchResult } from './types/v2'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthProjectsV2Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
@@ -18,8 +18,8 @@ export class LabrinthProjectsV2Module extends AbstractModule {
|
||||
* console.log(project.title) // "Sodium"
|
||||
* ```
|
||||
*/
|
||||
public async get(id: string): Promise<ProjectV2> {
|
||||
return this.client.request<ProjectV2>(`/project/${id}`, {
|
||||
public async get(id: string): Promise<Labrinth.Projects.v2.Project> {
|
||||
return this.client.request<Labrinth.Projects.v2.Project>(`/project/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
@@ -37,8 +37,8 @@ export class LabrinthProjectsV2Module extends AbstractModule {
|
||||
* const projects = await client.labrinth.projects_v2.getMultiple(['sodium', 'lithium', 'phosphor'])
|
||||
* ```
|
||||
*/
|
||||
public async getMultiple(ids: string[]): Promise<ProjectV2[]> {
|
||||
return this.client.request<ProjectV2[]>(`/projects`, {
|
||||
public async getMultiple(ids: string[]): Promise<Labrinth.Projects.v2.Project[]> {
|
||||
return this.client.request<Labrinth.Projects.v2.Project[]>(`/projects`, {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
@@ -61,8 +61,10 @@ export class LabrinthProjectsV2Module extends AbstractModule {
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
public async search(params: ProjectSearchParams): Promise<SearchResult> {
|
||||
return this.client.request<SearchResult>(`/search`, {
|
||||
public async search(
|
||||
params: Labrinth.Projects.v2.ProjectSearchParams,
|
||||
): Promise<Labrinth.Projects.v2.SearchResult> {
|
||||
return this.client.request<Labrinth.Projects.v2.SearchResult>(`/search`, {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
@@ -83,7 +85,7 @@ export class LabrinthProjectsV2Module extends AbstractModule {
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
public async edit(id: string, data: Partial<ProjectV2>): Promise<void> {
|
||||
public async edit(id: string, data: Partial<Labrinth.Projects.v2.Project>): Promise<void> {
|
||||
return this.client.request(`/project/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { ProjectV3 } from './types/v3'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthProjectsV3Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
@@ -18,8 +18,8 @@ export class LabrinthProjectsV3Module extends AbstractModule {
|
||||
* console.log(project.project_types) // v3 field
|
||||
* ```
|
||||
*/
|
||||
public async get(id: string): Promise<ProjectV3> {
|
||||
return this.client.request<ProjectV3>(`/project/${id}`, {
|
||||
public async get(id: string): Promise<Labrinth.Projects.v3.Project> {
|
||||
return this.client.request<Labrinth.Projects.v3.Project>(`/project/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
@@ -37,8 +37,8 @@ export class LabrinthProjectsV3Module extends AbstractModule {
|
||||
* const projects = await client.labrinth.projects_v3.getMultiple(['sodium', 'lithium'])
|
||||
* ```
|
||||
*/
|
||||
public async getMultiple(ids: string[]): Promise<ProjectV3[]> {
|
||||
return this.client.request<ProjectV3[]>(`/projects`, {
|
||||
public async getMultiple(ids: string[]): Promise<Labrinth.Projects.v3.Project[]> {
|
||||
return this.client.request<Labrinth.Projects.v3.Project[]>(`/projects`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
@@ -59,7 +59,7 @@ export class LabrinthProjectsV3Module extends AbstractModule {
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
public async edit(id: string, data: Partial<ProjectV3>): Promise<void> {
|
||||
public async edit(id: string, data: Labrinth.Projects.v3.EditProjectRequest): Promise<void> {
|
||||
return this.client.request(`/project/${id}`, {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
|
||||
135
packages/api-client/src/modules/labrinth/state/index.ts
Normal file
135
packages/api-client/src/modules/labrinth/state/index.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { Labrinth } from '../types'
|
||||
|
||||
export class LabrinthStateModule extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'labrinth_state'
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the complete generated state by fetching from multiple endpoints
|
||||
*
|
||||
* @returns Promise resolving to the generated state containing categories, loaders, products, etc.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const state = await client.labrinth.state.build()
|
||||
* console.log(state.products) // Available billing products
|
||||
* ```
|
||||
*/
|
||||
public async build(): Promise<Labrinth.State.GeneratedState> {
|
||||
const errors: unknown[] = []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleError = (err: any, defaultValue: any) => {
|
||||
console.error('Error fetching state data:', err)
|
||||
errors.push(err)
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
// TODO: as we add new modules, move these raw requests to actual
|
||||
// abstractions
|
||||
const [
|
||||
categories,
|
||||
loaders,
|
||||
gameVersions,
|
||||
donationPlatforms,
|
||||
reportTypes,
|
||||
homePageProjects,
|
||||
homePageSearch,
|
||||
homePageNotifs,
|
||||
products,
|
||||
muralBankDetails,
|
||||
iso3166Data,
|
||||
] = await Promise.all([
|
||||
// Tag endpoints
|
||||
this.client
|
||||
.request<Labrinth.Tags.v2.Category[]>('/tag/category', {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
})
|
||||
.catch((err) => handleError(err, [])),
|
||||
this.client
|
||||
.request<Labrinth.Tags.v2.Loader[]>('/tag/loader', {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
})
|
||||
.catch((err) => handleError(err, [])),
|
||||
this.client
|
||||
.request<Labrinth.Tags.v2.GameVersion[]>('/tag/game_version', {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
})
|
||||
.catch((err) => handleError(err, [])),
|
||||
this.client
|
||||
.request<Labrinth.Tags.v2.DonationPlatform[]>('/tag/donation_platform', {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
})
|
||||
.catch((err) => handleError(err, [])),
|
||||
this.client
|
||||
.request<string[]>('/tag/report_type', { api: 'labrinth', version: 2, method: 'GET' })
|
||||
.catch((err) => handleError(err, [])),
|
||||
|
||||
// Homepage data
|
||||
this.client
|
||||
.request<Labrinth.Projects.v2.Project[]>('/projects_random', {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
params: { count: '60' },
|
||||
})
|
||||
.catch((err) => handleError(err, [])),
|
||||
this.client
|
||||
.request<Labrinth.Search.v2.SearchResults>('/search', {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
params: { limit: '3', query: 'leave', index: 'relevance' },
|
||||
})
|
||||
.catch((err) => handleError(err, {} as Labrinth.Search.v2.SearchResults)),
|
||||
this.client
|
||||
.request<Labrinth.Search.v2.SearchResults>('/search', {
|
||||
api: 'labrinth',
|
||||
version: 2,
|
||||
method: 'GET',
|
||||
params: { limit: '3', query: '', index: 'updated' },
|
||||
})
|
||||
.catch((err) => handleError(err, {} as Labrinth.Search.v2.SearchResults)),
|
||||
|
||||
// Internal billing/mural endpoints
|
||||
this.client.labrinth.billing_internal.getProducts().catch((err) => handleError(err, [])),
|
||||
this.client
|
||||
.request<{ bankDetails: Record<string, { bankNames: string[] }> }>('/mural/bank-details', {
|
||||
api: 'labrinth',
|
||||
version: 'internal',
|
||||
method: 'GET',
|
||||
})
|
||||
.catch((err) => handleError(err, null)),
|
||||
|
||||
// ISO3166 country and subdivision data
|
||||
this.client.iso3166.data
|
||||
.build()
|
||||
.catch((err) => handleError(err, { countries: [], subdivisions: {} })),
|
||||
])
|
||||
|
||||
return {
|
||||
categories,
|
||||
loaders,
|
||||
gameVersions,
|
||||
donationPlatforms,
|
||||
reportTypes,
|
||||
homePageProjects,
|
||||
homePageSearch,
|
||||
homePageNotifs,
|
||||
products,
|
||||
muralBankDetails: muralBankDetails?.bankDetails,
|
||||
countries: iso3166Data.countries,
|
||||
subdivisions: iso3166Data.subdivisions,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
453
packages/api-client/src/modules/labrinth/types.ts
Normal file
453
packages/api-client/src/modules/labrinth/types.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import type { ISO3166 } from '../iso3166/types'
|
||||
|
||||
export namespace Labrinth {
|
||||
export namespace Billing {
|
||||
export namespace Internal {
|
||||
export type PriceDuration = 'five-days' | 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
export type SubscriptionStatus = 'provisioned' | 'unprovisioned'
|
||||
|
||||
export type UserSubscription = {
|
||||
id: string
|
||||
user_id: string
|
||||
price_id: string
|
||||
interval: PriceDuration
|
||||
status: SubscriptionStatus
|
||||
created: string // ISO datetime string
|
||||
metadata?: SubscriptionMetadata
|
||||
}
|
||||
|
||||
export type SubscriptionMetadata =
|
||||
| { type: 'pyro'; id: string; region?: string }
|
||||
| { type: 'medal'; id: string }
|
||||
|
||||
export type ChargeStatus =
|
||||
| 'open'
|
||||
| 'processing'
|
||||
| 'succeeded'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'expiring'
|
||||
|
||||
export type ChargeType = 'one-time' | 'subscription' | 'proration' | 'refund'
|
||||
|
||||
export type PaymentPlatform = 'Stripe' | 'None'
|
||||
|
||||
export type Charge = {
|
||||
id: string
|
||||
user_id: string
|
||||
price_id: string
|
||||
amount: number
|
||||
currency_code: string
|
||||
status: ChargeStatus
|
||||
due: string // ISO datetime string
|
||||
last_attempt: string | null // ISO datetime string
|
||||
type: ChargeType
|
||||
subscription_id: string | null
|
||||
subscription_interval: PriceDuration | null
|
||||
platform: PaymentPlatform
|
||||
parent_charge_id: string | null
|
||||
net: number | null
|
||||
}
|
||||
|
||||
export type ProductMetadata =
|
||||
| { type: 'midas' }
|
||||
| {
|
||||
type: 'pyro'
|
||||
cpu: number
|
||||
ram: number
|
||||
swap: number
|
||||
storage: number
|
||||
}
|
||||
| {
|
||||
type: 'medal'
|
||||
cpu: number
|
||||
ram: number
|
||||
swap: number
|
||||
storage: number
|
||||
region: string
|
||||
}
|
||||
|
||||
export type Price =
|
||||
| { type: 'one-time'; price: number }
|
||||
| { type: 'recurring'; intervals: Record<PriceDuration, number> }
|
||||
|
||||
export type ProductPrice = {
|
||||
id: string
|
||||
product_id: string
|
||||
prices: Price
|
||||
currency_code: string
|
||||
}
|
||||
|
||||
export type Product = {
|
||||
id: string
|
||||
metadata: ProductMetadata
|
||||
prices: ProductPrice[]
|
||||
unitary: boolean
|
||||
}
|
||||
|
||||
export type EditSubscriptionRequest = {
|
||||
interval?: PriceDuration
|
||||
payment_method?: string
|
||||
cancelled?: boolean
|
||||
region?: string
|
||||
product?: string
|
||||
}
|
||||
|
||||
export type EditSubscriptionResponse = {
|
||||
payment_intent_id: string
|
||||
client_secret: string
|
||||
tax: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export type AddPaymentMethodFlowResponse = {
|
||||
client_secret: string
|
||||
}
|
||||
|
||||
export type EditPaymentMethodRequest = {
|
||||
primary: boolean
|
||||
}
|
||||
|
||||
export type InitiatePaymentRequest = {
|
||||
type: 'payment_method' | 'confirmation_token'
|
||||
id?: string
|
||||
token?: string
|
||||
charge:
|
||||
| { type: 'existing'; id: string }
|
||||
| { type: 'new'; product_id: string; interval?: PriceDuration }
|
||||
existing_payment_intent?: string
|
||||
metadata?: {
|
||||
type: 'pyro'
|
||||
server_name?: string
|
||||
server_region?: string
|
||||
source: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type InitiatePaymentResponse = {
|
||||
payment_intent_id?: string
|
||||
client_secret?: string
|
||||
price_id: string
|
||||
tax: number
|
||||
total: number
|
||||
payment_method?: string
|
||||
}
|
||||
|
||||
export type RefundChargeRequest = {
|
||||
type: 'full' | 'partial' | 'none'
|
||||
amount?: number
|
||||
unprovision?: boolean
|
||||
}
|
||||
|
||||
export type CreditRequest =
|
||||
| { subscription_ids: string[]; days: number; send_email: boolean; message: string }
|
||||
| { nodes: string[]; days: number; send_email: boolean; message: string }
|
||||
| { region: string; days: number; send_email: boolean; message: string }
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Projects {
|
||||
export namespace v2 {
|
||||
export type Environment = 'required' | 'optional' | 'unsupported' | 'unknown'
|
||||
|
||||
export type ProjectStatus =
|
||||
| 'approved'
|
||||
| 'archived'
|
||||
| 'rejected'
|
||||
| 'draft'
|
||||
| 'unlisted'
|
||||
| 'processing'
|
||||
| 'withheld'
|
||||
| 'scheduled'
|
||||
| 'private'
|
||||
| 'unknown'
|
||||
|
||||
export type MonetizationStatus = 'monetized' | 'demonetized' | 'force-demonetized'
|
||||
|
||||
export type ProjectType =
|
||||
| 'mod'
|
||||
| 'modpack'
|
||||
| 'resourcepack'
|
||||
| 'shader'
|
||||
| 'plugin'
|
||||
| 'datapack'
|
||||
|
||||
export type GalleryImage = {
|
||||
url: string
|
||||
featured: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
created: string
|
||||
ordering: number
|
||||
}
|
||||
|
||||
export type DonationLink = {
|
||||
id: string
|
||||
platform: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
slug: string
|
||||
project_type: ProjectType
|
||||
team: string
|
||||
title: string
|
||||
description: string
|
||||
body: string
|
||||
published: string
|
||||
updated: string
|
||||
approved?: string
|
||||
queued?: string
|
||||
status: ProjectStatus
|
||||
requested_status?: ProjectStatus
|
||||
moderator_message?: {
|
||||
message: string
|
||||
body?: string
|
||||
}
|
||||
license: {
|
||||
id: string
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
downloads: number
|
||||
followers: number
|
||||
categories: string[]
|
||||
additional_categories: string[]
|
||||
game_versions: string[]
|
||||
loaders: string[]
|
||||
versions: string[]
|
||||
icon_url?: string
|
||||
issues_url?: string
|
||||
source_url?: string
|
||||
wiki_url?: string
|
||||
discord_url?: string
|
||||
donation_urls?: DonationLink[]
|
||||
gallery?: GalleryImage[]
|
||||
color?: number
|
||||
thread_id: string
|
||||
monetization_status: MonetizationStatus
|
||||
}
|
||||
|
||||
export type SearchResultHit = {
|
||||
project_id: string
|
||||
project_type: ProjectType
|
||||
slug: string
|
||||
author: string
|
||||
title: string
|
||||
description: string
|
||||
categories: string[]
|
||||
display_categories: string[]
|
||||
versions: string[]
|
||||
downloads: number
|
||||
follows: number
|
||||
icon_url: string
|
||||
date_created: string
|
||||
date_modified: string
|
||||
latest_version?: string
|
||||
license: string
|
||||
client_side: Environment
|
||||
server_side: Environment
|
||||
gallery: string[]
|
||||
color?: number
|
||||
}
|
||||
|
||||
export type SearchResult = {
|
||||
hits: SearchResultHit[]
|
||||
offset: number
|
||||
limit: number
|
||||
total_hits: number
|
||||
}
|
||||
|
||||
export type ProjectSearchParams = {
|
||||
query?: string
|
||||
facets?: string[][]
|
||||
filters?: string
|
||||
index?: 'relevance' | 'downloads' | 'follows' | 'newest' | 'updated'
|
||||
offset?: number
|
||||
limit?: number
|
||||
}
|
||||
}
|
||||
|
||||
export namespace v3 {
|
||||
export type Environment =
|
||||
| 'client_and_server'
|
||||
| 'client_only'
|
||||
| 'client_only_server_optional'
|
||||
| 'singleplayer_only'
|
||||
| 'server_only'
|
||||
| 'server_only_client_optional'
|
||||
| 'dedicated_server_only'
|
||||
| 'client_or_server'
|
||||
| 'client_or_server_prefers_both'
|
||||
| 'unknown'
|
||||
|
||||
export type GalleryItem = {
|
||||
url: string
|
||||
raw_url: string
|
||||
featured: boolean
|
||||
name?: string
|
||||
description?: string
|
||||
created: string
|
||||
ordering: number
|
||||
}
|
||||
|
||||
export type Link = {
|
||||
platform: string
|
||||
donation: boolean
|
||||
url: string
|
||||
}
|
||||
|
||||
export type Project = {
|
||||
id: string
|
||||
slug?: string
|
||||
project_types: string[]
|
||||
games: string[]
|
||||
team_id: string
|
||||
organization?: string
|
||||
name: string
|
||||
summary: string
|
||||
description: string
|
||||
published: string
|
||||
updated: string
|
||||
approved?: string
|
||||
queued?: string
|
||||
status: v2.ProjectStatus
|
||||
requested_status?: v2.ProjectStatus
|
||||
license: {
|
||||
id: string
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
downloads: number
|
||||
followers: number
|
||||
categories: string[]
|
||||
additional_categories: string[]
|
||||
loaders: string[]
|
||||
versions: string[]
|
||||
icon_url?: string
|
||||
link_urls: Record<string, Link>
|
||||
gallery: GalleryItem[]
|
||||
color?: number
|
||||
thread_id: string
|
||||
monetization_status: v2.MonetizationStatus
|
||||
side_types_migration_review_status: 'reviewed' | 'pending'
|
||||
environment?: Environment[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type EditProjectRequest = {
|
||||
name?: string
|
||||
summary?: string
|
||||
description?: string
|
||||
categories?: string[]
|
||||
additional_categories?: string[]
|
||||
license_url?: string | null
|
||||
link_urls?: Record<string, string | null>
|
||||
license_id?: string
|
||||
slug?: string
|
||||
status?: v2.ProjectStatus
|
||||
requested_status?: v2.ProjectStatus | null
|
||||
moderation_message?: string | null
|
||||
moderation_message_body?: string | null
|
||||
monetization_status?: v2.MonetizationStatus
|
||||
side_types_migration_review_status?: 'reviewed' | 'pending'
|
||||
environment?: Environment
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Tags {
|
||||
export namespace v2 {
|
||||
export interface Category {
|
||||
icon: string
|
||||
name: string
|
||||
project_type: string
|
||||
header: string
|
||||
}
|
||||
|
||||
export interface Loader {
|
||||
icon: string
|
||||
name: string
|
||||
supported_project_types: string[]
|
||||
}
|
||||
|
||||
export interface GameVersion {
|
||||
version: string
|
||||
version_type: string
|
||||
date: string // RFC 3339 DateTime
|
||||
major: boolean
|
||||
}
|
||||
|
||||
export interface DonationPlatform {
|
||||
short: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Search {
|
||||
export namespace v2 {
|
||||
export interface ResultSearchProject {
|
||||
project_id: string
|
||||
project_type: string
|
||||
slug: string | null
|
||||
author: string
|
||||
title: string
|
||||
description: string
|
||||
categories: string[]
|
||||
display_categories: string[]
|
||||
versions: string[]
|
||||
downloads: number
|
||||
follows: number
|
||||
icon_url: string
|
||||
date_created: string
|
||||
date_modified: string
|
||||
latest_version: string
|
||||
license: string
|
||||
client_side: string
|
||||
server_side: string
|
||||
gallery: string[]
|
||||
featured_gallery: string | null
|
||||
color: number | null
|
||||
}
|
||||
|
||||
export interface SearchResults {
|
||||
hits: ResultSearchProject[]
|
||||
offset: number
|
||||
limit: number
|
||||
total_hits: number
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace State {
|
||||
export interface GeneratedState {
|
||||
categories: Tags.v2.Category[]
|
||||
loaders: Tags.v2.Loader[]
|
||||
gameVersions: Tags.v2.GameVersion[]
|
||||
donationPlatforms: Tags.v2.DonationPlatform[]
|
||||
reportTypes: string[]
|
||||
muralBankDetails?: Record<
|
||||
string,
|
||||
{
|
||||
bankNames: string[]
|
||||
}
|
||||
>
|
||||
|
||||
homePageProjects?: Projects.v2.Project[]
|
||||
homePageSearch?: Search.v2.SearchResults
|
||||
homePageNotifs?: Search.v2.SearchResults
|
||||
products?: Billing.Internal.Product[]
|
||||
|
||||
countries: ISO3166.Country[]
|
||||
subdivisions: Record<string, ISO3166.Subdivision[]>
|
||||
|
||||
errors: unknown[]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
export * from './labrinth/projects/types/v2'
|
||||
export * from './labrinth/projects/types/v3'
|
||||
export * from './archon/types'
|
||||
export * from './iso3166/types'
|
||||
export * from './kyros/types'
|
||||
export * from './labrinth/types'
|
||||
|
||||
@@ -20,7 +20,7 @@ export type RequestOptions = {
|
||||
* - number: version number (e.g., 2 for v2, 3 for v3)
|
||||
* - 'internal': use internal API
|
||||
*/
|
||||
version: number | 'internal'
|
||||
version: number | 'internal' | string
|
||||
|
||||
/**
|
||||
* HTTP method to use
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
{
|
||||
"extends": "@modrinth/tooling-config/typescript/base.json"
|
||||
"extends": "@modrinth/tooling-config/typescript/base.json",
|
||||
"compilerOptions": {
|
||||
"isolatedModules": false
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.6 KiB |
@@ -40,6 +40,7 @@ import _CurseForgeIcon from './external/curseforge.svg?component'
|
||||
import _DiscordIcon from './external/discord.svg?component'
|
||||
import _FacebookIcon from './external/facebook.svg?component'
|
||||
import _GithubIcon from './external/github.svg?component'
|
||||
import _MinecraftServerIcon from './external/illustrations/minecraft_server_icon.png?url'
|
||||
import _InstagramIcon from './external/instagram.svg?component'
|
||||
import _KoFiIcon from './external/kofi.svg?component'
|
||||
import _MastodonIcon from './external/mastodon.svg?component'
|
||||
@@ -113,6 +114,7 @@ export const VenmoIcon = _VenmoIcon
|
||||
export const PolygonIcon = _PolygonIcon
|
||||
export const USDCColorIcon = _USDCColorIcon
|
||||
export const VisaIcon = _VisaIcon
|
||||
export const MinecraftServerIcon = _MinecraftServerIcon
|
||||
|
||||
export * from './generated-icons'
|
||||
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
|
||||
|
||||
@@ -15,6 +15,8 @@ export default [
|
||||
'@typescript-eslint/no-type-alias': 'off',
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
'@typescript-eslint/prefer-literal-enum-member': 'off',
|
||||
'@typescript-eslint/no-namespace': 'off',
|
||||
'@typescript-eslint/no-invalid-void-type': 'off',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './src/components'
|
||||
export * from './src/composables'
|
||||
export * from './src/pages'
|
||||
export * from './src/providers'
|
||||
export * from './src/utils'
|
||||
|
||||
@@ -4,14 +4,25 @@
|
||||
"private": true,
|
||||
"main": "./index.ts",
|
||||
"types": "./index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./index.ts",
|
||||
"default": "./index.ts"
|
||||
},
|
||||
"./pages": {
|
||||
"types": "./src/pages/index.ts",
|
||||
"default": "./src/pages/index.ts"
|
||||
},
|
||||
"./src/*": "./src/*"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"fix": "eslint . --fix && prettier --write .",
|
||||
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"src/**/*.d.ts\" --out-file src/locales/en-US/index.json --preserve-whitespace"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@formatjs/cli": "^6.2.12",
|
||||
"@modrinth/tooling-config": "workspace:*",
|
||||
"@stripe/stripe-js": "^7.3.1",
|
||||
"@vintl/unplugin": "^1.5.1",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
@@ -26,8 +37,10 @@
|
||||
"@codemirror/language": "^6.9.3",
|
||||
"@codemirror/state": "^6.3.2",
|
||||
"@codemirror/view": "^6.22.1",
|
||||
"@modrinth/api-client": "workspace:*",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@tanstack/vue-query": "^5.90.7",
|
||||
"@tresjs/cientos": "^4.3.0",
|
||||
"@tresjs/core": "^4.3.4",
|
||||
"@tresjs/post-processing": "^2.4.0",
|
||||
@@ -38,6 +51,7 @@
|
||||
"apexcharts": "^3.44.0",
|
||||
"dayjs": "^1.11.10",
|
||||
"floating-vue": "^5.2.2",
|
||||
"fuse.js": "^6.6.2",
|
||||
"highlight.js": "^11.9.0",
|
||||
"markdown-it": "^13.0.2",
|
||||
"postprocessing": "^6.37.6",
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { InfoIcon } from '@modrinth/assets'
|
||||
import { formatPrice } from '@modrinth/utils'
|
||||
import { type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { Menu } from 'floating-vue'
|
||||
import { computed, inject, type Ref } from 'vue'
|
||||
|
||||
import { monthsInInterval, type ServerBillingInterval, type ServerPlan } from '../../utils/billing'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
plan: ServerPlan
|
||||
plan: Labrinth.Billing.Internal.Product
|
||||
title: MessageDescriptor
|
||||
description: MessageDescriptor
|
||||
buttonColor?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
@@ -25,7 +27,7 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', plan: ServerPlan): void
|
||||
(e: 'select', plan: Labrinth.Billing.Internal.Product): void
|
||||
}>()
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
@@ -36,13 +38,23 @@ const currency = inject<string>('currency')
|
||||
|
||||
const perMonth = computed(() => {
|
||||
if (!props.plan || !currency || !selectedInterval?.value) return undefined
|
||||
const total = props.plan.prices?.find((x) => x.currency_code === currency)?.prices?.intervals?.[
|
||||
selectedInterval.value
|
||||
]
|
||||
const total = getPriceForInterval(props.plan, currency, selectedInterval.value)
|
||||
if (!total) return undefined
|
||||
return total / monthsInInterval[selectedInterval.value]
|
||||
})
|
||||
|
||||
const planSpecs = computed(() => {
|
||||
const metadata = props.plan.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
return {
|
||||
ram: metadata.ram,
|
||||
storage: metadata.storage,
|
||||
cpu: metadata.cpu,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const mostPopularStyle = computed(() => {
|
||||
if (!props.mostPopular) return undefined
|
||||
const style: Record<string, string> = {
|
||||
@@ -121,11 +133,11 @@ const mostPopularStyle = computed(() => {
|
||||
</template>
|
||||
|
||||
<template #popper>
|
||||
<div class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
|
||||
<div v-if="planSpecs" class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
|
||||
<ServersSpecs
|
||||
:ram="plan.metadata.ram!"
|
||||
:storage="plan.metadata.storage!"
|
||||
:cpus="plan.metadata.cpu!"
|
||||
:ram="planSpecs.ram"
|
||||
:storage="planSpecs.storage"
|
||||
:cpus="planSpecs.cpu"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
@@ -7,23 +8,12 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { UserSubscription } from '@modrinth/utils'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { useStripe } from '../../composables/stripe'
|
||||
import { commonMessages } from '../../utils'
|
||||
import type {
|
||||
CreatePaymentIntentRequest,
|
||||
CreatePaymentIntentResponse,
|
||||
ServerBillingInterval,
|
||||
ServerPlan,
|
||||
ServerRegion,
|
||||
ServerStockRequest,
|
||||
UpdatePaymentIntentRequest,
|
||||
UpdatePaymentIntentResponse,
|
||||
} from '../../utils/billing'
|
||||
import { ButtonStyled } from '../index'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
@@ -39,6 +29,9 @@ export type RegionPing = {
|
||||
ping: number
|
||||
}
|
||||
|
||||
// Type alias for billing interval that matches both the local and api-client types
|
||||
export type ServerBillingInterval = 'monthly' | 'quarterly' | 'yearly'
|
||||
|
||||
const props = defineProps<{
|
||||
publishableKey: string
|
||||
returnUrl: string
|
||||
@@ -46,23 +39,30 @@ const props = defineProps<{
|
||||
customer: Stripe.Customer
|
||||
currency: string
|
||||
pings: RegionPing[]
|
||||
regions: ServerRegion[]
|
||||
availableProducts: ServerPlan[]
|
||||
regions: Archon.Servers.v1.Region[]
|
||||
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||
planStage?: boolean
|
||||
existingPlan?: ServerPlan
|
||||
existingSubscription?: UserSubscription
|
||||
existingPlan?: Labrinth.Billing.Internal.Product
|
||||
existingSubscription?: Labrinth.Billing.Internal.UserSubscription
|
||||
refreshPaymentMethods: () => Promise<void>
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
fetchStock: (
|
||||
region: Archon.Servers.v1.Region,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
) => Promise<number>
|
||||
initiatePayment: (
|
||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||
) => Promise<UpdatePaymentIntentResponse | CreatePaymentIntentResponse | null>
|
||||
body: Labrinth.Billing.Internal.InitiatePaymentRequest,
|
||||
) => Promise<
|
||||
| Labrinth.Billing.Internal.InitiatePaymentResponse
|
||||
| Labrinth.Billing.Internal.EditSubscriptionResponse
|
||||
| null
|
||||
>
|
||||
onError: (err: Error) => void
|
||||
onFinalizeNoPaymentChange?: () => Promise<void>
|
||||
affiliateCode?: string | null
|
||||
}>()
|
||||
|
||||
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
|
||||
const selectedPlan = ref<ServerPlan>()
|
||||
const selectedPlan = ref<Labrinth.Billing.Internal.Product>()
|
||||
const selectedInterval = ref<ServerBillingInterval>('quarterly')
|
||||
const loading = ref(false)
|
||||
const selectedRegion = ref<string>()
|
||||
@@ -237,7 +237,7 @@ watch(selectedPlan, () => {
|
||||
}
|
||||
})
|
||||
|
||||
const defaultPlan = computed<ServerPlan | undefined>(() => {
|
||||
const defaultPlan = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
|
||||
return (
|
||||
props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ??
|
||||
props.availableProducts.find((p) => p?.metadata?.type === 'pyro') ??
|
||||
@@ -245,7 +245,11 @@ const defaultPlan = computed<ServerPlan | undefined>(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function begin(interval: ServerBillingInterval, plan?: ServerPlan, project?: string) {
|
||||
function begin(
|
||||
interval: ServerBillingInterval,
|
||||
plan?: Labrinth.Billing.Internal.Product | null,
|
||||
project?: string,
|
||||
) {
|
||||
loading.value = false
|
||||
|
||||
if (plan === null) {
|
||||
|
||||
@@ -552,7 +552,7 @@ import Checkbox from '../base/Checkbox.vue'
|
||||
import Slider from '../base/Slider.vue'
|
||||
import AnimatedLogo from '../brand/AnimatedLogo.vue'
|
||||
import NewModal from '../modal/NewModal.vue'
|
||||
import LoaderIcon from '../servers/LoaderIcon.vue'
|
||||
import LoaderIcon from '../servers/icons/LoaderIcon.vue'
|
||||
|
||||
const { locale, formatMessage } = useVIntl()
|
||||
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { formatPrice } from '@modrinth/utils'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { computed, provide } from 'vue'
|
||||
|
||||
import { monthsInInterval, type ServerBillingInterval, type ServerPlan } from '../../utils/billing'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
|
||||
import OptionGroup from '../base/OptionGroup.vue'
|
||||
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
availableProducts: ServerPlan[]
|
||||
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||
currency: string
|
||||
existingPlan?: ServerPlan
|
||||
existingPlan?: Labrinth.Billing.Internal.Product
|
||||
}>()
|
||||
|
||||
const availableBillingIntervals = ['monthly', 'quarterly']
|
||||
|
||||
const selectedPlan = defineModel<ServerPlan>('plan')
|
||||
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
|
||||
const selectedInterval = defineModel<ServerBillingInterval>('interval')
|
||||
const emit = defineEmits<{
|
||||
(e: 'choose-custom'): void
|
||||
@@ -75,7 +77,10 @@ const isSameAsExistingPlan = computed(() => {
|
||||
})
|
||||
|
||||
const plansByRam = computed(() => {
|
||||
const byName: Record<'small' | 'medium' | 'large', ServerPlan | undefined> = {
|
||||
const byName: Record<
|
||||
'small' | 'medium' | 'large',
|
||||
Labrinth.Billing.Internal.Product | undefined
|
||||
> = {
|
||||
small: undefined,
|
||||
medium: undefined,
|
||||
large: undefined,
|
||||
@@ -93,13 +98,11 @@ function handleCustomPlan() {
|
||||
emit('choose-custom')
|
||||
}
|
||||
|
||||
function pricePerMonth(plan?: ServerPlan) {
|
||||
if (!plan) return undefined
|
||||
const total = plan.prices?.find((x) => x.currency_code === props.currency)?.prices?.intervals?.[
|
||||
selectedInterval.value!
|
||||
]
|
||||
function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
|
||||
if (!plan || !selectedInterval.value) return undefined
|
||||
const total = getPriceForInterval(plan, props.currency, selectedInterval.value)
|
||||
if (!total) return undefined
|
||||
return total / monthsInInterval[selectedInterval.value!]
|
||||
return total / monthsInInterval[selectedInterval.value]
|
||||
}
|
||||
|
||||
const customPricePerGb = computed(() => {
|
||||
@@ -107,7 +110,9 @@ const customPricePerGb = computed(() => {
|
||||
let min: number | undefined
|
||||
for (const p of props.availableProducts) {
|
||||
const perMonth = pricePerMonth(p)
|
||||
const ramGb = (p?.metadata?.ram ?? 0) / 1024
|
||||
const metadata = p?.metadata
|
||||
if (!metadata || (metadata.type !== 'pyro' && metadata.type !== 'medal')) continue
|
||||
const ramGb = metadata.ram / 1024
|
||||
if (perMonth && ramGb > 0) {
|
||||
const perGb = perMonth / ramGb
|
||||
if (min === undefined || perGb < min) min = perGb
|
||||
|
||||
@@ -1,44 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { InfoIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import { formatPrice } from '../../../../utils'
|
||||
import {
|
||||
monthsInInterval,
|
||||
type ServerBillingInterval,
|
||||
type ServerPlan,
|
||||
type ServerRegion,
|
||||
type ServerStockRequest,
|
||||
} from '../../utils/billing'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils.ts'
|
||||
import { regionOverrides } from '../../utils/regions.ts'
|
||||
import Slider from '../base/Slider.vue'
|
||||
import ModalLoadingIndicator from '../modal/ModalLoadingIndicator.vue'
|
||||
import type { RegionPing } from './ModrinthServersPurchaseModal.vue'
|
||||
import type { RegionPing, ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersRegionButton from './ServersRegionButton.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const { formatMessage, locale } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
regions: ServerRegion[]
|
||||
regions: Archon.Servers.v1.Region[]
|
||||
pings: RegionPing[]
|
||||
fetchStock: (region: ServerRegion, request: ServerStockRequest) => Promise<number>
|
||||
fetchStock: (
|
||||
region: Archon.Servers.v1.Region,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
) => Promise<number>
|
||||
custom: boolean
|
||||
currency: string
|
||||
interval: ServerBillingInterval
|
||||
availableProducts: ServerPlan[]
|
||||
availableProducts: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const loading = ref(true)
|
||||
const checkingCustomStock = ref(false)
|
||||
const selectedPlan = defineModel<ServerPlan>('plan')
|
||||
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
|
||||
const selectedRegion = defineModel<string>('region')
|
||||
|
||||
const selectedPrice = computed(() => {
|
||||
const amount = selectedPlan.value?.prices?.find((price) => price.currency_code === props.currency)
|
||||
?.prices?.intervals?.[props.interval]
|
||||
if (!selectedPlan.value) return undefined
|
||||
const amount = getPriceForInterval(selectedPlan.value, props.currency, props.interval)
|
||||
return amount ? amount / monthsInInterval[props.interval] : undefined
|
||||
})
|
||||
|
||||
@@ -67,7 +65,13 @@ const selectedRam = ref<number>(-1)
|
||||
|
||||
const ramOptions = computed(() => {
|
||||
return props.availableProducts
|
||||
.map((product) => (product.metadata.ram ?? 0) / 1024)
|
||||
.map((product) => {
|
||||
const metadata = product.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
return metadata.ram / 1024
|
||||
}
|
||||
return 0
|
||||
})
|
||||
.filter((x) => x > 0)
|
||||
})
|
||||
|
||||
@@ -80,38 +84,63 @@ const maxRam = computed(() => {
|
||||
|
||||
const lowestProduct = computed(() => {
|
||||
return (
|
||||
props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === minRam.value,
|
||||
) ?? props.availableProducts[0]
|
||||
props.availableProducts.find((product) => {
|
||||
const metadata = product.metadata
|
||||
return (
|
||||
(metadata.type === 'pyro' || metadata.type === 'medal') &&
|
||||
metadata.ram / 1024 === minRam.value
|
||||
)
|
||||
}) ?? props.availableProducts[0]
|
||||
)
|
||||
})
|
||||
|
||||
const selectedPlanSpecs = computed(() => {
|
||||
if (!selectedPlan.value) return null
|
||||
const metadata = selectedPlan.value.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
return {
|
||||
ram: metadata.ram,
|
||||
storage: metadata.storage,
|
||||
cpu: metadata.cpu,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
function updateRamStock(regionToCheck: string, newRam: number) {
|
||||
if (newRam > 0) {
|
||||
checkingCustomStock.value = true
|
||||
const plan = props.availableProducts.find(
|
||||
(product) => (product.metadata.ram ?? 0) / 1024 === newRam,
|
||||
)
|
||||
const plan = props.availableProducts.find((product) => {
|
||||
const metadata = product.metadata
|
||||
return (
|
||||
(metadata.type === 'pyro' || metadata.type === 'medal') && metadata.ram / 1024 === newRam
|
||||
)
|
||||
})
|
||||
if (plan) {
|
||||
const region = sortedRegions.value.find((region) => region.shortcode === regionToCheck)
|
||||
if (region) {
|
||||
props
|
||||
.fetchStock(region, {
|
||||
cpu: plan.metadata.cpu ?? 0,
|
||||
memory_mb: plan.metadata.ram ?? 0,
|
||||
swap_mb: plan.metadata.swap ?? 0,
|
||||
storage_mb: plan.metadata.storage ?? 0,
|
||||
})
|
||||
.then((stock: number) => {
|
||||
if (stock > 0) {
|
||||
selectedPlan.value = plan
|
||||
} else {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkingCustomStock.value = false
|
||||
})
|
||||
const metadata = plan.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
props
|
||||
.fetchStock(region, {
|
||||
cpu: metadata.cpu,
|
||||
memory_mb: metadata.ram,
|
||||
swap_mb: metadata.swap,
|
||||
storage_mb: metadata.storage,
|
||||
})
|
||||
.then((stock: number) => {
|
||||
if (stock > 0) {
|
||||
selectedPlan.value = plan
|
||||
} else {
|
||||
selectedPlan.value = undefined
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
checkingCustomStock.value = false
|
||||
})
|
||||
} else {
|
||||
checkingCustomStock.value = false
|
||||
}
|
||||
} else {
|
||||
checkingCustomStock.value = false
|
||||
}
|
||||
@@ -151,22 +180,28 @@ const messages = defineMessages({
|
||||
|
||||
async function updateStock() {
|
||||
currentStock.value = {}
|
||||
|
||||
const getStockRequest = (
|
||||
product: Labrinth.Billing.Internal.Product,
|
||||
): Archon.Servers.v0.StockRequest => {
|
||||
const metadata = product.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
return {
|
||||
cpu: metadata.cpu,
|
||||
memory_mb: metadata.ram,
|
||||
swap_mb: metadata.swap,
|
||||
storage_mb: metadata.storage,
|
||||
}
|
||||
}
|
||||
return { cpu: 0, memory_mb: 0, swap_mb: 0, storage_mb: 0 }
|
||||
}
|
||||
|
||||
const capacityChecks = sortedRegions.value.map((region) =>
|
||||
props.fetchStock(
|
||||
region,
|
||||
selectedPlan.value
|
||||
? {
|
||||
cpu: selectedPlan.value?.metadata.cpu ?? 0,
|
||||
memory_mb: selectedPlan.value?.metadata.ram ?? 0,
|
||||
swap_mb: selectedPlan.value?.metadata.swap ?? 0,
|
||||
storage_mb: selectedPlan.value?.metadata.storage ?? 0,
|
||||
}
|
||||
: {
|
||||
cpu: lowestProduct.value.metadata.cpu ?? 0,
|
||||
memory_mb: lowestProduct.value.metadata.ram ?? 0,
|
||||
swap_mb: lowestProduct.value.metadata.swap ?? 0,
|
||||
storage_mb: lowestProduct.value.metadata.storage ?? 0,
|
||||
},
|
||||
? getStockRequest(selectedPlan.value)
|
||||
: getStockRequest(lowestProduct.value),
|
||||
),
|
||||
)
|
||||
const results = await Promise.all(capacityChecks)
|
||||
@@ -255,12 +290,12 @@ onMounted(() => {
|
||||
<div v-if="checkingCustomStock" class="flex gap-2 items-center">
|
||||
<SpinnerIcon class="size-5 shrink-0 animate-spin" /> Checking availability...
|
||||
</div>
|
||||
<div v-else-if="selectedPlan">
|
||||
<div v-else-if="selectedPlanSpecs">
|
||||
<ServersSpecs
|
||||
class="!flex-row justify-between"
|
||||
:ram="selectedPlan.metadata.ram ?? 0"
|
||||
:storage="selectedPlan.metadata.storage ?? 0"
|
||||
:cpus="selectedPlan.metadata.cpu ?? 0"
|
||||
:ram="selectedPlanSpecs.ram"
|
||||
:storage="selectedPlanSpecs.storage"
|
||||
:cpus="selectedPlanSpecs.cpu"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex gap-2 items-center">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
@@ -9,18 +10,13 @@ import {
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { formatPrice, getPingLevel, type UserSubscription } from '@modrinth/utils'
|
||||
import { formatPrice, getPingLevel } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import dayjs from 'dayjs'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
monthsInInterval,
|
||||
type ServerBillingInterval,
|
||||
type ServerPlan,
|
||||
type ServerRegion,
|
||||
} from '../../utils/billing'
|
||||
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
import ButtonStyled from '../base/ButtonStyled.vue'
|
||||
import Checkbox from '../base/Checkbox.vue'
|
||||
@@ -28,6 +24,7 @@ import TagItem from '../base/TagItem.vue'
|
||||
import ModrinthServersIcon from '../servers/ModrinthServersIcon.vue'
|
||||
import ExpandableInvoiceTotal from './ExpandableInvoiceTotal.vue'
|
||||
import FormattedPaymentMethod from './FormattedPaymentMethod.vue'
|
||||
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
|
||||
import ServersSpecs from './ServersSpecs.vue'
|
||||
|
||||
const vintl = useVIntl()
|
||||
@@ -38,8 +35,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const props = defineProps<{
|
||||
plan: ServerPlan
|
||||
region: ServerRegion
|
||||
plan: Labrinth.Billing.Internal.Product
|
||||
region: Archon.Servers.v1.Region
|
||||
tax?: number
|
||||
total?: number
|
||||
currency: string
|
||||
@@ -48,25 +45,28 @@ const props = defineProps<{
|
||||
selectedPaymentMethod: Stripe.PaymentMethod | undefined
|
||||
hasPaymentMethod?: boolean
|
||||
noPaymentRequired?: boolean
|
||||
existingPlan?: ServerPlan
|
||||
existingSubscription?: UserSubscription
|
||||
existingPlan?: Labrinth.Billing.Internal.Product
|
||||
existingSubscription?: Labrinth.Billing.Internal.UserSubscription
|
||||
}>()
|
||||
|
||||
const interval = defineModel<ServerBillingInterval>('interval', { required: true })
|
||||
const acceptedEula = defineModel<boolean>('acceptedEula', { required: true })
|
||||
|
||||
const prices = computed(() => {
|
||||
return props.plan.prices.find((x) => x.currency_code === props.currency)
|
||||
})
|
||||
|
||||
const selectedPlanPriceForInterval = computed<number | undefined>(() => {
|
||||
return prices.value?.prices?.intervals?.[interval.value as keyof typeof monthsInInterval]
|
||||
return getPriceForInterval(props.plan, props.currency, interval.value)
|
||||
})
|
||||
|
||||
const existingPlanPriceForInterval = computed<number | undefined>(() => {
|
||||
if (!props.existingPlan) return undefined
|
||||
const p = props.existingPlan.prices.find((x) => x.currency_code === props.currency)
|
||||
return p?.prices?.intervals?.[interval.value as keyof typeof monthsInInterval]
|
||||
return getPriceForInterval(props.existingPlan, props.currency, interval.value)
|
||||
})
|
||||
|
||||
const monthlyPrice = computed<number | undefined>(() => {
|
||||
return getPriceForInterval(props.plan, props.currency, 'monthly')
|
||||
})
|
||||
|
||||
const quarterlyPrice = computed<number | undefined>(() => {
|
||||
return getPriceForInterval(props.plan, props.currency, 'quarterly')
|
||||
})
|
||||
|
||||
const upgradeDeltaPrice = computed<number | undefined>(() => {
|
||||
@@ -137,6 +137,18 @@ const planName = computed(() => {
|
||||
return 'Custom'
|
||||
})
|
||||
|
||||
const planSpecs = computed(() => {
|
||||
const metadata = props.plan.metadata
|
||||
if (metadata.type === 'pyro' || metadata.type === 'medal') {
|
||||
return {
|
||||
ram: metadata.ram,
|
||||
storage: metadata.storage,
|
||||
cpu: metadata.cpu,
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
const flag = computed(
|
||||
() =>
|
||||
regionOverrides[props.region.shortcode]?.flag ??
|
||||
@@ -173,11 +185,11 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
</div>
|
||||
<div>
|
||||
<ServersSpecs
|
||||
v-if="plan.metadata && plan.metadata.ram && plan.metadata.storage && plan.metadata.cpu"
|
||||
v-if="planSpecs"
|
||||
class="!grid sm:grid-cols-2"
|
||||
:ram="plan.metadata.ram"
|
||||
:storage="plan.metadata.storage"
|
||||
:cpus="plan.metadata.cpu"
|
||||
:ram="planSpecs.ram"
|
||||
:storage="planSpecs.storage"
|
||||
:cpus="planSpecs.cpu"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,8 +246,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
>Pay monthly</span
|
||||
>
|
||||
<span class="text-sm text-secondary flex items-center gap-1"
|
||||
>{{ formatPrice(locale, prices?.prices.intervals['monthly'], currency, true) }} /
|
||||
month</span
|
||||
>{{ formatPrice(locale, monthlyPrice, currency, true) }} / month</span
|
||||
>
|
||||
</div>
|
||||
</button>
|
||||
@@ -261,7 +272,7 @@ function setInterval(newInterval: ServerBillingInterval) {
|
||||
>{{
|
||||
formatPrice(
|
||||
locale,
|
||||
(prices?.prices?.intervals?.['quarterly'] ?? 0) / monthsInInterval['quarterly'],
|
||||
(quarterlyPrice ?? 0) / monthsInInterval['quarterly'],
|
||||
currency,
|
||||
true,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { SignalIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { getPingLevel } from '@modrinth/utils'
|
||||
import { useVIntl } from '@vintl/vintl'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ServerRegion } from '../../utils/billing'
|
||||
import { regionOverrides } from '../../utils/regions'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -12,7 +12,7 @@ const { formatMessage } = useVIntl()
|
||||
const currentRegion = defineModel<string | undefined>({ required: true })
|
||||
|
||||
const props = defineProps<{
|
||||
region: ServerRegion
|
||||
region: Archon.Servers.v1.Region
|
||||
ping?: number
|
||||
bestPing?: boolean
|
||||
outOfStock?: boolean
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<ModrinthServersPurchaseModal
|
||||
v-if="customer && regionsData"
|
||||
ref="purchaseModal"
|
||||
:publishable-key="props.stripePublishableKey"
|
||||
:initiate-payment="async (body) => await initiatePayment(body)"
|
||||
:available-products="pyroProducts"
|
||||
:on-error="handleError"
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods"
|
||||
:currency="selectedCurrency"
|
||||
:return-url="`${props.siteUrl}/servers/manage`"
|
||||
:pings="regionPings"
|
||||
:regions="regionsData"
|
||||
:refresh-payment-methods="fetchPaymentData"
|
||||
:fetch-stock="fetchStock"
|
||||
:plan-stage="true"
|
||||
:existing-plan="currentPlanFromSubscription"
|
||||
:existing-subscription="subscription || undefined"
|
||||
:on-finalize-no-payment-change="finalizeDowngrade"
|
||||
@hide="
|
||||
() => {
|
||||
debug('modal hidden, resetting subscription')
|
||||
subscription = null
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
ModrinthServersPurchaseModal,
|
||||
useDebugLogger,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
stripePublishableKey: string
|
||||
siteUrl: string
|
||||
products: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { labrinth, archon } = injectModrinthClient()
|
||||
const debug = useDebugLogger('ServersUpgradeModalWrapper')
|
||||
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
|
||||
|
||||
// stripe type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const customer = ref<any>(null)
|
||||
|
||||
// stripe type
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const paymentMethods = ref<any[]>([])
|
||||
const selectedCurrency = ref<string>('USD')
|
||||
|
||||
const regionPings = ref<
|
||||
{
|
||||
region: string
|
||||
ping: number
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const pyroProducts = (props.products as Labrinth.Billing.Internal.Product[])
|
||||
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
|
||||
.sort((a, b) => {
|
||||
const aRam = a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
|
||||
const bRam = b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
|
||||
return aRam - bRam
|
||||
})
|
||||
|
||||
function handleError(err: unknown) {
|
||||
debug('Purchase modal error:', err)
|
||||
}
|
||||
|
||||
const { data: customerData } = useQuery({
|
||||
queryKey: ['billing', 'customer'],
|
||||
queryFn: () => labrinth.billing_internal.getCustomer(),
|
||||
})
|
||||
|
||||
const { data: paymentMethodsData, refetch: refetchPaymentMethods } = useQuery({
|
||||
queryKey: ['billing', 'payment-methods'],
|
||||
queryFn: () => labrinth.billing_internal.getPaymentMethods(),
|
||||
})
|
||||
|
||||
const { data: regionsData } = useQuery({
|
||||
queryKey: ['servers', 'regions'],
|
||||
queryFn: () => archon.servers_v1.getRegions(),
|
||||
})
|
||||
|
||||
watch(customerData, (newCustomer) => {
|
||||
if (newCustomer) customer.value = newCustomer
|
||||
})
|
||||
|
||||
watch(paymentMethodsData, (newMethods) => {
|
||||
if (newMethods) paymentMethods.value = newMethods
|
||||
})
|
||||
|
||||
watch(regionsData, (newRegions) => {
|
||||
if (newRegions) {
|
||||
newRegions.forEach((region) => {
|
||||
runPingTest(region)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
async function fetchPaymentData() {
|
||||
await refetchPaymentMethods()
|
||||
}
|
||||
|
||||
async function fetchStock(
|
||||
region: Archon.Servers.v1.Region,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
): Promise<number> {
|
||||
const result = await archon.servers_v0.checkStock(region.shortcode, request)
|
||||
return result.available
|
||||
}
|
||||
|
||||
const PING_COUNT = 20
|
||||
const PING_INTERVAL = 200
|
||||
const MAX_PING_TIME = 1000
|
||||
|
||||
function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
|
||||
if (index > 10) {
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: -1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
|
||||
try {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const pings: number[] = []
|
||||
|
||||
socket.onopen = () => {
|
||||
for (let i = 0; i < PING_COUNT; i++) {
|
||||
setTimeout(() => {
|
||||
socket.send(String(performance.now()))
|
||||
}, i * PING_INTERVAL)
|
||||
}
|
||||
setTimeout(
|
||||
() => {
|
||||
socket.close()
|
||||
const median = Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
|
||||
if (median) {
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: median,
|
||||
})
|
||||
}
|
||||
},
|
||||
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const start = Number(event.data)
|
||||
pings.push(performance.now() - start)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
runPingTest(region, index + 1)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = ref<Labrinth.Billing.Internal.UserSubscription | null>(null)
|
||||
// Dry run state
|
||||
const dryRunResponse = ref<{
|
||||
requires_payment: boolean
|
||||
required_payment_is_proration: boolean
|
||||
} | null>(null)
|
||||
const pendingDowngradeBody = ref<Labrinth.Billing.Internal.EditSubscriptionRequest | null>(null)
|
||||
const currentPlanFromSubscription = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
|
||||
return subscription.value
|
||||
? pyroProducts.find((p) =>
|
||||
p.prices.some((price) => price.id === subscription.value?.price_id),
|
||||
) ?? undefined
|
||||
: undefined
|
||||
})
|
||||
|
||||
const currentInterval = computed<'monthly' | 'quarterly'>(() => {
|
||||
const interval = subscription.value?.interval
|
||||
|
||||
if (interval === 'monthly' || interval === 'quarterly') {
|
||||
return interval
|
||||
}
|
||||
|
||||
return 'monthly'
|
||||
})
|
||||
|
||||
const editSubscriptionMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
id,
|
||||
body,
|
||||
dry,
|
||||
}: {
|
||||
id: string
|
||||
body: Labrinth.Billing.Internal.EditSubscriptionRequest
|
||||
dry: boolean
|
||||
}) => {
|
||||
return await labrinth.billing_internal.editSubscription(id, body, dry)
|
||||
},
|
||||
})
|
||||
|
||||
async function initiatePayment(
|
||||
body: Labrinth.Billing.Internal.InitiatePaymentRequest,
|
||||
): Promise<Labrinth.Billing.Internal.EditSubscriptionResponse | null> {
|
||||
debug('initiatePayment called', {
|
||||
hasSubscription: !!subscription.value,
|
||||
subscriptionId: subscription.value?.id,
|
||||
body,
|
||||
})
|
||||
if (subscription.value) {
|
||||
const transformedBody: Labrinth.Billing.Internal.EditSubscriptionRequest = {
|
||||
interval: body.charge.type === 'new' ? body.charge.interval : undefined,
|
||||
payment_method: body.type === 'confirmation_token' ? body.token : body.id,
|
||||
product: body.charge.type === 'new' ? body.charge.product_id : undefined,
|
||||
region: body.metadata?.server_region,
|
||||
}
|
||||
|
||||
try {
|
||||
const dry = await editSubscriptionMutation.mutateAsync({
|
||||
id: subscription.value.id,
|
||||
body: transformedBody,
|
||||
dry: true,
|
||||
})
|
||||
|
||||
if (dry && typeof dry === 'object' && 'payment_intent_id' in dry) {
|
||||
dryRunResponse.value = {
|
||||
requires_payment: !!dry.payment_intent_id,
|
||||
required_payment_is_proration: true,
|
||||
}
|
||||
pendingDowngradeBody.value = transformedBody
|
||||
if (dry.payment_intent_id) {
|
||||
return await finalizeImmediate(transformedBody)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
// Fallback if dry run not supported
|
||||
return await finalizeImmediate(transformedBody)
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Dry run failed, attempting immediate patch', e)
|
||||
return await finalizeImmediate(transformedBody)
|
||||
}
|
||||
} else {
|
||||
debug('subscription.value is null/undefined', {
|
||||
subscriptionValue: subscription.value,
|
||||
})
|
||||
addNotification({
|
||||
title: 'Unable to determine subscription ID.',
|
||||
text: 'Please contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
return Promise.reject(new Error('Unable to determine subscription ID.'))
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeImmediate(body: Labrinth.Billing.Internal.EditSubscriptionRequest) {
|
||||
if (!subscription.value) return null
|
||||
|
||||
const result = await editSubscriptionMutation.mutateAsync({
|
||||
id: subscription.value.id,
|
||||
body,
|
||||
dry: false,
|
||||
})
|
||||
|
||||
return result ?? null
|
||||
}
|
||||
|
||||
async function finalizeDowngrade() {
|
||||
if (!subscription.value || !pendingDowngradeBody.value) return
|
||||
try {
|
||||
await finalizeImmediate(pendingDowngradeBody.value)
|
||||
addNotification({
|
||||
title: 'Subscription updated',
|
||||
text: 'Your plan has been downgraded and will take effect next billing cycle.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (e) {
|
||||
addNotification({
|
||||
title: 'Failed to apply subscription changes',
|
||||
text: 'Please try again or contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
throw e
|
||||
} finally {
|
||||
dryRunResponse.value = null
|
||||
pendingDowngradeBody.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function open(id?: string) {
|
||||
debug('open called', { id })
|
||||
if (id) {
|
||||
const subscriptions = await labrinth.billing_internal.getSubscriptions()
|
||||
debug('fetched subscriptions', {
|
||||
count: subscriptions.length,
|
||||
subscriptions: subscriptions.map((s) => ({
|
||||
id: s.id,
|
||||
metadataType: s.metadata?.type,
|
||||
metadataId: s.metadata?.id,
|
||||
})),
|
||||
})
|
||||
for (const sub of subscriptions) {
|
||||
if (
|
||||
(sub?.metadata?.type === 'pyro' || sub?.metadata?.type === 'medal') &&
|
||||
sub.metadata.id === id
|
||||
) {
|
||||
subscription.value = sub
|
||||
debug('found matching subscription', {
|
||||
subscriptionId: sub.id,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!subscription.value) {
|
||||
debug('no matching subscription found for id', id)
|
||||
}
|
||||
} else {
|
||||
debug('no id provided, resetting subscription')
|
||||
subscription.value = null
|
||||
}
|
||||
|
||||
purchaseModal.value?.show(currentInterval.value)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open,
|
||||
})
|
||||
</script>
|
||||
@@ -108,6 +108,7 @@ export { default as AffiliateLinkCreateModal } from './affiliate/AffiliateLinkCr
|
||||
export { default as AddPaymentMethodModal } from './billing/AddPaymentMethodModal.vue'
|
||||
export { default as ModrinthServersPurchaseModal } from './billing/ModrinthServersPurchaseModal.vue'
|
||||
export { default as PurchaseModal } from './billing/PurchaseModal.vue'
|
||||
export { default as ServersUpgradeModalWrapper } from './billing/ServersUpgradeModalWrapper.vue'
|
||||
|
||||
// Skins
|
||||
export { default as CapeButton } from './skin/CapeButton.vue'
|
||||
@@ -127,4 +128,11 @@ export { default as ThemeSelector } from './settings/ThemeSelector.vue'
|
||||
// Servers
|
||||
export { default as ServersSpecs } from './billing/ServersSpecs.vue'
|
||||
export { default as BackupWarning } from './servers/backups/BackupWarning.vue'
|
||||
export { default as LoaderIcon } from './servers/icons/LoaderIcon.vue'
|
||||
export { default as ServerIcon } from './servers/icons/ServerIcon.vue'
|
||||
export { default as ServerInfoLabels } from './servers/labels/ServerInfoLabels.vue'
|
||||
export { default as MedalBackgroundImage } from './servers/marketing/MedalBackgroundImage.vue'
|
||||
export { default as MedalServerListing } from './servers/marketing/MedalServerListing.vue'
|
||||
export type { PendingChange } from './servers/ServerListing.vue'
|
||||
export { default as ServerListing } from './servers/ServerListing.vue'
|
||||
export { default as ServersPromo } from './servers/ServersPromo.vue'
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
import type { EnvironmentV3 } from '@modrinth/utils'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '@vintl/vintl'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
@@ -8,7 +7,7 @@ import LargeRadioButton from '../../../base/LargeRadioButton.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const value = defineModel<EnvironmentV3 | undefined>({ required: true })
|
||||
const value = defineModel<string | undefined>({ required: true })
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
@@ -135,7 +134,7 @@ type SubOptionKey = ValidKeys<(typeof OUTER_OPTIONS)[keyof typeof OUTER_OPTIONS]
|
||||
const currentOuterOption = ref<OuterOptionKey>()
|
||||
const currentSubOption = ref<SubOptionKey>()
|
||||
|
||||
const computedOption = computed<EnvironmentV3>(() => {
|
||||
const computedOption = computed<string>(() => {
|
||||
switch (currentOuterOption.value) {
|
||||
case 'client':
|
||||
return 'client_only'
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
||||
class="relative flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<PanelSpinner />
|
||||
<LoaderCircleIcon class="size-5 animate-spin" />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
@@ -66,7 +66,7 @@
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been cancelled. Please update your
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been cancelled. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
@@ -76,8 +76,9 @@
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended: {{ suspension_reason }}.
|
||||
Please update your billing information or contact Modrinth Support for more information.
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended:
|
||||
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
|
||||
for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
@@ -86,7 +87,7 @@
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
@@ -112,19 +113,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, LockIcon, SparklesIcon } from '@modrinth/assets'
|
||||
import { Avatar, CopyCode, ServersSpecs } from '@modrinth/ui'
|
||||
import type { Project, Server } from '@modrinth/utils'
|
||||
import type { Archon, ProjectV2 } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LoaderCircleIcon,
|
||||
LockIcon,
|
||||
SparklesIcon,
|
||||
TriangleAlertIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useModrinthServers } from '~/composables/servers/modrinth-servers.ts'
|
||||
import { injectModrinthClient } from '../../providers/api-client'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import CopyCode from '../base/CopyCode.vue'
|
||||
import ServersSpecs from '../billing/ServersSpecs.vue'
|
||||
import ServerIcon from './icons/ServerIcon.vue'
|
||||
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
|
||||
|
||||
import PanelErrorIcon from './icons/PanelErrorIcon.vue'
|
||||
import PanelSpinner from './PanelSpinner.vue'
|
||||
import ServerIcon from './ServerIcon.vue'
|
||||
import ServerInfoLabels from './ServerInfoLabels.vue'
|
||||
|
||||
type PendingChange = {
|
||||
export type PendingChange = {
|
||||
planSize: string
|
||||
cpu: number
|
||||
cpuBurst: number
|
||||
@@ -136,37 +144,100 @@ type PendingChange = {
|
||||
verb: string
|
||||
}
|
||||
|
||||
const props = defineProps<Partial<Server> & { pendingChange?: PendingChange }>()
|
||||
const props = defineProps<Partial<Archon.Servers.v0.Server> & { pendingChange?: PendingChange }>()
|
||||
|
||||
if (props.server_id && props.status === 'available') {
|
||||
// Necessary only to get server icon
|
||||
await useModrinthServers(props.server_id, ['general'])
|
||||
}
|
||||
const { archon, kyros, labrinth } = injectModrinthClient()
|
||||
|
||||
const showGameLabel = computed(() => !!props.game)
|
||||
const showLoaderLabel = computed(() => !!props.loader)
|
||||
|
||||
let projectData: Ref<Project | null>
|
||||
if (props.upstream) {
|
||||
const { data } = await useAsyncData<Project>(
|
||||
`server-project-${props.server_id}`,
|
||||
async (): Promise<Project> => {
|
||||
const result = await useBaseFetch(`project/${props.upstream?.project_id}`)
|
||||
return result as Project
|
||||
},
|
||||
)
|
||||
projectData = data
|
||||
} else {
|
||||
projectData = ref(null)
|
||||
const { data: projectData } = useQuery({
|
||||
queryKey: ['project', props.upstream?.project_id] as const,
|
||||
queryFn: async (): Promise<ProjectV2 | null> => {
|
||||
if (!props.upstream?.project_id) return null
|
||||
return await labrinth.projects_v2.get(props.upstream.project_id)
|
||||
},
|
||||
enabled: computed(() => !!props.upstream?.project_id),
|
||||
})
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url)
|
||||
|
||||
async function processImageBlob(blob: Blob, size: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
ctx.drawImage(img, 0, 0, size, size)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
URL.revokeObjectURL(img.src)
|
||||
resolve(dataURL)
|
||||
}
|
||||
img.src = URL.createObjectURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
const image = useState<string | undefined>(`server-icon-${props.server_id}`, () => undefined)
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
|
||||
async function dataURLToBlob(dataURL: string): Promise<Blob> {
|
||||
const res = await fetch(dataURL)
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
const { data: image } = useQuery({
|
||||
queryKey: ['server-icon', props.server_id] as const,
|
||||
queryFn: async (): Promise<string | undefined> => {
|
||||
if (!props.server_id || props.status !== 'available') return undefined
|
||||
|
||||
try {
|
||||
const auth = await archon.servers_v0.getFilesystemAuth(props.server_id)
|
||||
|
||||
try {
|
||||
const blob = await kyros.files_v0.downloadFile(
|
||||
auth.url,
|
||||
auth.token,
|
||||
'/server-icon-original.png',
|
||||
)
|
||||
|
||||
return await processImageBlob(blob, 512)
|
||||
} catch {
|
||||
const projectIcon = iconUrl.value
|
||||
if (projectIcon) {
|
||||
const response = await fetch(projectIcon)
|
||||
const blob = await response.blob()
|
||||
|
||||
const scaledDataUrl = await processImageBlob(blob, 64)
|
||||
const scaledBlob = await dataURLToBlob(scaledDataUrl)
|
||||
const scaledFile = new File([scaledBlob], 'server-icon.png', { type: 'image/png' })
|
||||
|
||||
await kyros.files_v0.uploadFile(auth.url, auth.token, '/server-icon.png', scaledFile)
|
||||
|
||||
const originalFile = new File([blob], 'server-icon-original.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
await kyros.files_v0.uploadFile(
|
||||
auth.url,
|
||||
auth.token,
|
||||
'/server-icon-original.png',
|
||||
originalFile,
|
||||
)
|
||||
|
||||
return scaledDataUrl
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Icon processing failed:', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
enabled: computed(() => !!props.server_id && props.status === 'available'),
|
||||
})
|
||||
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
|
||||
const formatDate = (d: unknown) => {
|
||||
try {
|
||||
return dayjs(d as any).format('MMMM D, YYYY')
|
||||
return dayjs(d as string).format('MMMM D, YYYY')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
@@ -13,13 +13,15 @@
|
||||
v-else
|
||||
class="h-full w-full select-none object-fill"
|
||||
alt="Server Icon"
|
||||
src="~/assets/images/servers/minecraft_server_icon.png"
|
||||
:src="MinecraftServerIcon"
|
||||
/>
|
||||
</client-only>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MinecraftServerIcon } from '@modrinth/assets'
|
||||
|
||||
defineProps<{
|
||||
image: string | undefined
|
||||
}>()
|
||||
@@ -5,7 +5,7 @@
|
||||
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
|
||||
>
|
||||
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
|
||||
<NuxtLink
|
||||
<AutoLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
||||
class="flex min-w-0 items-center truncate text-sm font-semibold"
|
||||
@@ -16,7 +16,7 @@
|
||||
<span v-if="mcVersion">{{ mcVersion }}</span>
|
||||
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</AutoLink>
|
||||
<div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
|
||||
{{ game[0].toUpperCase() + game.slice(1) }}
|
||||
<span v-if="mcVersion">{{ mcVersion }}</span>
|
||||
@@ -27,6 +27,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GameIcon } from '@modrinth/assets'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
|
||||
defineProps<{
|
||||
game: string
|
||||
@@ -34,6 +37,6 @@ defineProps<{
|
||||
isLink?: boolean
|
||||
}>()
|
||||
|
||||
const route = useNativeRoute()
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
</script>
|
||||
@@ -33,6 +33,7 @@ import ServerSubdomainLabel from './ServerSubdomainLabel.vue'
|
||||
import ServerUptimeLabel from './ServerUptimeLabel.vue'
|
||||
|
||||
interface ServerInfoLabelsProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
serverData: Record<string, any>
|
||||
showGameLabel: boolean
|
||||
showLoaderLabel: boolean
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<LoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
|
||||
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
|
||||
<NuxtLink
|
||||
<AutoLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/servers/manage/${serverId}/options/loader` : ''"
|
||||
class="flex min-w-0 items-center text-sm font-semibold"
|
||||
@@ -18,7 +18,7 @@
|
||||
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</AutoLink>
|
||||
<div v-else class="min-w-0 text-sm font-semibold">
|
||||
<span v-if="loader">
|
||||
{{ loader }}
|
||||
@@ -34,7 +34,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LoaderIcon from './icons/LoaderIcon.vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import LoaderIcon from '../icons/LoaderIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
noSeparator?: boolean
|
||||
loader?: 'Fabric' | 'Quilt' | 'Forge' | 'NeoForge' | 'Paper' | 'Spigot' | 'Bukkit' | 'Vanilla'
|
||||
@@ -42,6 +46,6 @@ defineProps<{
|
||||
isLink?: boolean
|
||||
}>()
|
||||
|
||||
const route = useNativeRoute()
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
</script>
|
||||
@@ -22,6 +22,8 @@
|
||||
import { LinkIcon } from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
@@ -39,7 +41,7 @@ const copySubdomain = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const route = useNativeRoute()
|
||||
const route = useRoute()
|
||||
const serverId = computed(() => route.params.id as string)
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
|
||||
@@ -8,7 +8,7 @@
|
||||
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Timer class="flex size-5 shrink-0" />
|
||||
<TimerIcon class="flex size-5 shrink-0" />
|
||||
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
|
||||
{{ formattedUptime }}
|
||||
</time>
|
||||
@@ -17,10 +17,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TimerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Timer from './icons/Timer.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
uptimeSeconds: number
|
||||
noSeparator?: boolean
|
||||
@@ -12,7 +12,6 @@
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts"></script>
|
||||
<style scoped lang="scss">
|
||||
.overlay {
|
||||
position: absolute;
|
||||
@@ -80,7 +80,7 @@
|
||||
</div>
|
||||
</AutoLink>
|
||||
|
||||
<div class="z-10 ml-auto">
|
||||
<div v-if="isNuxt" class="z-10 ml-auto">
|
||||
<ButtonStyled color="medal-promo" type="outlined" size="large">
|
||||
<button class="my-auto" @click="handleUpgrade"><RocketIcon /> Upgrade</button>
|
||||
</ButtonStyled>
|
||||
@@ -91,7 +91,7 @@
|
||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
||||
class="relative flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<PanelSpinner />
|
||||
<LoaderCircleIcon class="size-5 animate-spin" />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
@@ -99,7 +99,7 @@
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<PanelErrorIcon class="!size-5" /> Your Medal server trial has ended and your server has
|
||||
<TriangleAlertIcon class="!size-5" /> Your Medal server trial has ended and your server has
|
||||
been suspended. Please upgrade to continue to use your server.
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,8 +108,9 @@
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended: {{ suspension_reason }}.
|
||||
Please update your billing information or contact Modrinth Support for more information.
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended:
|
||||
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
|
||||
for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
@@ -118,7 +119,7 @@
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<PanelErrorIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
@@ -127,39 +128,48 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronRightIcon, LockIcon, RocketIcon, SparklesIcon } from '@modrinth/assets'
|
||||
import { AutoLink, Avatar, ButtonStyled, CopyCode } from '@modrinth/ui'
|
||||
import type { Project, Server } from '@modrinth/utils'
|
||||
import { type Archon, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LoaderCircleIcon,
|
||||
LockIcon,
|
||||
RocketIcon,
|
||||
SparklesIcon,
|
||||
TriangleAlertIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjsDuration from 'dayjs/plugin/duration'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import MedalBackgroundImage from '~/components/ui/servers/marketing/MedalBackgroundImage.vue'
|
||||
|
||||
import PanelErrorIcon from '../icons/PanelErrorIcon.vue'
|
||||
import PanelSpinner from '../PanelSpinner.vue'
|
||||
import ServerInfoLabels from '../ServerInfoLabels.vue'
|
||||
import { injectModrinthClient } from '../../../providers/api-client'
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import CopyCode from '../../base/CopyCode.vue'
|
||||
import ServerInfoLabels from '../labels/ServerInfoLabels.vue'
|
||||
import MedalBackgroundImage from './MedalBackgroundImage.vue'
|
||||
|
||||
dayjs.extend(dayjsDuration)
|
||||
|
||||
const props = defineProps<Partial<Server>>()
|
||||
const props = defineProps<Partial<Archon.Servers.v0.Server>>()
|
||||
const emit = defineEmits<{ (e: 'upgrade'): void }>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||
|
||||
const showGameLabel = computed(() => !!props.game)
|
||||
const showLoaderLabel = computed(() => !!props.loader)
|
||||
|
||||
let projectData: Ref<Project | null>
|
||||
if (props.upstream) {
|
||||
const { data } = await useAsyncData<Project>(
|
||||
`server-project-${props.server_id}`,
|
||||
async (): Promise<Project> => {
|
||||
const result = await useBaseFetch(`project/${props.upstream?.project_id}`)
|
||||
return result as Project
|
||||
},
|
||||
)
|
||||
projectData = data
|
||||
} else {
|
||||
projectData = ref(null)
|
||||
}
|
||||
const { data: projectData } = useQuery({
|
||||
queryKey: ['server-project', props.server_id, props.upstream?.project_id],
|
||||
queryFn: async () => {
|
||||
if (!props.upstream?.project_id) return null
|
||||
return await client.labrinth.projects_v2.get(props.upstream.project_id)
|
||||
},
|
||||
enabled: !!props.upstream?.project_id,
|
||||
})
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
@@ -1,19 +1,11 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { loadStripe, type Stripe as StripeJs, type StripeElements } from '@stripe/stripe-js'
|
||||
import type { ContactOption } from '@stripe/stripe-js/dist/stripe-js/elements/address'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed, type Ref, ref } from 'vue'
|
||||
|
||||
import type {
|
||||
BasePaymentIntentResponse,
|
||||
ChargeRequestType,
|
||||
CreatePaymentIntentRequest,
|
||||
CreatePaymentIntentResponse,
|
||||
PaymentRequestType,
|
||||
ServerBillingInterval,
|
||||
ServerPlan,
|
||||
UpdatePaymentIntentRequest,
|
||||
UpdatePaymentIntentResponse,
|
||||
} from '../utils/billing.ts'
|
||||
import type { ServerBillingInterval } from '../components/billing/ModrinthServersPurchaseModal.vue'
|
||||
import { getPriceForInterval } from '../utils/product-utils'
|
||||
|
||||
// export type CreateElements = (
|
||||
// paymentMethods: Stripe.PaymentMethod[],
|
||||
@@ -29,13 +21,17 @@ export const useStripe = (
|
||||
customer: Stripe.Customer,
|
||||
paymentMethods: Stripe.PaymentMethod[],
|
||||
currency: string,
|
||||
product: Ref<ServerPlan | undefined>,
|
||||
product: Ref<Labrinth.Billing.Internal.Product | undefined>,
|
||||
interval: Ref<ServerBillingInterval>,
|
||||
region: Ref<string | undefined>,
|
||||
project: Ref<string | undefined>,
|
||||
initiatePayment: (
|
||||
body: CreatePaymentIntentRequest | UpdatePaymentIntentRequest,
|
||||
) => Promise<CreatePaymentIntentResponse | UpdatePaymentIntentResponse | null>,
|
||||
body: Labrinth.Billing.Internal.InitiatePaymentRequest,
|
||||
) => Promise<
|
||||
| Labrinth.Billing.Internal.InitiatePaymentResponse
|
||||
| Labrinth.Billing.Internal.EditSubscriptionResponse
|
||||
| null
|
||||
>,
|
||||
onError: (err: Error) => void,
|
||||
affiliateCode?: Ref<string | null>,
|
||||
) => {
|
||||
@@ -62,18 +58,6 @@ export const useStripe = (
|
||||
stripe.value = await loadStripe(publishableKey)
|
||||
}
|
||||
|
||||
function createIntent(
|
||||
body: CreatePaymentIntentRequest,
|
||||
): Promise<CreatePaymentIntentResponse | null> {
|
||||
return initiatePayment(body) as Promise<CreatePaymentIntentResponse | null>
|
||||
}
|
||||
|
||||
function updateIntent(
|
||||
body: UpdatePaymentIntentRequest,
|
||||
): Promise<UpdatePaymentIntentResponse | null> {
|
||||
return initiatePayment(body) as Promise<UpdatePaymentIntentResponse | null>
|
||||
}
|
||||
|
||||
const planPrices = computed(() => {
|
||||
return product.value?.prices.find((x) => x.currency_code === currency)
|
||||
})
|
||||
@@ -180,9 +164,9 @@ export const useStripe = (
|
||||
} = createElements({
|
||||
mode: 'payment',
|
||||
currency: currency.toLowerCase(),
|
||||
amount: product.value?.prices.find((x) => x.currency_code === currency)?.prices.intervals[
|
||||
interval.value
|
||||
],
|
||||
amount: product.value
|
||||
? getPriceForInterval(product.value, currency, interval.value)
|
||||
: undefined,
|
||||
paymentMethodCreation: 'manual',
|
||||
setupFutureUsage: 'off_session',
|
||||
})
|
||||
@@ -208,82 +192,61 @@ export const useStripe = (
|
||||
selectedPaymentMethod.value = paymentMethods.find((x) => x.id === id)
|
||||
}
|
||||
|
||||
const requestType: PaymentRequestType = confirmation
|
||||
? {
|
||||
type: 'confirmation_token',
|
||||
token: id,
|
||||
}
|
||||
: {
|
||||
type: 'payment_method',
|
||||
id: id,
|
||||
}
|
||||
|
||||
if (!product.value) {
|
||||
return handlePaymentError('No product selected')
|
||||
}
|
||||
|
||||
const charge: ChargeRequestType = {
|
||||
type: 'new',
|
||||
product_id: product.value?.id,
|
||||
interval: interval.value,
|
||||
const request: Labrinth.Billing.Internal.InitiatePaymentRequest = {
|
||||
type: confirmation ? 'confirmation_token' : 'payment_method',
|
||||
...(confirmation ? { token: id } : { id }),
|
||||
charge: {
|
||||
type: 'new',
|
||||
product_id: product.value.id,
|
||||
interval: interval.value as Labrinth.Billing.Internal.PriceDuration,
|
||||
},
|
||||
...(paymentIntentId.value ? { existing_payment_intent: paymentIntentId.value } : {}),
|
||||
metadata: {
|
||||
type: 'pyro',
|
||||
server_region: region.value,
|
||||
source: project.value
|
||||
? {
|
||||
project_id: project.value,
|
||||
}
|
||||
: {},
|
||||
...(affiliateCode?.value ? { affiliate_code: affiliateCode.value } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
let result: BasePaymentIntentResponse | null = null
|
||||
|
||||
const affiliateMetadata =
|
||||
affiliateCode && affiliateCode.value
|
||||
? {
|
||||
affiliate_code: affiliateCode.value,
|
||||
}
|
||||
: {}
|
||||
|
||||
const metadata: CreatePaymentIntentRequest['metadata'] = {
|
||||
type: 'pyro',
|
||||
server_region: region.value,
|
||||
source: project.value
|
||||
? {
|
||||
project_id: project.value,
|
||||
}
|
||||
: {},
|
||||
...affiliateMetadata,
|
||||
}
|
||||
|
||||
if (paymentIntentId.value) {
|
||||
result = await updateIntent({
|
||||
...requestType,
|
||||
charge,
|
||||
existing_payment_intent: paymentIntentId.value,
|
||||
metadata,
|
||||
})
|
||||
if (result) console.log(`Updated payment intent: ${interval.value} for ${result.total}`)
|
||||
} else {
|
||||
const created = await createIntent({
|
||||
...requestType,
|
||||
charge,
|
||||
metadata: metadata,
|
||||
})
|
||||
if (created) {
|
||||
paymentIntentId.value = created.payment_intent_id
|
||||
clientSecret.value = created.client_secret
|
||||
result = created
|
||||
console.log(`Created payment intent: ${interval.value} for ${created.total}`)
|
||||
}
|
||||
}
|
||||
const result = await initiatePayment(request)
|
||||
|
||||
if (!result) {
|
||||
tax.value = 0
|
||||
total.value = 0
|
||||
noPaymentRequired.value = true
|
||||
} else {
|
||||
if (result.payment_intent_id) {
|
||||
paymentIntentId.value = result.payment_intent_id
|
||||
}
|
||||
if (result.client_secret) {
|
||||
clientSecret.value = result.client_secret
|
||||
}
|
||||
tax.value = result.tax
|
||||
total.value = result.total
|
||||
noPaymentRequired.value = false
|
||||
|
||||
console.log(
|
||||
`${paymentIntentId.value ? 'Updated' : 'Created'} payment intent: ${interval.value} for ${result.total}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (confirmation) {
|
||||
confirmationToken.value = id
|
||||
if (result && result.payment_method) {
|
||||
inputtedPaymentMethod.value = result.payment_method
|
||||
if (result && 'payment_method' in result && result.payment_method) {
|
||||
// payment_method is a string ID from the API, need to find the full object
|
||||
const method = paymentMethods.find((x) => x.id === result.payment_method)
|
||||
if (method) {
|
||||
inputtedPaymentMethod.value = method
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -384,11 +347,12 @@ export const useStripe = (
|
||||
}
|
||||
|
||||
submittingPayment.value = true
|
||||
const productPrice = product.value?.prices.find((x) => x.currency_code === currency)
|
||||
const { error } = await stripe.value.confirmPayment({
|
||||
clientSecret: secert,
|
||||
confirmParams: {
|
||||
confirmation_token: confirmationToken.value,
|
||||
return_url: `${returnUrl}?priceId=${product.value?.prices.find((x) => x.currency_code === currency)?.id}&plan=${interval.value}`,
|
||||
return_url: `${returnUrl}?priceId=${productPrice?.id}&plan=${interval.value}`,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
1
packages/ui/src/pages/index.ts
Normal file
1
packages/ui/src/pages/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as ServersManagePageIndex } from './servers/manage/index.vue'
|
||||
339
packages/ui/src/pages/servers/manage/index.vue
Normal file
339
packages/ui/src/pages/servers/manage/index.vue
Normal file
@@ -0,0 +1,339 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-list-root
|
||||
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||
>
|
||||
<ServersUpgradeModalWrapper
|
||||
v-if="isNuxt"
|
||||
ref="upgradeModal"
|
||||
:stripe-publishable-key
|
||||
:site-url
|
||||
:products
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="hasError || fetchError"
|
||||
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<HammerIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
|
||||
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
|
||||
<li>
|
||||
Our systems automatically alert our team when there's an issue. We are already working
|
||||
on getting them back online.
|
||||
</li>
|
||||
<li>
|
||||
If you recently purchased your Modrinth Server, it is currently in a queue and will
|
||||
appear here as soon as it's ready. <br />
|
||||
<span class="font-medium text-contrast"
|
||||
>Do not attempt to purchase a new server.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
If you require personalized support regarding the status of your server, please
|
||||
contact Modrinth Support.
|
||||
</li>
|
||||
|
||||
<li v-if="fetchError" class="text-red">
|
||||
<p>Error details:</p>
|
||||
<CopyCode
|
||||
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
|
||||
:copyable="false"
|
||||
:selectable="false"
|
||||
:language="'json'"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<ButtonStyled size="large" type="standard" color="brand">
|
||||
<AutoLink class="mt-6 !w-full" to="https://support.modrinth.com"
|
||||
>Contact Modrinth Support</AutoLink
|
||||
>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" @click="() => router.go(0)">
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition v-else name="fade" mode="out-in">
|
||||
<div v-if="isLoading && !serverResponse" key="loading" class="flex flex-col gap-4 py-8">
|
||||
<div class="mb-4 text-center">
|
||||
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
|
||||
<p class="m-0 mt-2 text-secondary">Loading your servers...</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
class="flex animate-pulse flex-row items-center gap-4 overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4"
|
||||
>
|
||||
<div class="size-16 rounded-xl bg-button-bg"></div>
|
||||
<div class="flex flex-1 flex-col gap-2">
|
||||
<div class="h-6 w-48 rounded bg-button-bg"></div>
|
||||
<div class="h-4 w-64 rounded bg-button-bg opacity-75"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="serverList.length === 0 && !isPollingForNewServers"
|
||||
key="empty"
|
||||
class="flex h-full flex-col items-center justify-center gap-8"
|
||||
>
|
||||
<img
|
||||
src="https://cdn.modrinth.com/servers/excitement.webp"
|
||||
alt=""
|
||||
class="max-w-[360px]"
|
||||
style="
|
||||
mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%);
|
||||
"
|
||||
/>
|
||||
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
|
||||
<p class="m-0">Modrinth Servers is a new way to play modded Minecraft with your friends.</p>
|
||||
<ButtonStyled size="large" type="standard" color="brand">
|
||||
<AutoLink to="/servers#plan">Create a Server</AutoLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else key="list">
|
||||
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
|
||||
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
|
||||
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
|
||||
<div class="iconified-input w-full md:w-72">
|
||||
<label class="sr-only" for="search">Search</label>
|
||||
<SearchIcon />
|
||||
<input
|
||||
id="search"
|
||||
v-model="searchInput"
|
||||
class="input-text-inherit"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
placeholder="Search servers..."
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled v-if="isNuxt" type="standard">
|
||||
<AutoLink :to="{ path: '/servers', hash: '#plan' }">
|
||||
<PlusIcon />
|
||||
New server
|
||||
</AutoLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-20"
|
||||
leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 max-h-20"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="isPollingForNewServers"
|
||||
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
|
||||
>
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
<span>Checking for new servers...</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<TransitionGroup
|
||||
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="m-0 flex flex-col gap-4 p-0"
|
||||
>
|
||||
<MedalServerListing
|
||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
@upgrade="openUpgradeModal(server.server_id)"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Archon, type Labrinth, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import { HammerIcon, LoaderCircleIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { AutoLink, ButtonStyled, CopyCode, injectModrinthClient } from '@modrinth/ui'
|
||||
import type { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ServersUpgradeModalWrapper from '../../../components/billing/ServersUpgradeModalWrapper.vue'
|
||||
import MedalServerListing from '../../../components/servers/marketing/MedalServerListing.vue'
|
||||
import ServerListing from '../../../components/servers/ServerListing.vue'
|
||||
|
||||
defineProps<{
|
||||
stripePublishableKey?: string
|
||||
siteUrl?: string
|
||||
products?: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||
|
||||
const hasError = ref(false)
|
||||
const isPollingForNewServers = ref(false)
|
||||
const pollingState = ref({
|
||||
enabled: false,
|
||||
count: 0,
|
||||
initialServers: [] as Archon.Servers.v0.Server[],
|
||||
})
|
||||
|
||||
const {
|
||||
data: serverResponse,
|
||||
error: fetchError,
|
||||
isLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['servers'],
|
||||
queryFn: async () => {
|
||||
const response = await client.archon.servers_v0.list()
|
||||
|
||||
// Fetch subscriptions for medal servers
|
||||
const hasMedalServers = response.servers.some((s) => s.is_medal)
|
||||
if (hasMedalServers) {
|
||||
const subscriptions = await client.labrinth.billing_internal.getSubscriptions()
|
||||
|
||||
// Inject medal_expires into servers
|
||||
for (const server of response.servers) {
|
||||
if (server.is_medal) {
|
||||
const sub = subscriptions.find((s) => s.metadata?.id === server.server_id)
|
||||
if (sub) {
|
||||
server.medal_expires = dayjs(sub.created).add(5, 'days').toISOString()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if new servers appeared (stop polling)
|
||||
if (pollingState.value.enabled) {
|
||||
pollingState.value.count++
|
||||
if (response.servers.length !== pollingState.value.initialServers.length) {
|
||||
pollingState.value.enabled = false
|
||||
router.replace({ query: {} })
|
||||
} else if (pollingState.value.count >= 5) {
|
||||
pollingState.value.enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
return response
|
||||
},
|
||||
refetchInterval: () => (pollingState.value.enabled ? 5000 : false),
|
||||
})
|
||||
|
||||
watch([fetchError, serverResponse], ([error, response]) => {
|
||||
hasError.value = !!error || !response
|
||||
})
|
||||
|
||||
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
if (!serverResponse.value) return []
|
||||
return serverResponse.value.servers
|
||||
})
|
||||
|
||||
const searchInput = ref('')
|
||||
|
||||
const fuse = computed(() => {
|
||||
if (serverList.value.length === 0) return null
|
||||
return new Fuse(serverList.value, {
|
||||
keys: ['name', 'loader', 'mc_version', 'game', 'state'],
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
})
|
||||
})
|
||||
|
||||
function introToTop(array: Archon.Servers.v0.Server[]): Archon.Servers.v0.Server[] {
|
||||
return array.slice().sort((a, b) => {
|
||||
return Number(b.flows?.intro) - Number(a.flows?.intro)
|
||||
})
|
||||
}
|
||||
|
||||
const filteredData = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
if (!searchInput.value.trim()) {
|
||||
return introToTop(serverList.value)
|
||||
}
|
||||
return fuse.value
|
||||
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
|
||||
: []
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (route.query.redirect_status === 'succeeded') {
|
||||
isPollingForNewServers.value = true
|
||||
pollingState.value = {
|
||||
enabled: true,
|
||||
count: 0,
|
||||
initialServers: [...(serverResponse.value?.servers ?? [])],
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
|
||||
open: (id: string) => void | Promise<void>
|
||||
}>
|
||||
|
||||
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
|
||||
function openUpgradeModal(serverId: string) {
|
||||
upgradeModal.value?.open(serverId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 300ms ease-in-out,
|
||||
transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 200ms ease-in-out;
|
||||
}
|
||||
|
||||
.list-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
.list-move {
|
||||
transition: transform 200ms ease-in-out;
|
||||
}
|
||||
</style>
|
||||
8
packages/ui/src/providers/api-client.ts
Normal file
8
packages/ui/src/providers/api-client.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { AbstractModrinthClient } from '@modrinth/api-client'
|
||||
|
||||
import { createContext } from './index'
|
||||
|
||||
export const [injectModrinthClient, provideModrinthClient] = createContext<AbstractModrinthClient>(
|
||||
'root',
|
||||
'modrinthClient',
|
||||
)
|
||||
@@ -78,5 +78,6 @@ export function createContext<ContextValue>(
|
||||
return [injectContext, provideContext] as const
|
||||
}
|
||||
|
||||
export * from './api-client'
|
||||
export * from './project-page'
|
||||
export * from './web-notifications'
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Project, ProjectV3Partial, TeamMember } from '@modrinth/utils'
|
||||
import type { Labrinth } from '@modrinth/api-client/src/modules/types'
|
||||
// TODO: api client this shit
|
||||
import type { TeamMember } from '@modrinth/utils'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createContext } from '.'
|
||||
|
||||
export interface ProjectPageContext {
|
||||
projectV2: Ref<Project>
|
||||
projectV3: Ref<ProjectV3Partial>
|
||||
projectV2: Ref<Labrinth.Projects.v2.Project>
|
||||
projectV3: Ref<Labrinth.Projects.v3.Project>
|
||||
refreshProject: () => Promise<void>
|
||||
currentMember: Ref<TeamMember>
|
||||
}
|
||||
|
||||
54
packages/ui/src/utils/product-utils.ts
Normal file
54
packages/ui/src/utils/product-utils.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
|
||||
export function getProductDisplayName(product: Labrinth.Billing.Internal.Product): string {
|
||||
const { metadata } = product
|
||||
|
||||
if (metadata.type === 'pyro') {
|
||||
const ramGB = metadata.ram / 1024
|
||||
return `${ramGB}GB Server`
|
||||
}
|
||||
|
||||
if (metadata.type === 'medal') {
|
||||
const ramGB = metadata.ram / 1024
|
||||
return `${ramGB}GB Medal Server (${metadata.region})`
|
||||
}
|
||||
|
||||
return 'Unknown Product'
|
||||
}
|
||||
|
||||
export function getProductDescription(product: Labrinth.Billing.Internal.Product): string {
|
||||
const { metadata } = product
|
||||
|
||||
if (metadata.type === 'pyro') {
|
||||
return `${metadata.cpu} vCPU, ${metadata.ram}MB RAM, ${metadata.storage}MB Storage`
|
||||
}
|
||||
|
||||
if (metadata.type === 'medal') {
|
||||
return `${metadata.cpu} vCPU, ${metadata.ram}MB RAM, ${metadata.storage}MB Storage`
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export function getPriceForInterval(
|
||||
product: Labrinth.Billing.Internal.Product,
|
||||
currency: string,
|
||||
interval: Labrinth.Billing.Internal.PriceDuration,
|
||||
): number | undefined {
|
||||
const productPrice = product.prices.find((x) => x.currency_code === currency)
|
||||
if (!productPrice) return undefined
|
||||
|
||||
const { prices } = productPrice
|
||||
|
||||
if (prices.type === 'recurring') {
|
||||
return prices.intervals[interval]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const monthsInInterval: Record<'monthly' | 'quarterly' | 'yearly', number> = {
|
||||
monthly: 1,
|
||||
quarterly: 3,
|
||||
yearly: 12,
|
||||
}
|
||||
79
pnpm-lock.yaml
generated
79
pnpm-lock.yaml
generated
@@ -47,6 +47,9 @@ importers:
|
||||
|
||||
apps/app-frontend:
|
||||
dependencies:
|
||||
'@modrinth/api-client':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/api-client
|
||||
'@modrinth/assets':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/assets
|
||||
@@ -62,6 +65,9 @@ importers:
|
||||
'@sfirew/minecraft-motd-parser':
|
||||
specifier: ^1.1.6
|
||||
version: 1.1.6
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.90.7
|
||||
version: 5.90.7(vue@3.5.13(typescript@5.5.4))
|
||||
'@tauri-apps/api':
|
||||
specifier: ^2.5.0
|
||||
version: 2.5.0
|
||||
@@ -239,6 +245,9 @@ importers:
|
||||
'@pinia/nuxt':
|
||||
specifier: ^0.5.1
|
||||
version: 0.5.1(magicast@0.3.5)(rollup@4.28.1)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.90.7
|
||||
version: 5.90.7(vue@3.5.13(typescript@5.5.4))
|
||||
'@types/three':
|
||||
specifier: ^0.172.0
|
||||
version: 0.172.0
|
||||
@@ -287,9 +296,6 @@ importers:
|
||||
markdown-it:
|
||||
specifier: 14.1.0
|
||||
version: 14.1.0
|
||||
papaparse:
|
||||
specifier: ^5.4.1
|
||||
version: 5.5.3
|
||||
pathe:
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2
|
||||
@@ -342,9 +348,6 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^20.1.0
|
||||
version: 20.14.11
|
||||
'@types/papaparse':
|
||||
specifier: ^5.3.15
|
||||
version: 5.3.16
|
||||
'@vintl/compact-number':
|
||||
specifier: ^2.0.5
|
||||
version: 2.0.7(@formatjs/intl@2.10.4(typescript@5.5.4))
|
||||
@@ -544,12 +547,18 @@ importers:
|
||||
'@codemirror/view':
|
||||
specifier: ^6.22.1
|
||||
version: 6.28.4
|
||||
'@modrinth/api-client':
|
||||
specifier: workspace:*
|
||||
version: link:../api-client
|
||||
'@modrinth/assets':
|
||||
specifier: workspace:*
|
||||
version: link:../assets
|
||||
'@modrinth/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../utils
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.90.7
|
||||
version: 5.90.7(vue@3.5.13(typescript@5.5.4))
|
||||
'@tresjs/cientos':
|
||||
specifier: ^4.3.0
|
||||
version: 4.3.1(@tresjs/core@4.3.6(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4)))(@types/three@0.172.0)(react@19.1.1)(three@0.172.0)(typescript@5.5.4)(vue@3.5.13(typescript@5.5.4))
|
||||
@@ -580,6 +589,9 @@ importers:
|
||||
floating-vue:
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(@nuxt/kit@3.17.5(magicast@0.3.5))(vue@3.5.13(typescript@5.5.4))
|
||||
fuse.js:
|
||||
specifier: ^6.6.2
|
||||
version: 6.6.2
|
||||
highlight.js:
|
||||
specifier: ^11.9.0
|
||||
version: 11.9.0
|
||||
@@ -670,7 +682,7 @@ importers:
|
||||
version: 11.9.0
|
||||
highlightjs-mcfunction:
|
||||
specifier: github:modrinth/better-highlightjs-mcfunction
|
||||
version: https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/68a27ae888cfc0e8737f4f2cf1abb67e82078166
|
||||
version: https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e
|
||||
markdown-it:
|
||||
specifier: ^14.1.0
|
||||
version: 14.1.0
|
||||
@@ -2587,6 +2599,22 @@ packages:
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/query-core@5.90.7':
|
||||
resolution: {integrity: sha512-6PN65csiuTNfBMXqQUxQhCNdtm1rV+9kC9YwWAIKcaxAauq3Wu7p18j3gQY3YIBJU70jT/wzCCZ2uqto/vQgiQ==}
|
||||
|
||||
'@tanstack/vue-query@5.90.7':
|
||||
resolution: {integrity: sha512-2h0esebc2qVRVDge3gFArhss5+qn/Wb4abXuaEliSy+/xPWQMrEX7Ny0UKkCy1HcMZZjvfTtNRUfpYrx+vpipw==}
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.1.2
|
||||
vue: ^2.6.0 || ^3.3.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
'@taplo/core@0.2.0':
|
||||
resolution: {integrity: sha512-r8bl54Zj1In3QLkiW/ex694bVzpPJ9EhwqT9xkcUVODnVUGirdB1JTsmiIv0o1uwqZiwhi8xNnTOQBRQCpizrQ==}
|
||||
|
||||
@@ -2806,9 +2834,6 @@ packages:
|
||||
'@types/offscreencanvas@2019.7.3':
|
||||
resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==}
|
||||
|
||||
'@types/papaparse@5.3.16':
|
||||
resolution: {integrity: sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==}
|
||||
|
||||
'@types/resolve@1.20.2':
|
||||
resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==}
|
||||
|
||||
@@ -5041,8 +5066,8 @@ packages:
|
||||
resolution: {integrity: sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/68a27ae888cfc0e8737f4f2cf1abb67e82078166:
|
||||
resolution: {tarball: https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/68a27ae888cfc0e8737f4f2cf1abb67e82078166}
|
||||
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e:
|
||||
resolution: {tarball: https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e}
|
||||
version: 1.0.0
|
||||
|
||||
hookable@5.5.3:
|
||||
@@ -6184,9 +6209,6 @@ packages:
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
papaparse@5.5.3:
|
||||
resolution: {integrity: sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==}
|
||||
|
||||
param-case@3.0.4:
|
||||
resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==}
|
||||
|
||||
@@ -6887,6 +6909,9 @@ packages:
|
||||
remark-stringify@11.0.0:
|
||||
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||
|
||||
remove-accents@0.5.0:
|
||||
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||
|
||||
request-light@0.5.8:
|
||||
resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==}
|
||||
|
||||
@@ -10522,6 +10547,20 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.13(typescript@5.5.4)
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
dependencies:
|
||||
remove-accents: 0.5.0
|
||||
|
||||
'@tanstack/query-core@5.90.7': {}
|
||||
|
||||
'@tanstack/vue-query@5.90.7(vue@3.5.13(typescript@5.5.4))':
|
||||
dependencies:
|
||||
'@tanstack/match-sorter-utils': 8.19.4
|
||||
'@tanstack/query-core': 5.90.7
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.13(typescript@5.5.4)
|
||||
vue-demi: 0.14.10(vue@3.5.13(typescript@5.5.4))
|
||||
|
||||
'@taplo/core@0.2.0': {}
|
||||
|
||||
'@taplo/lib@0.5.0':
|
||||
@@ -10740,10 +10779,6 @@ snapshots:
|
||||
|
||||
'@types/offscreencanvas@2019.7.3': {}
|
||||
|
||||
'@types/papaparse@5.3.16':
|
||||
dependencies:
|
||||
'@types/node': 20.14.11
|
||||
|
||||
'@types/resolve@1.20.2': {}
|
||||
|
||||
'@types/rss@0.0.32': {}
|
||||
@@ -13731,7 +13766,7 @@ snapshots:
|
||||
|
||||
highlight.js@11.9.0: {}
|
||||
|
||||
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/68a27ae888cfc0e8737f4f2cf1abb67e82078166: {}
|
||||
highlightjs-mcfunction@https://codeload.github.com/modrinth/better-highlightjs-mcfunction/tar.gz/aa999b763fd792ffb950d28347eeb6811c83ea8e: {}
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
@@ -15299,8 +15334,6 @@ snapshots:
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
papaparse@5.5.3: {}
|
||||
|
||||
param-case@3.0.4:
|
||||
dependencies:
|
||||
dot-case: 3.0.4
|
||||
@@ -16033,6 +16066,8 @@ snapshots:
|
||||
mdast-util-to-markdown: 2.1.0
|
||||
unified: 11.0.5
|
||||
|
||||
remove-accents@0.5.0: {}
|
||||
|
||||
request-light@0.5.8: {}
|
||||
|
||||
request-light@0.7.0: {}
|
||||
|
||||
Reference in New Issue
Block a user