diff --git a/.vscode/settings.json b/.vscode/settings.json index d4d338f6dc..7522198819 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -29,5 +29,7 @@ }, "[rust]": { "editor.defaultFormatter": "rust-lang.rust-analyzer" - } + }, + "css.lint.unknownAtRules": "ignore", + "scss.lint.unknownAtRules": "ignore" } diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index a4bc69aafc..06e392bf3d 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -970,13 +970,6 @@ provideAppUpdateDownloadProgress(appUpdateDownload) - - - + + +
{{ formatMessage(messages.subscriptionDescription) }}
@@ -422,14 +425,7 @@ " color="green" > - + Resubscribe @@ -617,6 +613,7 @@ import { OverflowMenu, paymentMethodMessages, PurchaseModal, + ResubscribeModal, ServerListing, useFormatDateTime, useFormatPrice, @@ -624,7 +621,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' @@ -764,6 +762,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( @@ -793,16 +796,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)), @@ -912,13 +937,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 'Unknown' const ramSize = product.metadata.ram @@ -954,16 +986,42 @@ 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: 'Resubscription request submitted', - 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', }) } else { @@ -982,6 +1040,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'] }), diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts index 357a73ab7e..7e57fc2a53 100644 --- a/packages/api-client/src/platform/tauri.ts +++ b/packages/api-client/src/platform/tauri.ts @@ -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) } diff --git a/packages/ui/.storybook/preview.ts b/packages/ui/.storybook/preview.ts index d775c85df0..9b340008eb 100644 --- a/packages/ui/.storybook/preview.ts +++ b/packages/ui/.storybook/preview.ts @@ -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, diff --git a/packages/ui/src/components/base/CopyCode.vue b/packages/ui/src/components/base/CopyCode.vue index f046b5f12c..7e3edb33f6 100644 --- a/packages/ui/src/components/base/CopyCode.vue +++ b/packages/ui/src/components/base/CopyCode.vue @@ -1,5 +1,9 @@ - + {{ text }} @@ -27,42 +31,3 @@ async function copyText() { copied.value = true } - - diff --git a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue index edfd64ab1d..323d9f3df4 100644 --- a/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue +++ b/packages/ui/src/components/billing/ModrinthServersPurchaseModal.vue @@ -374,7 +374,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" diff --git a/packages/ui/src/components/billing/ResubscribeModal.vue b/packages/ui/src/components/billing/ResubscribeModal.vue new file mode 100644 index 0000000000..68c00859ac --- /dev/null +++ b/packages/ui/src/components/billing/ResubscribeModal.vue @@ -0,0 +1,201 @@ + + + + Resubscribe to Server + + + + + You are about to resubscribe to + {{ modalData.serverName }}. Your subscription will be reactivated and your server will continue running without + interruption. + + + + Plan + + + {{ modalData.planName }} + + {{ modalData.ramGb }} GB RAM + + {{ modalData.storageGb }} GB Storage + + {{ modalData.sharedCpus }} Shared CPUs + + + + + {{ formattedPrice }} + + /{{ intervalLabel }} + + + + + + Your next charge will be on + {{ formattedNextChargeDate }}. + + + + + + + + + Cancel + + + + + + Resubscribe + + + + + + + + diff --git a/packages/ui/src/components/billing/ServersPurchase3Review.vue b/packages/ui/src/components/billing/ServersPurchase3Review.vue index bcd83a5269..bfe6096c8d 100644 --- a/packages/ui/src/components/billing/ServersPurchase3Review.vue +++ b/packages/ui/src/components/billing/ServersPurchase3Review.vue @@ -179,7 +179,7 @@ function setInterval(newInterval: ServerBillingInterval) { - + {{ planName }} @@ -194,9 +194,7 @@ function setInterval(newInterval: ServerBillingInterval) { /> - + - + No payment required. Your downgrade will apply at the end of the current billing period. diff --git a/packages/ui/src/components/billing/index.ts b/packages/ui/src/components/billing/index.ts index f2ef22353b..2f96020354 100644 --- a/packages/ui/src/components/billing/index.ts +++ b/packages/ui/src/components/billing/index.ts @@ -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' diff --git a/packages/ui/src/components/servers/ServerListing.vue b/packages/ui/src/components/servers/ServerListing.vue index 40c460b989..6b461c2c8e 100644 --- a/packages/ui/src/components/servers/ServerListing.vue +++ b/packages/ui/src/components/servers/ServerListing.vue @@ -1,32 +1,46 @@ - + - - + + + + - {{ name }} - + + {{ name }} + + + New + - - New server - - - - Your server's hardware is currently being upgraded and will be back online shortly. - - - - Your server has been cancelled. Please update your - billing information or contact Modrinth Support for more information. + + + + Please wait while we set up your server. This can take up to 10 minutes. - - - - - Your server has been suspended: - {{ suspension_reason }}. Please update your billing information or contact Modrinth Support - for more information. + + Your server's hardware is currently being upgraded and will be back online shortly. - - - - - Your server has been suspended. Please update your - billing information or contact Modrinth Support for more information. + + Your subscription was cancelled + on + + {{ formatDate(cancellationDate) }} + due to payment failure. + Your files will be kept for + {{ filesRemainingDays }} more {{ filesRemainingDays === 1 ? 'day' : 'days' }} + and can be downloaded below before they're deleted. + + + Your subscription is set to cancel + on + + {{ formatDate(cancellationDate) }} + . + Your files will be preserved for 30 days after cancellation. + + + + Your server has been suspended by moderation action. + + + Your server has been suspended. Please contact Modrinth Support for more information. + + + + + + + + + + + Copied + Copy ID + + + + Support + + + + + Manage billing + + + + Resubscribe + - - + + Your server will {{ pendingChange.verb.toLowerCase() }} to the "{{ pendingChange.planSize - }}" plan on {{ formatDate(pendingChange.date) }}. + }}" plan on + {{ formatDate(pendingChange.date) }}. 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() -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(() => { + 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(() => { + 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) +} + + diff --git a/packages/ui/src/components/servers/icons/ServerIcon.vue b/packages/ui/src/components/servers/icons/ServerIcon.vue index 58d7cb3a03..77df78d5de 100644 --- a/packages/ui/src/components/servers/icons/ServerIcon.vue +++ b/packages/ui/src/components/servers/icons/ServerIcon.vue @@ -1,6 +1,6 @@ + @@ -24,5 +25,6 @@ import { MinecraftServerIcon } from '@modrinth/assets' defineProps<{ image: string | undefined + disabled?: boolean }>() diff --git a/packages/ui/src/components/servers/labels/Separator.vue b/packages/ui/src/components/servers/labels/Separator.vue new file mode 100644 index 0000000000..f434629a32 --- /dev/null +++ b/packages/ui/src/components/servers/labels/Separator.vue @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/components/servers/labels/ServerGameLabel.vue b/packages/ui/src/components/servers/labels/ServerGameLabel.vue index 6bbbbdec5f..5f64f66381 100644 --- a/packages/ui/src/components/servers/labels/ServerGameLabel.vue +++ b/packages/ui/src/components/servers/labels/ServerGameLabel.vue @@ -2,9 +2,11 @@ - + + + () const route = useRoute() diff --git a/packages/ui/src/components/servers/labels/ServerInfoLabels.vue b/packages/ui/src/components/servers/labels/ServerInfoLabels.vue index aaf5e00132..9b1e6638e2 100644 --- a/packages/ui/src/components/servers/labels/ServerInfoLabels.vue +++ b/packages/ui/src/components/servers/labels/ServerInfoLabels.vue @@ -1,21 +1,29 @@ + 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 showGameLabel: boolean showLoaderLabel: boolean + showPlayerCount?: boolean uptimeSeconds?: number column?: boolean linked?: boolean diff --git a/packages/ui/src/components/servers/labels/ServerLoaderLabel.vue b/packages/ui/src/components/servers/labels/ServerLoaderLabel.vue index 8b7de765de..e4d45d633f 100644 --- a/packages/ui/src/components/servers/labels/ServerLoaderLabel.vue +++ b/packages/ui/src/components/servers/labels/ServerLoaderLabel.vue @@ -1,8 +1,8 @@ - - - - + + + + + + + + {{ currentPlayers }} / {{ maxPlayers }} Players + + + + diff --git a/packages/ui/src/components/servers/labels/ServerSubdomainLabel.vue b/packages/ui/src/components/servers/labels/ServerSubdomainLabel.vue index 2b528ce8c6..5105cf855a 100644 --- a/packages/ui/src/components/servers/labels/ServerSubdomainLabel.vue +++ b/packages/ui/src/components/servers/labels/ServerSubdomainLabel.vue @@ -2,11 +2,12 @@ - - - + + + + - + - - + + {{ formattedUptime }} @@ -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 diff --git a/packages/ui/src/components/servers/marketing/MedalServerListing.vue b/packages/ui/src/components/servers/marketing/MedalServerListing.vue index 2648ed2130..8e8626a694 100644 --- a/packages/ui/src/components/servers/marketing/MedalServerListing.vue +++ b/packages/ui/src/components/servers/marketing/MedalServerListing.vue @@ -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-4 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" /> diff --git a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue index 45221b3d2d..8add032f8a 100644 --- a/packages/ui/src/layouts/wrapped/hosting/manage/index.vue +++ b/packages/ui/src/layouts/wrapped/hosting/manage/index.vue @@ -3,13 +3,28 @@ 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" > - + You don't have any servers yet! Modrinth Hosting is a new way to play modded Minecraft with your friends. - Create a server + Create a server + + Create a server + @@ -116,11 +134,15 @@ placeholder="Search servers..." wrapper-class="w-full md:w-72" /> - - + + New server + + + New server + @@ -152,12 +174,16 @@ v-for="server in filteredData.filter((s) => s.is_medal)" :key="server.server_id" v-bind="server" - @upgrade="openUpgradeModal(server.server_id)" + @upgrade="openPurchaseModal" /> @@ -171,23 +197,32 @@
+ You are about to resubscribe to + {{ modalData.serverName }}. Your subscription will be reactivated and your server will continue running without + interruption. +
+ Your next charge will be on + {{ formattedNextChargeDate }}. +
Modrinth Hosting is a new way to play modded Minecraft with your friends.