Compare commits

...
Author SHA1 Message Date
tdgao 73abf0f614 pnpm prepr 2026-03-31 11:53:31 -06:00
tdgao 31f60ef762 Merge branch 'truman/shared-ui-auth-di' into truman/update-purchase-server-flow 2026-03-31 11:08:49 -06:00
tdgao 6ca535375c move app auth provider setup to src/providers/setup 2026-03-31 11:07:43 -06:00
tdgao 8c2bc4b3a4 fix auth usage for empty servers list 2026-03-31 00:51:42 -06:00
tdgao 4a6d0e43b0 Merge branch 'truman/shared-ui-auth-di' into truman/update-purchase-server-flow 2026-03-31 00:45:03 -06:00
tdgao fe409d55f8 pnpm prepr 2026-03-31 00:44:43 -06:00
tdgao d082778dcf use refs instead of reactive 2026-03-31 00:44:17 -06:00
tdgao b7b462cddc implement auth for empty server list 2026-03-31 00:33:18 -06:00
tdgao 6f9452a82e Merge branch 'truman/new-server-card-states' into truman/update-purchase-server-flow 2026-03-31 00:26:44 -06:00
tdgao afac705a39 Merge branch 'truman/shared-ui-auth-di' into truman/new-server-card-states 2026-03-31 00:18:13 -06:00
tdgao d401f1183e feat: add shared UI package auth DI 2026-03-31 00:15:32 -06:00
tdgao 696fe8442c implement select plan design refresh 2026-03-30 14:08:32 -06:00
tdgao a4cc76e07d fix stories and adjust spacing 2026-03-30 11:37:47 -06:00
tdgao 1f025694b7 implement server list empty state component 2026-03-30 11:17:44 -06:00
tdgao 8509e03df4 fix duplicate attribute and pnpm prepr 2026-03-30 09:44:05 -06:00
tdgao 49acd75f98 Merge branch 'main' into truman/new-server-card-states 2026-03-30 09:39:37 -06:00
tdgao 340c62eb35 implement proper provisioning state for resubscribe 2026-03-26 21:12:18 -06:00
tdgao 557eed9759 implement resubscribe modal 2026-03-26 19:14:40 -06:00
tdgao d2ec943e25 update server listing files kept to be countdown 2026-03-24 10:28:47 -06:00
tdgao 2ab69ea08b fix disabled opacity for server card 2026-03-24 10:21:43 -06:00
tdgao bc2fa1e9e0 clean up copy 2026-03-23 22:24:22 -06:00
tdgao 17710094e5 fix loading icon color 2026-03-23 22:15:04 -06:00
tdgao 324da50e3e update disabled state text color 2026-03-23 14:52:07 -06:00
tdgao 0abdefd589 refactor: reduce code duplication for server listing 2026-03-23 14:45:54 -06:00
tdgao 8d61bc7ea9 handle known suspension states 2026-03-23 13:53:23 -06:00
tdgao ef6837d49e update pending change stage 2026-03-23 13:41:27 -06:00
tdgao eb82562c86 fix disabled server icon background 2026-03-23 13:23:16 -06:00
tdgao 1999e79188 do not apply opacity on lock or spiner icons 2026-03-23 13:03:56 -06:00
tdgao d01e9d7d52 filter out servers past 30 days after cancellation 2026-03-23 12:46:53 -06:00
tdgao 504ba7f8e2 pnpm prepr 2026-03-23 12:30:35 -06:00
tdgao fb1445c915 fix upgrading state loading icon 2026-03-23 12:30:31 -06:00
tdgao 345929eeff hook up purchase server modal in app 2026-03-20 15:20:37 -06:00
tdgao 83202a3761 fix tauri throwing error when api returns 204 No Content 2026-03-20 12:58:48 -06:00
tdgao 672757c295 hook up on download button 2026-03-20 11:52:38 -06:00
tdgao 0a05061bf7 hook up server cards and implement updated styles 2026-03-20 11:03:48 -06:00
tdgao d37c1bdeb1 add new server card buttons 2026-03-17 20:58:52 -06:00
tdgao 313ff30d7a Merge branch 'main' into truman/new-server-card-states 2026-03-17 20:11:48 -06:00
tdgao 2386b4df09 start status/player count info label, more style updates and fixes 2026-03-17 20:09:42 -06:00
tdgao 83df48a454 update server info label styles 2026-03-17 19:07:24 -06:00
tdgao 166a47c029 update server listing component styles 2026-03-17 18:55:15 -06:00
tdgao b7452dd0a0 convert copy code to use tailwind 2026-03-17 18:28:07 -06:00
tdgao 6f81061925 remove props. 2026-03-17 18:23:14 -06:00
tdgao 957e43a0d0 add fix for frontend stylesheet imports 2026-03-17 18:23:08 -06:00
tdgao 423818523a add server listing stories 2026-03-17 17:54:37 -06:00
tdgao 86dad839b4 fix some stylesheets not imported for storybook 2026-03-17 17:54:29 -06:00
tdgao 7d4ce863b0 fix type errors 2026-03-17 17:06:46 -06:00
66 changed files with 2085 additions and 501 deletions
+3 -1
View File
@@ -29,5 +29,7 @@
},
"[rust]": {
"editor.defaultFormatter": "rust-lang.rust-analyzer"
}
},
"css.lint.unknownAtRules": "ignore",
"scss.lint.unknownAtRules": "ignore"
}
+12 -7
View File
@@ -106,6 +106,7 @@ import {
} from '@/providers/download-progress.ts'
import { createServerInstall, provideServerInstall } from '@/providers/server-install'
import { setupProviders } from '@/providers/setup'
import { setupAuthProvider } from '@/providers/setup/auth'
import { useError } from '@/store/error.js'
import { useLoading, useTheming } from '@/store/state'
@@ -454,6 +455,10 @@ const credentials = ref()
const modrinthLoginFlowWaitModal = ref()
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
})
async function fetchCredentials() {
const creds = await getCreds().catch(handleError)
if (creds && creds.user_id) {
@@ -977,13 +982,6 @@ 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="/hosting/manage"
>
<ServerIcon />
</NavButton>
<NavButton
v-tooltip.right="'Discover content'"
to="/browse/modpack"
@@ -1007,6 +1005,13 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
>
<LibraryIcon />
</NavButton>
<NavButton
v-if="themeStore.featureFlags.servers_in_app"
v-tooltip.right="'Servers'"
to="/hosting/manage"
>
<ServerIcon />
</NavButton>
<div class="h-px w-6 mx-auto my-2 bg-surface-5"></div>
<suspense>
<QuickInstanceSwitcher />
@@ -77,12 +77,8 @@ body {
}
a {
color: var(--color-link);
color: inherit;
text-decoration: none;
&:hover {
text-decoration: none;
}
}
.badge {
@@ -174,4 +170,11 @@ img {
}
}
button,
input[type='button'] {
cursor: pointer;
border: none;
outline: 2px solid transparent;
}
@import '@modrinth/assets/omorphia.scss';
+7
View File
@@ -0,0 +1,7 @@
// src/config.ts
export const config = {
siteUrl: import.meta.env.VITE_SITE_URL,
stripePublishableKey:
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY ||
'pk_test_51JbFxJJygY5LJFfKV50mnXzz3YLvBVe2Gd1jn7ljWAkaBlRz3VQdxN9mXcPSrFbSqxwAb0svte9yhnsmm7qHfcWn00R611Ce7b',
}
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { injectModrinthClient, ServersManagePageIndex } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { config } from '../config'
const stripePublishableKey = (config.stripePublishableKey as string) || ''
const client = injectModrinthClient()
const { data: products } = useQuery({
queryKey: ['billing', 'products'],
queryFn: () => client.labrinth.billing_internal.getProducts(),
})
const resolvedProducts = computed<Labrinth.Billing.Internal.Product[]>(() => products.value ?? [])
</script>
<template>
<ServersManagePageIndex
:stripe-publishable-key="stripePublishableKey"
:site-url="'https://modrinth.com'"
:products="resolvedProducts"
/>
</template>
+2 -1
View File
@@ -1,6 +1,7 @@
import Browse from './Browse.vue'
import Index from './Index.vue'
import Servers from './Servers.vue'
import Skins from './Skins.vue'
import Worlds from './Worlds.vue'
export { Browse, Index, Skins, Worlds }
export { Browse, Index, Servers, Skins, Worlds }
@@ -0,0 +1,29 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { type Ref, ref, watchEffect } from 'vue'
type AppCredentials = {
session?: string | null
user?: Labrinth.Users.v2.User | null
}
export function setupAuthProvider(
credentials: Ref<AppCredentials | null | undefined>,
requestSignIn: (redirectPath: string) => void | Promise<void>,
) {
const sessionToken = ref<string | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const authProvider: AuthProvider = {
session_token: sessionToken,
user,
requestSignIn,
}
watchEffect(() => {
sessionToken.value = credentials.value?.session ?? null
user.value = credentials.value?.user ?? null
})
provideAuth(authProvider)
}
+1 -2
View File
@@ -1,4 +1,3 @@
import { ServersManagePageIndex } from '@modrinth/ui'
import { createRouter, createWebHistory } from 'vue-router'
import * as Pages from '@/pages'
@@ -31,7 +30,7 @@ export default new createRouter({
{
path: '/hosting/manage/',
name: 'Servers',
component: ServersManagePageIndex,
component: Pages.Servers,
meta: {
breadcrumb: [{ name: 'Servers' }],
},
+2
View File
@@ -16,6 +16,8 @@
"strict": true,
"types": ["vite/client"],
"paths": {
"@/*": ["./src/*"]
}
+3 -3
View File
@@ -87,12 +87,12 @@
"capabilities": ["ads", "core", "plugins"],
"csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net 'self' data: blob:",
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com wss://*.nodes.modrinth.com 'self' data: blob:",
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "https://*.posthog.com https://tally.so/widgets/embed.js 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ 'self'",
"script-src": "https://*.posthog.com https://posthog.modrinth.com https://js.stripe.com https://tally.so/widgets/embed.js 'self'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com https://tally.so/popup/ https://js.stripe.com https://hooks.stripe.com 'self'",
"media-src": "https://*.githubusercontent.com"
}
}
+3 -3
View File
@@ -456,9 +456,9 @@ kbd {
font-size: 0.85em !important;
}
@import '~/assets/styles/layout.scss';
@import '~/assets/styles/utils.scss';
@import '~/assets/styles/components.scss';
@import './layout.scss';
@import './utils.scss';
@import './components.scss';
// OMORPHIA FIXES
.card {
+1 -1
View File
@@ -3333,7 +3333,7 @@
"message": "Error resubscribing"
},
"settings.billing.pyro.resubscribe.request-submitted.text": {
"message": "If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made."
"message": "If the server is currently cancelled, it may take 10-15 minutes to set up the server."
},
"settings.billing.pyro.resubscribe.request-submitted.title": {
"message": "Resubscription request submitted"
@@ -519,7 +519,7 @@ async function modifyCharge() {
})
addNotification({
title: 'Modifications made',
text: 'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
text: 'If the server is currently cancelled, it may take up to 10 minutes for another charge attempt to be made.',
type: 'success',
})
await refreshCharges()
@@ -1181,7 +1181,8 @@ const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallation
}
const updateStats = (currentStats: Stats['current']) => {
isConnected.value = true
if (!isMounted.value) return
if (!isConnected.value) isConnected.value = true
stats.value = {
current: currentStats,
past: { ...stats.value.current },
@@ -1,5 +1,6 @@
<template>
<ServersUpgradeModalWrapper ref="upgradeModal" />
<ResubscribeModal ref="pyroResubscribeModal" @resubscribe="handlePyroResubscribeConfirm" />
<section class="universal-card experimental-styles-within">
<h2>{{ formatMessage(messages.subscriptionTitle) }}</h2>
<p>{{ formatMessage(messages.subscriptionDescription) }}</p>
@@ -284,6 +285,8 @@
v-if="subscription.serverInfo"
v-bind="subscription.serverInfo"
:pending-change="getPendingChange(subscription)"
:cancellation-date="getCancellationDate(subscription)"
:on-download-backup="getLatestBackupDownload(subscription.serverInfo)"
/>
<div v-else class="w-fit">
<p>
@@ -514,15 +517,9 @@
"
color="green"
>
<button
@click="
resubscribePyro(
subscription.id,
$dayjs(getPyroCharge(subscription).due).isBefore($dayjs()),
)
"
>
{{ formatMessage(messages.resubscribe) }} <RightArrowIcon />
<button @click="openPyroResubscribeModal(subscription)">
{{ formatMessage(messages.resubscribe) }}
<RightArrowIcon />
</button>
</ButtonStyled>
</div>
@@ -709,6 +706,7 @@ import {
OverflowMenu,
paymentMethodMessages,
PurchaseModal,
ResubscribeModal,
ServerListing,
useFormatDateTime,
useFormatPrice,
@@ -716,7 +714,8 @@ import {
} from '@modrinth/ui'
import { calculateSavings, getCurrency } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { useIntervalFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
import ModrinthServersIcon from '~/components/ui/servers/ModrinthServersIcon.vue'
import ServersUpgradeModalWrapper from '~/components/ui/servers/ServersUpgradeModalWrapper.vue'
@@ -998,7 +997,7 @@ const messages = defineMessages({
pyroResubscribeRequestSubmittedText: {
id: 'settings.billing.pyro.resubscribe.request-submitted.text',
defaultMessage:
'If the server is currently suspended, it may take up to 10 minutes for another charge attempt to be made.',
'If the server is currently cancelled, it may take 10-15 minutes to set up the server.',
},
pyroResubscribeSuccessText: {
id: 'settings.billing.pyro.resubscribe.success.text',
@@ -1053,6 +1052,11 @@ const { data: serversData } = useQuery({
queryFn: () => client.archon.servers_v0.list(),
})
const { data: serverFullList } = useQuery({
queryKey: ['servers', 'v1'],
queryFn: () => client.archon.servers_v1.list(),
})
const midasProduct = ref(products.find((x) => x.metadata?.type === 'midas'))
const midasSubscription = computed(() =>
subscriptions.value?.find(
@@ -1082,16 +1086,38 @@ const pyroSubscriptions = computed(() => {
const pyroSubs = subscriptions.value?.filter((s) => s?.metadata?.type === 'pyro') || []
const servers = serversData.value?.servers || []
return pyroSubs.map((subscription) => {
const server = servers.find((s) => s.server_id === subscription.metadata.id)
return {
...subscription,
serverInfo: server,
}
})
return pyroSubs
.map((subscription) => {
const server = servers.find((s) => s.server_id === subscription.metadata.id)
const charge = getPyroCharge(subscription)
return {
...subscription,
serverInfo: {
...server,
isProvisioning:
subscription.status === 'unprovisioned' &&
(charge?.status === 'processing' || charge?.status === 'open'),
},
}
})
.filter((subscription) => {
// files expire 30 days after cancellation
const cancellationDate = getCancellationDate(subscription)
if (
!cancellationDate ||
subscription.serverInfo?.status !== 'suspended' ||
subscription.serverInfo?.suspension_reason !== 'cancelled'
)
return true
const cancellation = new Date(cancellationDate)
const thirtyDaysLater = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000)
return new Date() <= thirtyDaysLater
})
})
const midasPurchaseModal = ref()
const pyroResubscribeModal = ref()
const country = useUserCountry()
const price = computed(() =>
midasProduct.value?.prices?.find((x) => x.currency_code === getCurrency(country.value)),
@@ -1201,13 +1227,20 @@ const getProductFromPriceId = (priceId) => {
return productsData.value.find((p) => p.prices?.some((x) => x.id === priceId))
}
const getPyroCharge = (subscription) => {
function getPyroCharge(subscription) {
if (!subscription || !charges.value) return null
return charges.value.find(
(charge) => charge.subscription_id === subscription.id && charge.status !== 'succeeded',
)
}
function getCancellationDate(subscription) {
const charge = getPyroCharge(subscription)
if (!charge) return null
if (charge.status === 'cancelled') return charge.due
return null
}
const getProductSize = (product) => {
if (!product || !product.metadata) return formatMessage(commonMessages.planUnknownLabel)
const ramSize = product.metadata.ram
@@ -1243,12 +1276,38 @@ const showPyroUpgradeModal = (subscription) => {
upgradeModal.value?.open(subscription?.metadata?.id)
}
const CHARGE_POLL_INTERVAL_MS = 20_000
const hasProvisioningSubscription = computed(() =>
pyroSubscriptions.value?.some((s) => s.serverInfo?.isProvisioning),
)
const { pause: pauseChargePoll, resume: resumeChargePoll } = useIntervalFn(
() => {
queryClient.invalidateQueries({ queryKey: ['billing', 'payments'] })
queryClient.invalidateQueries({ queryKey: ['billing', 'subscriptions'] })
},
CHARGE_POLL_INTERVAL_MS,
{ immediate: false },
)
watch(
hasProvisioningSubscription,
(isProvisioning) => {
if (isProvisioning) {
resumeChargePoll()
} else {
pauseChargePoll()
}
},
{ immediate: true },
)
const resubscribePyro = async (subscriptionId, wasSuspended) => {
try {
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
cancelled: false,
})
await refresh()
if (wasSuspended) {
addNotification({
title: formatMessage(messages.pyroResubscribeRequestSubmittedTitle),
@@ -1271,6 +1330,71 @@ const resubscribePyro = async (subscriptionId, wasSuspended) => {
}
}
function openPyroResubscribeModal(subscription) {
const charge = getPyroCharge(subscription)
const product = getPyroProduct(subscription)
const interval = charge?.subscription_interval || subscription?.interval
const productPrice = getProductPrice(product, interval)
pyroResubscribeModal.value?.show({
subscriptionId: subscription?.id ?? '',
wasSuspended: charge?.due ? new Date(charge.due).getTime() < Date.now() : false,
serverName: subscription?.serverInfo?.name ?? 'this server',
planName: `${getProductSize(product)} plan`,
ramGb: product?.metadata?.ram ? product.metadata.ram / 1024 : undefined,
storageGb: product?.metadata?.storage ? product.metadata.storage / 1024 : undefined,
sharedCpus: product?.metadata?.cpu ? product.metadata.cpu / 2 : undefined,
priceCents: charge?.amount ?? productPrice?.prices?.intervals?.[interval],
currencyCode: charge?.currency_code ?? productPrice?.currency_code,
interval,
nextChargeDate: charge?.due,
})
}
function handlePyroResubscribeConfirm({ subscriptionId, wasSuspended }) {
return resubscribePyro(subscriptionId, wasSuspended)
}
function getLatestBackupDownload(serverInfo) {
const serverFull = serverFullList.value?.find((s) => s.id === serverInfo.server_id)
if (!serverFull) return null
const activeWorld = serverFull.worlds.find((w) => w.is_active) ?? serverFull.worlds[0]
if (!activeWorld?.backups?.length) return null
const latestBackup = activeWorld.backups
.filter((b) => b.status === 'done')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
if (!latestBackup) return null
return async () => {
try {
const server = await client.archon.servers_v0.get(serverInfo.server_id)
const kyrosUrl = server.node?.instance
const jwt = server.node?.token
if (!kyrosUrl || !jwt) {
addNotification({
title: 'Download unavailable',
text: 'Server connection info is not available. Please contact support.',
type: 'error',
})
return
}
window.open(
`https://${kyrosUrl}/modrinth/v0/backups/${latestBackup.id}/download?auth=${jwt}`,
'_blank',
)
} catch {
addNotification({
title: 'Download failed',
text: 'An error occurred while trying to download the backup.',
type: 'error',
})
}
}
}
const refresh = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing'] }),
+2
View File
@@ -1,6 +1,7 @@
import { provideNotificationManager } from '@modrinth/ui'
import { FrontendNotificationManager } from './frontend-notifications'
import { setupAuthProvider } from './setup/auth'
import { setupFilePickerProvider } from './setup/file-picker'
import { setupModrinthClientProvider } from './setup/modrinth-client'
import { setupPageContextProvider } from './setup/page-context'
@@ -9,6 +10,7 @@ import { setupTagsProvider } from './setup/tags'
export function setupProviders(auth: Awaited<ReturnType<typeof useAuth>>) {
provideNotificationManager(new FrontendNotificationManager())
setupAuthProvider(auth)
setupModrinthClientProvider(auth)
setupTagsProvider()
setupFilePickerProvider()
+29
View File
@@ -0,0 +1,29 @@
import type { Labrinth } from '@modrinth/api-client'
import { type AuthProvider, provideAuth } from '@modrinth/ui'
import { ref, watchEffect } from 'vue'
export function setupAuthProvider(auth: Awaited<ReturnType<typeof useAuth>>) {
const router = useRouter()
const sessionToken = ref<string | null>(null)
const user = ref<Labrinth.Users.v2.User | null>(null)
const authProvider: AuthProvider = {
session_token: sessionToken,
user,
requestSignIn: async (redirectPath: string) => {
await router.push({
path: '/auth/sign-in',
query: {
redirect: redirectPath,
},
})
},
}
watchEffect(() => {
sessionToken.value = auth.value.token || null
user.value = (auth.value.user as Labrinth.Users.v2.User | null) ?? null
})
provideAuth(authProvider)
}
+5 -2
View File
@@ -103,8 +103,11 @@ export class TauriModrinthClient extends XHRUploadClient {
throw error
}
const data = await response.json()
return data as T
const text = await response.text()
if (!text) {
return undefined as T
}
return JSON.parse(text) as T
} catch (error) {
throw this.normalizeError(error)
}
+69 -3
View File
@@ -1,15 +1,22 @@
import '@modrinth/assets/omorphia.scss'
import 'floating-vue/dist/style.css'
import '../src/styles/tailwind.css'
import '../../assets/styles/defaults.scss'
// frontend css imports
// import '../../../apps/frontend/src/assets/styles/global.scss'
// import '../../../apps/frontend/src/assets/styles/tailwind.css'
// ---
// app-frontend css imports
import '../../../apps/app-frontend/src/assets/stylesheets/global.scss'
import type { Labrinth } from '@modrinth/api-client'
import { GenericModrinthClient } from '@modrinth/api-client'
import { withThemeByClassName } from '@storybook/addon-themes'
import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3-vite'
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query'
import FloatingVue from 'floating-vue'
import { defineComponent, ref } from 'vue'
import { computed, defineComponent, h, ref } from 'vue'
import { createI18n } from 'vue-i18n'
import { createMemoryHistory, createRouter } from 'vue-router'
import NotificationPanel from '../src/components/nav/NotificationPanel.vue'
import PopupNotificationPanel from '../src/components/nav/PopupNotificationPanel.vue'
@@ -109,9 +116,68 @@ class StorybookPopupNotificationManager extends AbstractPopupNotificationManager
}
}
const StorybookLink = defineComponent({
name: 'StorybookLink',
inheritAttrs: false,
props: {
to: {
type: [String, Object],
default: '',
},
},
setup(props, { attrs, slots }) {
const href = computed(() => {
if (typeof props.to === 'string') return props.to || '#'
if (props.to && typeof props.to === 'object' && 'path' in props.to) {
const path = props.to.path
return typeof path === 'string' ? path : '#'
}
return '#'
})
return () =>
h(
'a',
{
...attrs,
href: href.value,
},
slots.default?.(),
)
},
})
const StorybookClientOnly = defineComponent({
name: 'StorybookClientOnly',
setup(_, { slots }) {
return () => slots.default?.()
},
})
setup((app) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
mutations: {
retry: false,
},
},
})
app.use(VueQueryPlugin, { queryClient })
app.use(i18n)
const router = createRouter({
history: createMemoryHistory(),
routes: [{ path: '/:pathMatch(.*)*', component: { render: () => null } }],
})
app.use(router)
app.component('NuxtLink', StorybookLink)
app.component('RouterLink', StorybookLink)
app.component('ClientOnly', StorybookClientOnly)
// Provide the custom I18nContext for components using injectI18n()
const i18nContext: I18nContext = {
locale: i18n.global.locale,
+5 -40
View File
@@ -1,5 +1,9 @@
<template>
<button class="code" :class="{ copied }" :title="formatMessage(copiedMessage)" @click="copyText">
<button
class="!m-0 inline-flex w-fit select-text items-center gap-2 rounded-[10px] bg-[var(--color-button-bg)] px-2 py-1 font-mono text-sm text-primary transition-[opacity,filter,transform,outline] duration-200 ease-in-out hover:brightness-[0.85] active:scale-95 active:brightness-[0.8] motion-reduce:transition-none [&>svg]:h-[1em] [&>svg]:w-[1em]"
:title="formatMessage(copiedMessage)"
@click="copyText"
>
<span>{{ text }}</span>
<CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else />
@@ -27,42 +31,3 @@ async function copyText() {
copied.value = true
}
</script>
<style lang="scss" scoped>
.code {
color: var(--color-text);
display: inline-flex;
grid-gap: 0.5rem;
font-family: var(--mono-font);
font-size: var(--font-size-sm);
margin: 0;
padding: 0.25rem 0.5rem;
background-color: var(--color-button-bg);
width: fit-content;
border-radius: 10px;
user-select: text;
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
svg {
width: 1em;
height: 1em;
}
&:hover {
filter: brightness(0.85);
}
&:active {
transform: scale(0.95);
filter: brightness(0.8);
}
}
</style>
@@ -1,149 +0,0 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { InfoIcon } from '@modrinth/assets'
import { Menu } from 'floating-vue'
import { computed, inject, type Ref } from 'vue'
import { useFormatPrice } from '../../composables'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const props = withDefaults(
defineProps<{
plan: Labrinth.Billing.Internal.Product
title: MessageDescriptor
description: MessageDescriptor
buttonColor?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
mostPopular?: boolean
selected?: boolean
}>(),
{
buttonColor: 'standard',
mostPopular: false,
selected: false,
},
)
const emit = defineEmits<{
(e: 'select', plan: Labrinth.Billing.Internal.Product): void
}>()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
// TODO: Use DI framework when merged.
const selectedInterval = inject<Ref<ServerBillingInterval>>('selectedInterval')
const currency = inject<string>('currency')
const perMonth = computed(() => {
if (!props.plan || !currency || !selectedInterval?.value) return undefined
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> = {
backgroundImage:
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
boxShadow: '0px 12px 38.1px rgba(27, 217, 106, 0.13)',
}
if (!props.selected) {
style.borderColor = 'rgba(12, 107, 52, 0.55)'
}
return style
})
</script>
<template>
<div
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
:class="{
'bg-brand-highlight border-brand': selected,
'bg-button-bg border-transparent': !selected,
'!bg-bg': mostPopular,
}"
:style="mostPopularStyle"
role="button"
tabindex="0"
:aria-pressed="selected"
@click="emit('select', plan)"
@keydown.enter.prevent="emit('select', plan)"
@keydown.space.prevent="emit('select', plan)"
>
<div class="flex h-full flex-col justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center justify-between">
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(title) }}
</span>
<div
v-if="mostPopular"
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Most Popular
</div>
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(perMonth, currency, true) }}
<span class="text-sm font-semibold text-secondary">
/ month{{ selectedInterval !== 'monthly' ? `, billed ${selectedInterval}` : '' }}
</span>
</span>
</div>
<span class="text-sm">{{ formatMessage(description) }}</span>
<div class="w-fit">
<Menu
placement="bottom-start"
:triggers="['hover', 'focus']"
:auto-hide="true"
:delay="{ show: 100, hide: 120 }"
:distance="6"
>
<template #default="{ shown }">
<div
class="flex w-fit items-center gap-2 cursor-help text-sm font-medium cursor-default select-none outline-none"
:class="shown ? 'text-primary' : 'text-secondary'"
role="button"
tabindex="0"
aria-haspopup="true"
:aria-expanded="shown"
>
<InfoIcon />
View plan details
</div>
</template>
<template #popper>
<div v-if="planSpecs" class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
<ServersSpecs
:ram="planSpecs.ram"
:storage="planSpecs.storage"
:cpus="planSpecs.cpu"
/>
</div>
</template>
</Menu>
</div>
</div>
</div>
</template>
@@ -283,6 +283,10 @@ function handleChooseCustom() {
selectedPlan.value = undefined
}
function handleProceed() {
setStep(nextStep.value)
}
// When the user explicitly wants to change or add a payment method from Review
// we must disable the auto-skip behavior, clear any selected method, and
// navigate to the Payment step so Stripe Elements can mount.
@@ -328,7 +332,7 @@ function goToBreadcrumbStep(id: string) {
</template>
</div>
</template>
<div class="w-[40rem] max-w-full">
<div :class="currentStep === 'plan' ? 'w-[56rem] max-w-full' : 'w-[40rem] max-w-full'">
<PlanSelector
v-if="currentStep === 'plan'"
v-model:plan="selectedPlan"
@@ -337,6 +341,7 @@ function goToBreadcrumbStep(id: string) {
:available-products="availableProducts"
:currency="currency"
@choose-custom="handleChooseCustom"
@proceed="handleProceed"
/>
<RegionSelector
v-else-if="currentStep === 'region'"
@@ -374,7 +379,7 @@ function goToBreadcrumbStep(id: string) {
:ping="currentPing"
:loading="paymentMethodLoading"
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
:has-payment-method="hasPaymentMethod"
:has-payment-method="!!hasPaymentMethod"
:tax="tax"
:total="total"
:no-payment-required="noPaymentRequired"
@@ -415,7 +420,7 @@ function goToBreadcrumbStep(id: string) {
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<ButtonStyled v-if="currentStep !== 'plan'" color="brand">
<button
v-tooltip="
currentStep === 'review' && !acceptedEula && !noPaymentRequired
@@ -0,0 +1,201 @@
<template>
<NewModal ref="modal" max-width="550px">
<template #title>
<div class="text-2xl font-semibold text-contrast">Resubscribe to Server</div>
</template>
<div class="flex w-[44rem] max-w-full flex-col gap-6">
<p class="m-0 text-secondary leading-relaxed">
You are about to resubscribe to
<span class="font-semibold text-contrast">{{ modalData.serverName }}</span
>. Your subscription will be reactivated and your server will continue running without
interruption.
</p>
<div class="flex flex-col gap-2.5">
<span class="text-contrast font-semibold">Plan</span>
<div
class="flex items-center justify-between gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-5"
>
<div class="flex flex-col gap-1">
<div class="truncate font-semibold text-contrast">{{ modalData.planName }}</div>
<div class="text-secondary flex gap-1.5 font-medium text-sm items-center">
{{ modalData.ramGb }} GB RAM
<div class="h-1.5 w-1.5 bg-button-border rounded-full"></div>
{{ modalData.storageGb }} GB Storage
<div class="h-1.5 w-1.5 bg-button-border rounded-full"></div>
{{ modalData.sharedCpus }} Shared CPUs
</div>
</div>
<div class="flex flex-col gap-1 items-end">
<div class="font-semibold text-contrast">
{{ formattedPrice }}
</div>
<div class="text-secondary">/{{ intervalLabel }}</div>
</div>
</div>
</div>
<p v-if="formattedNextChargeDate" class="m-0 text-primary">
Your next charge will be on
<span class="font-semibold text-contrast">{{ formattedNextChargeDate }}</span
>.
</p>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="handleCancel">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canResubscribe" @click="handleResubscribe">
<RotateCounterClockwiseIcon />
Resubscribe
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
import { useFormatDateTime, useFormatPrice } from '../../composables'
import { ButtonStyled, NewModal } from '../index'
type BillingInterval = Labrinth.Billing.Internal.PriceDuration
export type ResubscribeModalPayload = {
subscriptionId: string
wasSuspended: boolean
serverName?: string
planName?: string
ramGb?: number
storageGb?: number
sharedCpus?: number
priceCents?: number
currencyCode?: string
interval?: BillingInterval | null
nextChargeDate?: string | number | Date | null
}
type ResubscribeModalState = {
subscriptionId: string
wasSuspended: boolean
serverName: string
planName: string
ramGb: number
storageGb: number
sharedCpus: number
priceCents: number
currencyCode: string
interval: BillingInterval
nextChargeDate: string | number | Date
}
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'resubscribe', payload: { subscriptionId: string; wasSuspended: boolean }): void
}>()
const formatDate = useFormatDateTime({ dateStyle: 'long' })
const formatPrice = useFormatPrice()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const FALLBACK_NEXT_CHARGE_DATE = '2025-02-17'
const modalData = ref<ResubscribeModalState>({
subscriptionId: '',
wasSuspended: false,
serverName: 'this server',
planName: 'Medium plan',
ramGb: 2,
storageGb: 48,
sharedCpus: 3,
priceCents: 1500,
currencyCode: 'USD',
interval: 'monthly',
nextChargeDate: FALLBACK_NEXT_CHARGE_DATE,
})
const canResubscribe = computed(() => !!modalData.value.subscriptionId)
const intervalLabel = computed(() => {
switch (modalData.value.interval) {
case 'monthly':
return 'month'
case 'quarterly':
return 'quarter'
case 'yearly':
return 'year'
case 'five-days':
return '5 days'
default:
return 'month'
}
})
const formattedPrice = computed(() =>
formatPrice(modalData.value.priceCents, modalData.value.currencyCode),
)
const normalizedNextChargeDate = computed(() => {
const date = new Date(modalData.value.nextChargeDate)
if (Number.isNaN(date.getTime())) {
return null
}
return date
})
const formattedNextChargeDate = computed(() =>
normalizedNextChargeDate.value ? formatDate(normalizedNextChargeDate.value) : '',
)
function show(payload: ResubscribeModalPayload) {
modalData.value = {
subscriptionId: payload.subscriptionId,
wasSuspended: payload.wasSuspended,
serverName: payload.serverName?.trim() || 'this server',
planName: payload.planName ?? 'Medium plan',
ramGb: payload.ramGb ?? 2,
storageGb: payload.storageGb ?? 48,
sharedCpus: payload.sharedCpus ?? 3,
priceCents: payload.priceCents ?? 1500,
currencyCode: payload.currencyCode ?? 'USD',
interval: payload.interval ?? 'monthly',
nextChargeDate: payload.nextChargeDate ?? FALLBACK_NEXT_CHARGE_DATE,
}
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function handleCancel() {
hide()
emit('cancel')
}
function handleResubscribe() {
if (!canResubscribe.value) return
hide()
emit('resubscribe', {
subscriptionId: modalData.value.subscriptionId,
wasSuspended: modalData.value.wasSuspended,
})
}
defineExpose({
show,
hide,
})
</script>
@@ -1,13 +1,15 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { computed, provide } from 'vue'
import { RightArrowIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import ButtonStyled from '../base/ButtonStyled.vue'
import OptionGroup from '../base/OptionGroup.vue'
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
@@ -23,18 +25,10 @@ const availableBillingIntervals = ['monthly', 'quarterly']
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
const selectedInterval = defineModel<ServerBillingInterval>('interval')
const emit = defineEmits<{
(e: 'choose-custom'): void
(e: 'choose-custom' | 'proceed'): void
}>()
const messages = defineMessages({
title: {
id: 'servers.purchase.step.plan.prompt',
defaultMessage: 'Choose a plan',
},
subtitle: {
id: 'servers.purchase.step.plan.subtitle',
defaultMessage: 'Pick the amount of RAM and specs that fit your needs.',
},
selectPlan: {
id: 'servers.purchase.step.plan.select',
defaultMessage: 'Select Plan',
@@ -43,9 +37,17 @@ const messages = defineMessages({
id: 'servers.purchase.step.plan.get-started',
defaultMessage: 'Get started',
},
billed: {
id: 'servers.purchase.step.plan.billed',
defaultMessage: 'billed {interval}',
smallTitle: {
id: 'servers.purchase.step.plan.small',
defaultMessage: 'Small',
},
mediumTitle: {
id: 'servers.purchase.step.plan.medium',
defaultMessage: 'Medium',
},
largeTitle: {
id: 'servers.purchase.step.plan.large',
defaultMessage: 'Large',
},
smallDesc: {
id: 'servers.purchase.step.plan.small.desc',
@@ -67,6 +69,18 @@ const messages = defineMessages({
id: 'servers.purchase.step.plan.most-popular',
defaultMessage: 'Most Popular',
},
billingSubtitle: {
id: 'servers.purchase.step.plan.billing-subtitle',
defaultMessage: 'Available in North America, Europe, and Southeast Asia.',
},
customHeading: {
id: 'servers.purchase.step.plan.custom.heading',
defaultMessage: 'Know exactly what you need?',
},
yourCurrentPlan: {
id: 'servers.purchase.step.plan.your-current-plan',
defaultMessage: 'Your current plan',
},
})
const isSameAsExistingPlan = computed(() => {
@@ -95,8 +109,12 @@ const plansByRam = computed(() => {
return byName
})
function handleCustomPlan() {
emit('choose-custom')
function planSpecs(plan: Labrinth.Billing.Internal.Product) {
const m = plan.metadata
if (m.type === 'pyro' || m.type === 'medal') {
return { ram: m.ram, storage: m.storage, cpus: m.cpu }
}
return null
}
function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
@@ -106,22 +124,6 @@ function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
return total / monthsInInterval[selectedInterval.value]
}
const customPricePerGb = computed(() => {
// Calculate lowest price per GB among products for current interval
let min: number | undefined
for (const p of props.availableProducts) {
const perMonth = pricePerMonth(p)
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
}
}
return min
})
const customStartingPrice = computed(() => {
let min: number | undefined
for (const p of props.availableProducts) {
@@ -131,26 +133,47 @@ const customStartingPrice = computed(() => {
return min
})
provide('currency', props.currency)
provide('selectedInterval', selectedInterval)
const smallPrice = computed(() => pricePerMonth(plansByRam.value.small))
const mediumPrice = computed(() => pricePerMonth(plansByRam.value.medium))
const largePrice = computed(() => pricePerMonth(plansByRam.value.large))
const smallSpecs = computed(() =>
plansByRam.value.small ? planSpecs(plansByRam.value.small) : null,
)
const mediumSpecs = computed(() =>
plansByRam.value.medium ? planSpecs(plansByRam.value.medium) : null,
)
const largeSpecs = computed(() =>
plansByRam.value.large ? planSpecs(plansByRam.value.large) : null,
)
function selectPlan(plan: Labrinth.Billing.Internal.Product) {
selectedPlan.value = plan
emit('proceed')
}
function selectCustom() {
emit('choose-custom')
emit('proceed')
}
</script>
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 mb-5 !mt-0">
<span></span>
<div class="flex flex-col items-center gap-2 mb-5 !mt-0">
<OptionGroup
v-slot="{ option }"
v-model="selectedInterval"
class="!bg-button-bg !shadow-none"
:options="availableBillingIntervals"
>
<template v-if="option === 'monthly'"> Pay monthly </template>
<span v-else-if="option === 'quarterly'"> Pay quarterly </span>
<span v-else-if="option === 'yearly'"> Pay yearly </span>
<template v-if="option === 'monthly'">Monthly</template>
<span v-else-if="option === 'quarterly'">
Quarterly <span class="text-brand">(Save 16%)</span>
</span>
</OptionGroup>
<span class="bg-transparent p-0 text-sm text-xs font-bold text-brand">
{{ selectedInterval !== 'quarterly' ? 'Save' : 'Saving' }} 16% with quarterly billing!
</span>
<div class="text-sm text-secondary text-center">
{{ formatMessage(messages.billingSubtitle) }}
</div>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
@@ -164,68 +187,182 @@ provide('selectedInterval', selectedInterval)
Your server is already on this plan, choose a different plan.
</div>
</Transition>
<div class="grid grid-cols-1 sm:grid-cols-2 !gap-4">
<ModalBasedServerPlan
v-if="plansByRam.small"
:plan="plansByRam.small"
:title="{ id: 'servers.purchase.step.plan.small', defaultMessage: 'Small' }"
:description="messages.smallDesc"
:button-color="'blue'"
:selected="selectedPlan?.id === plansByRam.small.id"
@select="selectedPlan = $event"
/>
<ModalBasedServerPlan
v-if="plansByRam.medium"
:plan="plansByRam.medium"
:title="{ id: 'servers.purchase.step.plan.medium', defaultMessage: 'Medium' }"
:description="messages.mediumDesc"
most-popular
:button-color="'brand'"
:selected="selectedPlan?.id === plansByRam.medium.id"
@select="selectedPlan = $event"
/>
<ModalBasedServerPlan
v-if="plansByRam.large"
:plan="plansByRam.large"
:title="{ id: 'servers.purchase.step.plan.large', defaultMessage: 'Large' }"
:description="messages.largeDesc"
:button-color="'purple'"
:selected="selectedPlan?.id === plansByRam.large.id"
@select="selectedPlan = $event"
/>
<div class="grid grid-cols-3 gap-4 items-start">
<!-- Small -->
<div
v-if="customStartingPrice"
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
:class="!selectedPlan ? 'bg-brand-highlight border-brand' : 'bg-button-bg border-transparent'"
role="button"
tabindex="0"
:aria-pressed="!selectedPlan"
@click="handleCustomPlan"
@keydown.enter.prevent="handleCustomPlan"
@keydown.space.prevent="handleCustomPlan"
v-if="plansByRam.small && smallPrice"
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
>
<div class="flex h-full flex-col justify-between">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-2xl font-semibold text-contrast">Custom</span>
<div>
<div class="text-[1.75rem] font-semibold text-contrast leading-none">
{{ formatMessage(messages.smallTitle) }}
</div>
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(smallPrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.smallDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="blue" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.small.id"
@click="selectPlan(plansByRam.small!)"
>
{{
existingPlan?.id === plansByRam.small.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="smallSpecs"
:ram="smallSpecs.ram"
:storage="smallSpecs.storage"
:cpus="smallSpecs.cpus"
/>
</div>
<!-- Medium (Most Popular) -->
<div v-if="plansByRam.medium && mediumPrice" class="flex flex-col items-center relative">
<div
class="z-10 -mb-3.5 rounded-full text-sm font-medium text-brand whitespace-nowrap absolute -top-3 right-4 bg-surface-3"
>
<div
class="bg-brand-highlight border border-solid border-highlight-green px-2.5 py-0.5 rounded-full"
>
{{ formatMessage(messages.mostPopular) }}
</div>
</div>
<div
class="w-full flex flex-col gap-4 rounded-2xl bg-brand-inverted border-brand-highlight border border-solid p-5 h-full"
:style="{
backgroundImage:
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
}"
>
<div>
<div class="text-[1.75rem] font-semibold text-contrast leading-none">
{{ formatMessage(messages.mediumTitle) }}
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(customStartingPrice, currency, true) }}
<span class="text-sm font-semibold text-secondary">
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(mediumPrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</span>
<span class="text-sm">{{ formatMessage(messages.customDesc) }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<span v-if="customPricePerGb" class="text-sm text-secondary">
From {{ formatPrice(customPricePerGb, currency, true) }} / GB
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.mediumDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="brand" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.medium.id"
@click="selectPlan(plansByRam.medium!)"
>
{{
existingPlan?.id === plansByRam.medium.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="mediumSpecs"
:ram="mediumSpecs.ram"
:storage="mediumSpecs.storage"
:cpus="mediumSpecs.cpus"
/>
</div>
</div>
<!-- Large -->
<div
v-if="plansByRam.large && largePrice"
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
>
<div>
<div class="text-[1.75rem] font-semibold text-contrast leading-none">
{{ formatMessage(messages.largeTitle) }}
</div>
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(largePrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.largeDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="purple" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.large.id"
@click="selectPlan(plansByRam.large!)"
>
{{
existingPlan?.id === plansByRam.large.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="largeSpecs"
:ram="largeSpecs.ram"
:storage="largeSpecs.storage"
:cpus="largeSpecs.cpus"
/>
</div>
</div>
<!-- Custom plan banner -->
<div
v-if="customStartingPrice"
class="mt-4 flex items-center justify-between gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5"
>
<div class="flex flex-col gap-1">
<div class="text-xl font-semibold text-contrast">
{{ formatMessage(messages.customHeading) }}
</div>
<div class="text-sm text-secondary">
{{ formatMessage(messages.customDesc) }}
</div>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<ButtonStyled>
<button class="flex items-center gap-2" @click="selectCustom">
{{ formatMessage(messages.getStarted) }} <RightArrowIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<div class="text-sm text-secondary whitespace-nowrap">
Starting at {{ formatPrice(customStartingPrice, currency, true) }}/mo
</div>
</div>
</div>
@@ -179,7 +179,7 @@ function setInterval(newInterval: ServerBillingInterval) {
<template>
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
<div class="bg-table-alternateRow p-4 rounded-2xl">
<div class="bg-surface-2 p-4 rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<ModrinthServersIcon class="flex h-5 w-fit" />
<TagItem>{{ planName }}</TagItem>
@@ -194,9 +194,7 @@ function setInterval(newInterval: ServerBillingInterval) {
/>
</div>
</div>
<div
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
>
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-2 items-center justify-center">
<img
v-if="flag"
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
@@ -304,10 +302,7 @@ function setInterval(newInterval: ServerBillingInterval) {
"
/>
</template>
<div
v-else
class="p-4 rounded-2xl bg-table-alternateRow text-sm text-secondary leading-relaxed"
>
<div v-else class="p-4 rounded-2xl bg-surface-2 text-sm text-secondary leading-relaxed">
No payment required. Your downgrade will apply at the end of the current billing period.
</div>
</div>
@@ -1,5 +1,6 @@
export { default as AddPaymentMethodModal } from './AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './ModrinthServersPurchaseModal.vue'
export { default as PurchaseModal } from './PurchaseModal.vue'
export { default as ResubscribeModal } from './ResubscribeModal.vue'
export { default as ServersSpecs } from './ServersSpecs.vue'
export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue'
@@ -1,32 +1,46 @@
<template>
<div>
<NuxtLink :to="status === 'suspended' ? '' : `/hosting/manage/${props.server_id}`">
<NuxtLink :to="isDisabled ? '' : `/hosting/manage/${server_id}`">
<div
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4 transition-transform duration-100"
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-4 transition-transform duration-100"
:class="{
'!rounded-b-none border-b-0': status === 'suspended' || !!pendingChange,
'opacity-75': status === 'suspended',
'active:scale-95': status !== 'suspended' && !pendingChange,
'!rounded-b-none border-b-0': hasNotice,
'bg-surface-2': isDisabled,
'active:scale-95': !isDisabled && !hasNotice,
}"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<ServerIcon v-if="status !== 'suspended'" :image="image" />
<div
v-else
class="bg-bg-secondary flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
v-if="hasIconOverlay"
class="flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<LockIcon class="size-12 text-secondary" />
<ServerIcon :image="image ?? undefined" :disabled="isDisabled" />
<SpinnerIcon
v-if="isProvisioning || isUpgrading"
class="size-8 animate-spin absolute text-contrast"
:class="{ 'opacity-50': isDisabled }"
/>
<LockIcon v-else class="size-8 absolute" :class="{ 'opacity-50': isDisabled }" />
</div>
<ServerIcon v-else :image="image ?? undefined" :disabled="isDisabled" />
<div class="ml-4 flex flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
<h2 class="m-0 text-xl font-bold text-contrast" :class="{ 'opacity-50': isDisabled }">
{{ name }}
</h2>
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-brand rounded-full bg-brand-highlight border border-solid border-brand px-2.5 h-[28px]"
>
<SparklesIcon class="size-5 shrink-0 font-semibold" /> New
</div>
</div>
<div
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium"
:class="{ 'opacity-50': isDisabled }"
>
<Avatar
:src="iconUrl"
@@ -37,72 +51,122 @@
Using {{ projectData?.title || 'Unknown' }}
</div>
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
>
<SparklesIcon class="size-5 shrink-0" /> New server
</div>
<ServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
:server-data="
isConfiguring
? { net }
: {
game,
mc_version,
loader,
loader_version,
net,
online,
players: playerCount
? { current: playerCount.current, max: playerCount.max }
: undefined,
}
"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:show-player-count="showPlayerCount"
:class="{ 'opacity-50': isDisabled }"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-primary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>
</NuxtLink>
<div
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"
>
<LoaderCircleIcon class="size-5 animate-spin" />
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<div
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
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">
<TriangleAlertIcon class="!size-5" /> Your server has been cancelled. Please update your
billing information or contact Modrinth Support for more information.
<div v-if="noticeType" class="server-listing-notice">
<div v-if="noticeType === 'provisioning'" class="flex gap-2">
Please wait while we set up your server. This can take up to 10 minutes.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended' && suspension_reason"
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">
<TriangleAlertIcon class="!size-5" /> Your server has been suspended:
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
for more information.
<div v-else-if="noticeType === 'upgrading'" class="flex gap-2">
Your server's hardware is currently being upgraded and will be back online shortly.
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-else-if="status === 'suspended'"
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">
<TriangleAlertIcon class="!size-5" /> Your server has been suspended. Please update your
billing information or contact Modrinth Support for more information.
<div v-else-if="noticeType === 'cancelled' || noticeType === 'paymentfailed'">
Your subscription was cancelled<template v-if="cancellationDate">
on
<span class="font-medium text-contrast">
{{ formatDate(cancellationDate) }}
</span></template
><template v-if="noticeType === 'paymentfailed'"> due to payment failure</template
>.<template v-if="!isFilesExpired">
Your files will be kept for
<span class="font-medium text-red"
>{{ filesRemainingDays }} more {{ filesRemainingDays === 1 ? 'day' : 'days' }}</span
>
and can be downloaded below before they're deleted.</template
>
</div>
<div v-else-if="noticeType === 'setToCancel'">
Your subscription is set to cancel<template v-if="cancellationDate">
on
<span class="font-medium text-contrast">
{{ formatDate(cancellationDate) }}
</span></template
>.<template v-if="!isFilesExpired">
Your files will be preserved for 30 days after cancellation.
</template>
</div>
<div v-else-if="noticeType === 'moderated'">
Your server has been suspended by moderation action.
</div>
<div v-else>
Your server has been suspended. Please contact Modrinth Support for more information.
</div>
<div v-if="noticeButtons" class="flex gap-2">
<ButtonStyled
v-if="noticeButtons.downloadBackup && onDownloadBackup"
type="outlined"
circular
>
<button
v-tooltip="'Download latest backup'"
class="!border-surface-5"
@click="onDownloadBackup"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.copyId" type="outlined">
<button
v-tooltip="'Copy code to clipboard'"
class="!border-surface-5"
@click="copyToClipboard(server_id)"
>
<template v-if="copied"> Copied <CheckIcon class="text-green" /> </template>
<template v-else> Copy ID <CopyIcon /> </template>
</button>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.support">
<a href="https://support.modrinth.com/en/" target="_blank"
><MessagesSquareIcon /> Support
</a>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.manageBilling" color="brand">
<AutoLink :to="`/settings/billing#server-${server_id}`">
<CardIcon /> Manage billing
</AutoLink>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.resubscribe && onResubscribe" color="brand">
<button @click="onResubscribe"><RotateCounterClockwiseIcon /> Resubscribe</button>
</ButtonStyled>
</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
<div
v-if="pendingChange && status !== 'suspended'"
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-orange bg-bg-orange p-4 text-sm font-bold text-contrast"
>
<div v-if="pendingChange && status !== 'suspended'" class="server-listing-notice">
<div>
Your server will {{ pendingChange.verb.toLowerCase() }} to the "{{
pendingChange.planSize
}}" plan on {{ formatDate(pendingChange.date) }}.
}}" plan on
<span class="font-medium text-contrast">{{ formatDate(pendingChange.date) }}</span
>.
</div>
<ServersSpecs
class="!font-normal !text-contrast"
class="!font-normal !text-primary"
:ram="Math.round((pendingChange.ramGb ?? 0) * 1024)"
:storage="Math.round((pendingChange.storageGb ?? 0) * 1024)"
:cpus="pendingChange.cpuBurst"
@@ -115,19 +179,25 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
ChevronRightIcon,
LoaderCircleIcon,
DownloadIcon,
LockIcon,
MessagesSquareIcon,
SparklesIcon,
TriangleAlertIcon,
SpinnerIcon,
} from '@modrinth/assets'
import { AutoLink, ButtonStyled } from '@modrinth/ui'
import { useQuery } from '@tanstack/vue-query'
import { computed } from 'vue'
import { computed, ref } from 'vue'
import {
CardIcon,
CheckIcon,
CopyIcon,
RotateCounterClockwiseIcon,
} from '../../../../assets/generated-icons'
import { useFormatDateTime } from '../../composables'
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'
@@ -159,14 +229,97 @@ type ServerListingProps = {
upstream?: Archon.Servers.v0.Upstream | null
flows?: Archon.Servers.v0.Flows
pendingChange?: PendingChange
online?: boolean
playerCount?: {
current?: number
max?: number
}
isProvisioning?: boolean
cancellationDate?: string | Date | null
onResubscribe?: (() => void) | null
onDownloadBackup?: (() => void) | null
}
const props = defineProps<ServerListingProps>()
const { archon, kyros, labrinth } = injectModrinthClient()
const { kyros, labrinth } = injectModrinthClient()
const showGameLabel = computed(() => !!props.game)
const showLoaderLabel = computed(() => !!props.loader)
const isConfiguring = computed(() => props.flows?.intro)
const isUpgrading = computed(
() => props.status === 'suspended' && props.suspension_reason === 'upgrading',
)
const isDisabled = computed(() => props.status === 'suspended' || props.isProvisioning)
const isSetToCancel = computed(() => !!props.cancellationDate && props.status !== 'suspended')
const filesRemainingDays = computed(() => {
if (!props.cancellationDate) return 0
const cancellation = new Date(props.cancellationDate)
const expiresAt = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000) // expires 30 days after cancellation
const remaining = Math.ceil((expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
return Math.max(0, remaining)
})
const isFilesExpired = computed(() => filesRemainingDays.value <= 0)
const hasIconOverlay = computed(
() => props.isProvisioning || isUpgrading.value || props.status === 'suspended',
)
type NoticeType =
| 'provisioning'
| 'upgrading'
| 'cancelled'
| 'paymentfailed'
| 'moderated'
| 'suspended'
| 'setToCancel'
const noticeType = computed<NoticeType | null>(() => {
if (props.isProvisioning) return 'provisioning'
if (props.status === 'suspended') {
switch (props.suspension_reason) {
case 'upgrading':
return 'upgrading'
case 'cancelled':
return 'cancelled'
case 'paymentfailed':
return 'paymentfailed'
case 'moderated':
return 'moderated'
default:
return 'suspended'
}
}
if (isSetToCancel.value) return 'setToCancel'
return null
})
type NoticeButtons = {
downloadBackup?: boolean
copyId?: boolean
support?: boolean
manageBilling?: boolean
resubscribe?: boolean
}
const noticeButtons = computed<NoticeButtons | null>(() => {
switch (noticeType.value) {
case 'cancelled':
case 'setToCancel':
return { downloadBackup: true, copyId: true, support: true, resubscribe: true }
case 'paymentfailed':
return { downloadBackup: true, copyId: true, support: true, manageBilling: true }
case 'moderated':
case 'suspended':
return { copyId: true, support: true }
default:
return null
}
})
const hasNotice = computed(() => !!noticeType.value || !!props.pendingChange)
const showGameLabel = computed(() => !!props.game && !isConfiguring.value)
const showLoaderLabel = computed(() => !!props.loader && !isConfiguring.value)
const showPlayerCount = computed(() => !!props.playerCount && !isConfiguring.value)
const { data: projectData } = useQuery({
queryKey: ['project', props.upstream?.project_id] as const,
@@ -207,14 +360,8 @@ const { data: image } = useQuery({
if (!props.server_id || props.status !== 'available') return null
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',
)
const blob = await kyros.files_v0.downloadFile('/server-icon-original.png')
return await processImageBlob(blob, 512)
} catch {
@@ -227,17 +374,12 @@ const { data: image } = useQuery({
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)
kyros.files_v0.uploadFile('/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,
)
kyros.files_v0.uploadFile('/server-icon-original.png', originalFile)
return scaledDataUrl
}
@@ -252,5 +394,19 @@ const { data: image } = useQuery({
enabled: computed(() => !!props.server_id && props.status === 'available'),
})
const isConfiguring = computed(() => props.flows?.intro)
const copied = ref(false)
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<style scoped>
.server-listing-notice {
@apply relative flex w-full rounded-b-2xl border-[1px] border-solid p-4 flex-col gap-4 border-surface-5 bg-bg-raised text-primary;
}
</style>
@@ -1,6 +1,6 @@
<template>
<div
class="experimental-styles-within flex size-16 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
class="experimental-styles-within relative flex size-16 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<client-only>
<img
@@ -16,6 +16,7 @@
:src="MinecraftServerIcon"
/>
</client-only>
<div v-if="disabled" class="absolute inset-0 bg-surface-1 opacity-50" />
</div>
</template>
@@ -24,5 +25,6 @@ import { MinecraftServerIcon } from '@modrinth/assets'
defineProps<{
image: string | undefined
disabled?: boolean
}>()
</script>
+2 -1
View File
@@ -4,7 +4,8 @@ export * from './icons'
export { default as InstallingBanner } from './InstallingBanner.vue'
export * from './labels'
export * from './marketing'
export type { PendingChange } from './ServerListing.vue'
export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue'
export type { PendingChange, PendingChange } from './ServerListing.vue'
export { default as ServerListing } from './ServerListing.vue'
export { default as ServerSetupModal } from './ServerSetupModal.vue'
export { default as ServersPromo } from './ServersPromo.vue'
@@ -0,0 +1,3 @@
<template>
<div class="experimental-styles-within h-1.5 w-1.5 bg-button-border rounded-full"></div>
</template>
@@ -2,9 +2,11 @@
<div
v-if="game"
v-tooltip="'Change server version'"
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
class="min-w-0 flex-none flex-row items-center gap-1.5 first:!flex"
>
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
<Separator v-if="!noSeparator" />
<GameIcon aria-hidden="true" />
<AutoLink
v-if="isLink"
:to="serverId ? `/hosting/manage/${serverId}/options/loader` : ''"
@@ -30,11 +32,13 @@ import { GameIcon } from '@modrinth/assets'
import { useRoute } from 'vue-router'
import AutoLink from '../../base/AutoLink.vue'
import Separator from './Separator.vue'
defineProps<{
game: string
mcVersion: string
isLink?: boolean
noSeparator?: boolean
}>()
const route = useRoute()
@@ -1,21 +1,29 @@
<template>
<div>
<ServerPlayerCount
v-if="showPlayerCount"
:current-players="serverData.players.current"
:max-players="serverData.players.max"
:online="serverData.online"
/>
<ServerGameLabel
v-if="showGameLabel"
:game="serverData.game"
:mc-version="serverData.mc_version ?? ''"
:no-separator="column || !showPlayerCount"
:is-link="linked"
/>
<ServerLoaderLabel
v-if="showLoaderLabel"
:loader="serverData.loader"
:loader-version="serverData.loader_version ?? ''"
:no-separator="column"
:no-separator="column || !showGameLabel"
:is-link="linked"
/>
<ServerSubdomainLabel
v-if="serverData.net?.domain"
:subdomain="serverData.net.domain"
:no-separator="column"
:no-separator="column || (!showLoaderLabel && !showGameLabel)"
:is-link="linked"
/>
<ServerUptimeLabel
@@ -29,6 +37,7 @@
<script setup lang="ts">
import ServerGameLabel from './ServerGameLabel.vue'
import ServerLoaderLabel from './ServerLoaderLabel.vue'
import ServerPlayerCount from './ServerPlayerCount.vue'
import ServerSubdomainLabel from './ServerSubdomainLabel.vue'
import ServerUptimeLabel from './ServerUptimeLabel.vue'
@@ -37,6 +46,7 @@ interface ServerInfoLabelsProps {
serverData: Record<string, any>
showGameLabel: boolean
showLoaderLabel: boolean
showPlayerCount?: boolean
uptimeSeconds?: number
column?: boolean
linked?: boolean
@@ -1,8 +1,8 @@
<template>
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-2 truncate">
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex flex-row items-center gap-2">
<LoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
<Separator v-if="!noSeparator" />
<div class="flex flex-row items-center gap-1.5">
<LoaderIcon v-if="loader" :loader="loader" />
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
<AutoLink
v-if="isLink"
@@ -38,6 +38,7 @@ import { useRoute } from 'vue-router'
import AutoLink from '../../base/AutoLink.vue'
import LoaderIcon from '../icons/LoaderIcon.vue'
import Separator from './Separator.vue'
defineProps<{
noSeparator?: boolean
@@ -0,0 +1,17 @@
<template>
<div class="flex gap-1">
<!-- indicator icon -->
{{ currentPlayers }} / {{ maxPlayers }} Players
</div>
</template>
<script setup lang="ts">
interface ServerPlayerCountProps {
currentPlayers: number
maxPlayers: number
online: boolean
}
defineProps<ServerPlayerCountProps>()
</script>
@@ -4,9 +4,10 @@
v-tooltip="'Copy custom URL'"
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer"
>
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex flex-row items-center gap-2">
<LinkIcon class="flex size-5 shrink-0" />
<Separator v-if="!noSeparator" />
<div class="flex flex-row items-center gap-1.5">
<LinkIcon />
<div
class="flex min-w-0 text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
@@ -25,6 +26,8 @@ import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Separator from './Separator.vue'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
@@ -5,10 +5,10 @@
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-2"
data-pyro-uptime
>
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<Separator v-if="!noSeparator" />
<div class="flex gap-2">
<TimerIcon class="flex size-5 shrink-0" />
<div class="flex gap-1.5">
<TimerIcon />
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
{{ formattedUptime }}
</time>
@@ -20,6 +20,8 @@
import { TimerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Separator from './Separator.vue'
const props = defineProps<{
uptimeSeconds: number
noSeparator?: boolean
@@ -75,7 +75,7 @@
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-primary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</AutoLink>
@@ -0,0 +1,251 @@
<template>
<div class="flex gap-8 items-center justify-center py-10">
<!-- Left column -->
<div class="flex flex-col gap-8 items-start pr-8 shrink-0 w-[380px]">
<!-- Heading -->
<div class="flex flex-col gap-2 items-start w-[300px]">
<p class="text-sm text-secondary">Modrinth Hosting</p>
<p class="text-[30px] leading-9 font-semibold text-contrast">No servers yet</p>
<p class="text-base font-normal text-primary">
Install mods, invite friends, and play together all from the Modrinth App.
</p>
</div>
<!-- Feature list -->
<div class="flex flex-col gap-4 items-start w-full">
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center"
>
<PackageOpenIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">One-click mod installs</p>
<p class="text-base font-normal text-primary">
Pick your favourite mods and we handle the rest.
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
>
<GlobeIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">Simple setup</p>
<p class="text-base font-normal text-primary">
Set up your server just like a singleplayer world.
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
>
<UsersIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">Play with friends</p>
<p class="text-base font-normal text-primary">
Invite friends and get them set up right in the Modrinth App.
</p>
</div>
</div>
</div>
<!-- CTA section -->
<div class="flex flex-col gap-6 items-start w-[300px]">
<div class="flex flex-col gap-3 items-start">
<ButtonStyled color="brand">
<button @click="onClickNewServer?.()">
<PlusIcon aria-hidden="true" />
New server
</button>
</ButtonStyled>
<AutoLink
to="https://modrinth.com/hosting"
target="_blank"
class="flex items-center gap-1"
>
Learn more about Modrinth Hosting
<RightArrowIcon class="size-5 shrink-0" aria-hidden="true" />
</AutoLink>
</div>
<template v-if="!loggedIn">
<div class="h-px w-full bg-surface-5" />
<div class="flex gap-3 items-center flex-wrap">
<p class="text-base font-normal text-primary">Already have a server?</p>
<ButtonStyled>
<button @click="onClickSignIn?.()">
<LogInIcon aria-hidden="true" />
Sign in
</button>
</ButtonStyled>
</div>
</template>
</div>
</div>
<!-- Right column - mod icon grid -->
<div
class="relative flex h-[617px] w-[380px] shrink-0 items-center justify-center overflow-hidden rounded-[40px]"
>
<div class="rotate-[15deg]">
<div class="flex flex-col gap-4">
<div
v-for="row in GRID_ROWS"
:key="row"
class="flex gap-4 items-center shrink-0"
:class="animated ? (row % 2 === 1 ? 'drift-left' : 'drift-right relative left-14') : ''"
>
<div class="hidden drift-right drift-left"></div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
</div>
</div>
</div>
<!-- Gradient overlay fading to page background -->
<div
class="absolute inset-0 rounded-[40px] pointer-events-none bg-gradient-to-b from-transparent to-[var(--color-bg)]"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {
GlobeIcon,
LogInIcon,
PackageOpenIcon,
PlusIcon,
RightArrowIcon,
UsersIcon,
} from '@modrinth/assets'
import { AutoLink } from '@modrinth/ui'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import imgAircraft from './grid-images/aircraft.png'
import imgAlexs from "./grid-images/alex's.png"
import imgArtifacts from './grid-images/artifacts.png'
import imgBiomes from './grid-images/biomes.png'
import imgCatac from './grid-images/catac.png'
import imgCobble from './grid-images/cobble.png'
import imgComforts from './grid-images/comforts.png'
import imgCreate from './grid-images/create.png'
import imgCreate1 from './grid-images/create1.png'
import imgCreate2 from './grid-images/create2.png'
import imgCreate3 from './grid-images/create3.png'
import imgCreeper from './grid-images/creeper.png'
import imgFriends from './grid-images/friends.png'
import imgGeo from './grid-images/geo.png'
import imgNaturalist from './grid-images/naturalist.png'
import imgSeasons from './grid-images/seasons.png'
import imgTravellers from './grid-images/travellers.png'
import imgTree from './grid-images/tree.png'
import imgYum1 from './grid-images/yum1.png'
import imgYum2 from './grid-images/yum2.png'
import imgYum3 from './grid-images/yum3.png'
import imgYung from './grid-images/yung.png'
withDefaults(
defineProps<{
animated?: boolean
onClickNewServer?: () => void
onClickSignIn?: () => void
loggedIn?: boolean
}>(),
{ animated: false },
)
const GRID_ROWS = 6
const GRID_COLS = 5
const GRID_IMAGES = [
imgYum1,
imgYum2,
imgYum3,
imgYung,
imgCreeper,
imgFriends,
imgNaturalist,
imgBiomes,
imgCatac,
imgCobble,
imgGeo,
imgCreate,
imgCreate1,
imgCreate2,
imgCreate3,
imgAircraft,
imgArtifacts,
imgComforts,
imgTravellers,
imgAlexs,
imgSeasons,
imgTree,
]
function getGridImage(row: number, col: number): string {
return GRID_IMAGES[(row * GRID_COLS + col) % GRID_IMAGES.length]
}
</script>
<style scoped>
p {
margin: 0;
}
@keyframes drift-right {
from {
transform: translateX(-33%);
}
to {
transform: translateX(33%);
}
}
@keyframes drift-left {
from {
transform: translateX(33%);
}
to {
transform: translateX(-33%);
}
}
.drift-left {
animation: drift-left linear infinite alternate;
animation-duration: 400s;
}
.drift-right {
animation: drift-right linear infinite alternate;
animation-duration: 400s;
}
</style>
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -3,16 +3,31 @@
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
<ModrinthServersPurchaseModal
v-if="customer && regions"
:key="`purchase-modal-${customer.id}`"
ref="purchaseModal"
:publishable-key="props.stripePublishableKey"
:initiate-payment="
async (body) => await client.labrinth.billing_internal.initiatePayment(body)
"
:available-products="pyroProducts"
:on-error="handleError"
:customer="customer"
:payment-methods="paymentMethods ?? []"
:currency="selectedCurrency"
:return-url="`${props.siteUrl}/hosting/manage`"
:pings="regionPings"
:regions="regions"
:refresh-payment-methods="fetchPaymentData"
:fetch-stock="fetchStock"
:affiliate-code="affiliateCode"
plan-stage
/>
<ResubscribeModal ref="resubscribeModal" @resubscribe="handleResubscribeConfirm" />
<div
v-if="hasError || fetchError"
v-if="hasError"
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">
@@ -85,21 +100,13 @@
<div
v-else-if="serverList.length === 0 && !isPollingForNewServers"
key="empty"
class="flex h-full flex-col items-center justify-center gap-8"
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
>
<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%);
"
<ServerListEmpty
:logged-in="loggedIn"
@click-new-server="openPurchaseModal"
@click-sign-in="handleSignIn"
/>
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
<p class="m-0">Modrinth Hosting 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">
@@ -116,11 +123,15 @@
placeholder="Search servers..."
wrapper-class="w-full md:w-72"
/>
<ButtonStyled v-if="isNuxt" type="standard">
<AutoLink :to="{ path: '/servers', hash: '#plan' }">
<ButtonStyled type="standard">
<AutoLink v-if="isNuxt" :to="{ path: '/servers', hash: '#plan' }">
<PlusIcon />
New server
</AutoLink>
<button v-else :disabled="!canOpenPurchaseModal" @click="openPurchaseModal">
<PlusIcon />
New server
</button>
</ButtonStyled>
</div>
</div>
@@ -152,17 +163,22 @@
v-for="server in filteredData.filter((s) => s.is_medal)"
:key="server.server_id"
v-bind="server"
@upgrade="openUpgradeModal(server.server_id)"
@upgrade="openPurchaseModal"
/>
<ServerListing
v-for="server in filteredData.filter((s) => !s.is_medal)"
:key="server.server_id"
v-bind="server"
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
/>
</TransitionGroup>
<div v-else class="flex h-full items-center justify-center">
<div v-else-if="isLoading" class="flex h-full items-center justify-center">
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
</div>
<div v-else>No servers found.</div>
</div>
</Transition>
</div>
@@ -171,32 +187,44 @@
<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, StyledInput } from '@modrinth/ui'
import {
AutoLink,
ButtonStyled,
CopyCode,
injectAuth,
injectModrinthClient,
injectNotificationManager,
ModrinthServersPurchaseModal,
ResubscribeModal,
ServerListEmpty,
StyledInput,
} from '@modrinth/ui'
import type { ModrinthServersFetchError } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { useIntervalFn } from '@vueuse/core'
import dayjs from 'dayjs'
import Fuse from 'fuse.js'
import type { ComponentPublicInstance } from 'vue'
import type Stripe from 'stripe'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
import ServerListing from '#ui/components/servers/ServerListing.vue'
defineProps<{
stripePublishableKey?: string
siteUrl?: string
products?: Labrinth.Billing.Internal.Product[]
const props = defineProps<{
stripePublishableKey: string
siteUrl: string
products: Labrinth.Billing.Internal.Product[]
}>()
const router = useRouter()
const route = useRoute()
const auth = injectAuth()
const client = injectModrinthClient()
const loggedIn = computed(() => !!auth.user.value)
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const hasError = ref(false)
const isPollingForNewServers = ref(false)
const pollingState = ref({
enabled: false,
@@ -204,6 +232,159 @@ const pollingState = ref({
initialServers: [] as Archon.Servers.v0.Server[],
})
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
const affiliateCode = ref<string | null>(null)
const selectedCurrency = ref<string>('USD')
const regionPings = ref<
{
region: string
ping: number
}[]
>([])
const pyroProducts = computed(() => {
return [...props.products]
.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
})
})
const {
data: customer,
refetch: refetchCustomer,
isLoading: customerLoading,
} = useQuery({
queryKey: ['billing', 'customer'],
queryFn: () => client.labrinth.billing_internal.getCustomer() as Promise<Stripe.Customer>,
enabled: loggedIn,
})
const {
data: paymentMethods,
refetch: refetchPaymentMethods,
isLoading: paymentMethodsLoading,
} = useQuery({
queryKey: ['billing', 'payment-methods'],
queryFn: () =>
client.labrinth.billing_internal.getPaymentMethods() as Promise<Stripe.PaymentMethod[]>,
enabled: loggedIn,
})
const { data: regions, isLoading: regionsLoading } = useQuery({
queryKey: ['servers', 'regions'],
queryFn: () => client.archon.servers_v1.getRegions(),
enabled: loggedIn,
})
watch(
regions,
(newRegions) => {
regionPings.value = []
if (newRegions) {
newRegions.forEach((region) => {
runPingTest(region)
})
}
},
{ immediate: true },
)
async function fetchPaymentData() {
await Promise.all([refetchCustomer(), refetchPaymentMethods()])
}
async function fetchStock(
region: Archon.Servers.v1.Region,
request: Archon.Servers.v0.StockRequest,
): Promise<number> {
const result = await client.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 = regionPings.value.filter((entry) => entry.region !== region.shortcode)
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[] = []
let finalized = false
const finalize = (ping: number) => {
if (finalized) return
finalized = true
clearTimeout(connectTimeout)
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
regionPings.value.push({
region: region.shortcode,
ping,
})
socket.close()
}
const retryNext = () => {
if (finalized) return
finalized = true
clearTimeout(connectTimeout)
socket.close()
runPingTest(region, index + 1)
}
// Prevent hangs where the socket never opens or errors.
const connectTimeout = setTimeout(() => {
retryNext()
}, 3000)
socket.onopen = () => {
clearTimeout(connectTimeout)
for (let i = 0; i < PING_COUNT; i++) {
setTimeout(() => {
socket.send(String(performance.now()))
}, i * PING_INTERVAL)
}
setTimeout(
() => {
const median =
pings.length > 0
? Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
: -1
finalize(median)
},
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
)
}
socket.onmessage = (event) => {
const start = Number(event.data)
pings.push(performance.now() - start)
}
socket.onerror = () => {
retryNext()
}
} catch {
runPingTest(region, index + 1)
}
}
const {
data: serverResponse,
error: fetchError,
@@ -245,11 +426,10 @@ const {
return response
},
refetchInterval: computed(() => (pollingState.value.enabled ? 5000 : false)),
enabled: loggedIn,
})
watch([fetchError, serverResponse], ([error, response]) => {
hasError.value = !!error || !response
})
const hasError = computed(() => loggedIn.value && !!fetchError.value)
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
if (!serverResponse.value) return []
@@ -273,13 +453,23 @@ function introToTop(array: Archon.Servers.v0.Server[]): Archon.Servers.v0.Server
})
}
// files expire 30 days after cancellation
function filesExpired(server: Archon.Servers.v0.Server): boolean {
if (server.status !== 'suspended' || server.suspension_reason !== 'cancelled') return false
const cancellationDate = serverBillingMap.value.get(server.server_id)?.cancellationDate
if (!cancellationDate) return false
const cancellation = new Date(cancellationDate)
const thirtyDaysLater = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000)
return new Date() > thirtyDaysLater
}
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))
: []
const base = !searchInput.value.trim()
? introToTop(serverList.value)
: fuse.value
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
: []
return base.filter((server) => !filesExpired(server))
})
// Start polling only after initial data is available so the baseline is correct
@@ -299,14 +489,309 @@ watch(serverResponse, (response) => {
}
})
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
open: (id: string) => void | Promise<void>
}>
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
function openUpgradeModal(serverId: string) {
upgradeModal.value?.open(serverId)
const canOpenPurchaseModal = computed(() => {
return (
Boolean(props.stripePublishableKey) &&
Boolean(customer.value) &&
paymentMethods.value !== undefined &&
Boolean(regions.value) &&
!customerLoading.value &&
!paymentMethodsLoading.value &&
!regionsLoading.value
)
})
function handleError(err: unknown) {
const error = err as Error & { data?: { description?: string } }
addNotification({
title: 'An error occurred',
type: 'error',
text: error?.message ?? error?.data?.description ?? String(err),
})
}
function openPurchaseModal() {
if (!canOpenPurchaseModal.value || !purchaseModal.value) {
addNotification({
title: 'Purchase unavailable',
text: 'Payment information is still loading. Please try again in a moment.',
type: 'warning',
})
return
}
purchaseModal.value.show('quarterly')
}
function handleSignIn() {
void auth.requestSignIn('/hosting/manage')
}
const { data: subscriptions } = useQuery({
queryKey: ['billing', 'subscriptions'],
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
enabled: loggedIn,
})
const { data: charges } = useQuery({
queryKey: ['billing', 'payments'],
queryFn: () => client.labrinth.billing_internal.getPayments(),
enabled: loggedIn,
})
const CHARGE_POLL_INTERVAL_MS = 20_000
const hasProvisioningSubscription = computed(() => {
if (!subscriptions.value || !charges.value) return false
return subscriptions.value
.filter((s) => s?.metadata?.type === 'pyro')
.some((sub) => {
if (sub.status !== 'unprovisioned') return false
const charge = charges.value?.find((c) => c.subscription_id === sub.id)
return charge?.status === 'processing' || charge?.status === 'open'
})
})
const { pause: pauseChargePoll, resume: resumeChargePoll } = useIntervalFn(
() => {
queryClient.invalidateQueries({ queryKey: ['billing', 'payments'] })
queryClient.invalidateQueries({ queryKey: ['billing', 'subscriptions'] })
queryClient.invalidateQueries({ queryKey: ['servers'] })
},
CHARGE_POLL_INTERVAL_MS,
{ immediate: false },
)
watch(
hasProvisioningSubscription,
(isProvisioning) => {
if (isProvisioning) {
resumeChargePoll()
} else {
pauseChargePoll()
}
},
{ immediate: true },
)
const { data: serverFullList } = useQuery({
queryKey: ['servers', 'v1'],
queryFn: () => client.archon.servers_v1.list(),
enabled: loggedIn,
})
type ServerBillingInfo = {
cancellationDate?: string | null
isProvisioning?: boolean
onResubscribe?: () => void
onDownloadBackup?: (() => void) | null
}
type ResubscribeRequest = {
subscriptionId: string
wasSuspended: boolean
}
function getLatestBackupDownload(serverId: string): (() => void) | null {
const serverFull = serverFullList.value?.find((s) => s.id === serverId)
if (!serverFull) return null
const activeWorld = serverFull.worlds.find((w) => w.is_active) ?? serverFull.worlds[0]
if (!activeWorld?.backups?.length) return null
const latestBackup = activeWorld.backups
.filter((b) => b.status === 'done')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
if (!latestBackup) return null
return async () => {
try {
const server = await client.archon.servers_v0.get(serverId)
const kyrosUrl = server.node?.instance
const jwt = server.node?.token
if (!kyrosUrl || !jwt) {
addNotification({
title: 'Download unavailable',
text: 'Server connection info is not available. Please contact support.',
type: 'error',
})
return
}
window.open(
`https://${kyrosUrl}/modrinth/v0/backups/${latestBackup.id}/download?auth=${jwt}`,
'_blank',
)
} catch {
addNotification({
title: 'Download failed',
text: 'An error occurred while trying to download the backup.',
type: 'error',
})
}
}
}
function getProductFromPriceId(priceId: string | null | undefined) {
if (!priceId) return null
return (
pyroProducts.value.find((product) => product.prices.some((price) => price.id === priceId)) ??
null
)
}
function getPlanName(product: Labrinth.Billing.Internal.Product | null): string {
if (!product) return 'Medium plan'
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return 'Medium plan'
switch (product.metadata.ram) {
case 4096:
return 'Small plan'
case 6144:
return 'Medium plan'
case 8192:
return 'Large plan'
default:
return 'Custom plan'
}
}
function getRamGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.ram / 1024
}
function getStorageGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.storage / 1024
}
function getSharedCpus(product: Labrinth.Billing.Internal.Product | null): number | undefined {
if (!product) return undefined
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
return product.metadata.cpu / 2
}
function getRecurringPrice(
product: Labrinth.Billing.Internal.Product | null,
interval: Labrinth.Billing.Internal.PriceDuration,
preferredCurrency?: string,
): { amount: number; currencyCode: string } | null {
if (!product) return null
const recurringPrices = product.prices.filter((price) => price.prices.type === 'recurring')
const preferredPrice = preferredCurrency
? recurringPrices.find((price) => price.currency_code === preferredCurrency)
: undefined
const usdPrice = recurringPrices.find((price) => price.currency_code === 'USD')
const selectedPrice = preferredPrice ?? usdPrice ?? recurringPrices[0]
if (!selectedPrice || selectedPrice.prices.type !== 'recurring') return null
return {
amount: selectedPrice.prices.intervals[interval],
currencyCode: selectedPrice.currency_code,
}
}
function openResubscribeModal(
serverId: string,
subscription: Labrinth.Billing.Internal.UserSubscription,
charge?: Labrinth.Billing.Internal.Charge | null,
) {
const displayInterval = charge?.subscription_interval ?? subscription.interval
const displayPriceId = charge?.price_id ?? subscription.price_id
const product = getProductFromPriceId(displayPriceId)
const fallbackPrice = getRecurringPrice(product, displayInterval, charge?.currency_code)
resubscribeModal.value?.show({
subscriptionId: subscription.id,
wasSuspended: !!charge?.due && dayjs(charge.due).isBefore(dayjs()),
serverName:
serverList.value.find((server) => server.server_id === serverId)?.name ?? 'this server',
planName: getPlanName(product),
ramGb: getRamGb(product),
storageGb: getStorageGb(product),
sharedCpus: getSharedCpus(product),
priceCents: charge?.amount ?? fallbackPrice?.amount,
currencyCode: charge?.currency_code ?? fallbackPrice?.currencyCode,
interval: displayInterval,
nextChargeDate: charge?.due,
})
}
async function handleResubscribeConfirm({ subscriptionId, wasSuspended }: ResubscribeRequest) {
try {
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
cancelled: false,
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing'] }),
queryClient.invalidateQueries({ queryKey: ['servers'] }),
])
if (wasSuspended) {
addNotification({
title: 'Resubscription request submitted',
text: 'If the server is currently cancelled, it may take up to 10 minutes for another charge attempt to be made.',
type: 'success',
})
} else {
addNotification({
title: 'Success',
text: 'Server subscription resubscribed successfully',
type: 'success',
})
}
} catch {
addNotification({
title: 'Error resubscribing',
text: 'An error occurred while resubscribing to your Modrinth server.',
type: 'error',
})
}
}
const serverBillingMap = computed(() => {
const map = new Map<string, ServerBillingInfo>()
if (!subscriptions.value || !charges.value) return map
const pyroSubs = subscriptions.value.filter((s) => s?.metadata?.type === 'pyro')
for (const sub of pyroSubs) {
const serverId = (sub.metadata as { id?: string })?.id
if (!serverId) continue
const charge = charges.value.find(
(c) => c.subscription_id === sub.id && c.status !== 'succeeded',
)
const info: ServerBillingInfo = {
isProvisioning:
sub.status === 'unprovisioned' &&
(charge?.status === 'processing' || charge?.status === 'open'),
}
info.onDownloadBackup = getLatestBackupDownload(serverId)
if (charge?.status === 'cancelled') {
info.cancellationDate = charge.due
info.onResubscribe = () => openResubscribeModal(serverId, sub, charge)
}
map.set(serverId, info)
}
return map
})
</script>
<style scoped>
+17 -8
View File
@@ -2600,39 +2600,48 @@
"servers.purchase.step.payment.title": {
"defaultMessage": "Payment method"
},
"servers.purchase.step.plan.billed": {
"defaultMessage": "billed {interval}"
"servers.purchase.step.plan.billing-subtitle": {
"defaultMessage": "Available in North America, Europe, and Southeast Asia."
},
"servers.purchase.step.plan.custom.desc": {
"defaultMessage": "Pick a customized plan with just the specs you need."
},
"servers.purchase.step.plan.custom.heading": {
"defaultMessage": "Know exactly what you need?"
},
"servers.purchase.step.plan.get-started": {
"defaultMessage": "Get started"
},
"servers.purchase.step.plan.large": {
"defaultMessage": "Large"
},
"servers.purchase.step.plan.large.desc": {
"defaultMessage": "Ideal for 1525 players, modpacks, or heavy modding."
},
"servers.purchase.step.plan.medium": {
"defaultMessage": "Medium"
},
"servers.purchase.step.plan.medium.desc": {
"defaultMessage": "Great for 615 players and multiple mods."
},
"servers.purchase.step.plan.most-popular": {
"defaultMessage": "Most Popular"
},
"servers.purchase.step.plan.prompt": {
"defaultMessage": "Choose a plan"
},
"servers.purchase.step.plan.select": {
"defaultMessage": "Select Plan"
},
"servers.purchase.step.plan.small": {
"defaultMessage": "Small"
},
"servers.purchase.step.plan.small.desc": {
"defaultMessage": "Perfect for 15 friends with a few light mods."
},
"servers.purchase.step.plan.subtitle": {
"defaultMessage": "Pick the amount of RAM and specs that fit your needs."
},
"servers.purchase.step.plan.title": {
"defaultMessage": "Plan"
},
"servers.purchase.step.plan.your-current-plan": {
"defaultMessage": "Your current plan"
},
"servers.purchase.step.region.title": {
"defaultMessage": "Region"
},
+12
View File
@@ -0,0 +1,12 @@
import type { Labrinth } from '@modrinth/api-client/src/modules/labrinth/types'
import type { Ref } from 'vue'
import { createContext } from './create-context'
export interface AuthProvider {
session_token: Ref<string | null>
user: Ref<Labrinth.Users.v2.User | null>
requestSignIn: (redirectPath: string) => void | Promise<void>
}
export const [injectAuth, provideAuth] = createContext<AuthProvider>('root', 'auth')
+1
View File
@@ -1,5 +1,6 @@
export * from './api-client'
export * from './app-backup'
export * from './auth'
export * from './content-manager'
export { createContext } from './create-context'
export * from './file-picker'
@@ -0,0 +1,16 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ServerListEmpty from '../../components/servers/server-list-empty/ServerListEmpty.vue'
const meta = {
title: 'Servers/ServerListEmpty',
component: ServerListEmpty,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ServerListEmpty>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
@@ -0,0 +1,156 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ServerListing from '../../components/servers/ServerListing.vue'
const baseServer = {
server_id: '8759b459-6f9e-49e0-aa70-dfab5b2abb2f',
name: 'Survival SMP',
status: 'available',
game: 'Minecraft',
mc_version: '1.21.5',
loader: 'Fabric',
loader_version: '0.16.14',
net: {
ip: '203.0.113.42',
port: 25565,
domain: 'play',
},
} as const
const pendingChange = {
planSize: 'Performance 8 GB',
cpu: 4,
cpuBurst: 6,
ramGb: 8,
storageGb: 80,
date: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(),
verb: 'Upgrade',
} as const
const meta = {
title: 'Servers/ServerListing',
component: ServerListing,
parameters: {
layout: 'padded',
},
decorators: [
(story) => ({
components: { story },
template: '<div style="max-width: 920px;"><story /></div>',
}),
],
} satisfies Meta<typeof ServerListing>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
...baseServer,
},
}
export const ConfiguringNewServer: Story = {
args: {
...baseServer,
name: 'Fresh Vanilla World',
status: 'installing',
flows: { intro: true },
loader: 'Vanilla',
loader_version: null,
},
}
export const WithPendingDowngrade: Story = {
args: {
...baseServer,
name: 'Competitive UHC',
pendingChange: {
...pendingChange,
planSize: 'Small',
ramGb: 4,
storageGb: 40,
cpuBurst: 4,
verb: 'Downgrade',
},
},
}
export const SuspendedUpgrading: Story = {
args: {
...baseServer,
name: 'Creative Build Team',
status: 'suspended',
suspension_reason: 'upgrading',
},
}
export const Provisioning: Story = {
args: {
...baseServer,
name: 'My New Server',
status: 'installing',
},
}
export const SetToCancel: Story = {
args: {
...baseServer,
name: 'Survival SMP',
cancellationDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
onResubscribe: () => alert('Resubscribe clicked'),
onDownloadBackup: () => alert('Download backup clicked'),
},
}
export const SuspendedCancelled: Story = {
args: {
...baseServer,
name: 'Old Event Server',
status: 'suspended',
suspension_reason: 'cancelled',
cancellationDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
onResubscribe: () => alert('Resubscribe clicked'),
onDownloadBackup: () => alert('Download backup clicked'),
},
}
export const SuspendedCancelledFilesExpired: Story = {
args: {
...baseServer,
onDownloadBackup: null,
name: 'Old Event Server',
status: 'suspended',
suspension_reason: 'cancelled',
cancellationDate: new Date(2025, 1, 17).toISOString(),
onResubscribe: () => alert('Resubscribe clicked'),
},
}
export const SuspendedPaymentFailed: Story = {
args: {
...baseServer,
name: 'Minigames Network',
status: 'suspended',
suspension_reason: 'paymentfailed',
cancellationDate: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
onDownloadBackup: () => alert('Download backup clicked'),
},
}
export const SuspendedModerated: Story = {
args: {
...baseServer,
name: 'Banned Server',
status: 'suspended',
suspension_reason: 'moderated',
},
}
export const SuspendedGeneric: Story = {
args: {
...baseServer,
name: 'Server with other suspension reason',
status: 'suspended',
},
}