feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

* refactor: move server settings pages into /ui and add app ServerSettingsModal

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

* start status/player count info label, more style updates and fixes

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

* move app auth provider setup to src/providers/setup

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

* implement servers guest plan modal and signin which redirects back to modal's next step

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

* feat: start on shared console implementation into logs and overview pages

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

* feat: shared server header & overview page for app and website (#5736)

* feat: implement shared server header for app and website

* feat: implement wrapped overview page with shared composable and hook it up

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

* feat: implement server install content in app and server setup modal with DI

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

* feat: remove legacy components from apps/frontend/src/components/ui/servers

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>

* qa pass (#5738)

* fix: qa

* feat: qa

* fix: server icon fetch fails due to global node auth race condition overriding each other

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

* fix: notification panel in modal and when overlapping with action bar

* fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

* feat: loading animation for server panel + caching improvements for app

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

* fix: server settings invalidate saved state and remove server context provider since its already provided in the page

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

* fix: ip click active and remove power dont ask again

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

* fix: nagivate to billing button in app to go to website

* fix: stripe return url in app causing app to open modrinth.com in tauri

* refactor: better show polling UI code

* fix: new server polling comparison to use server ids instead of length

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

* fix: app-frontend missing env config and staging environment

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

* fix: no upgrade button for medal server listing in app

* fix: go to overview instead of content tab after onboarding

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

* feat: swap out server hard drive icon to server stack icon

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

* feat: improve share modal and bring it back for sharing log

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

* fix: delay import to fix overview page slow load on MacOS

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

* fix: clear purchase intent from sign-in after closing modal

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com>

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

Co-authored-by: Calum H. <calum@modrinth.com>
Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
Truman Gao
2026-04-12 15:38:08 -06:00
committed by GitHub
parent a2a97d1313
commit 693a371d61
278 changed files with 15974 additions and 12608 deletions

View File

@@ -0,0 +1,68 @@
<template>
<Teleport to="body">
<FloatingActionBar :shown="props.isVisible">
<p class="m-0 font-semibold text-sm md:text-base">You have unsaved changes.</p>
<div class="ml-auto flex gap-2">
<ButtonStyled type="transparent">
<button :disabled="props.isUpdating" @click="props.reset"><HistoryIcon /> Reset</button>
</ButtonStyled>
<ButtonStyled :color="props.restart ? 'standard' : 'brand'">
<button :disabled="props.isUpdating" @click="props.save">
<SpinnerIcon v-if="props.isUpdating" class="animate-spin" />
<SaveIcon v-else />
{{ props.isUpdating ? 'Saving...' : 'Save' }}
</button>
</ButtonStyled>
<ButtonStyled v-if="props.restart" color="brand">
<button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
<SpinnerIcon v-if="props.isUpdating || isTransitioning" class="animate-spin" />
{{ powerButtonLabel }}
</button>
</ButtonStyled>
</div>
</FloatingActionBar>
</Teleport>
</template>
<script setup lang="ts">
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
const props = defineProps<{
isUpdating: boolean
restart?: boolean
save: () => void | Promise<void>
reset: () => void
isVisible: boolean
serverId: string
}>()
const client = injectModrinthClient()
const { powerState } = injectModrinthServerContext()
const isStopped = computed(() => powerState.value === 'stopped' || powerState.value === 'crashed')
const isTransitioning = computed(
() => powerState.value === 'starting' || powerState.value === 'stopping',
)
const powerButtonLabel = computed(() => {
if (props.isUpdating) return 'Saving...'
if (isTransitioning.value) return isStopped.value ? 'Starting...' : 'Restarting...'
return isStopped.value ? 'Save & start' : 'Save & restart'
})
const saveAndPower = async () => {
try {
await props.save()
} catch {
return
}
await client.archon.servers_v0.power(props.serverId, isStopped.value ? 'Start' : 'Restart')
}
</script>

View File

@@ -1,108 +1,234 @@
<template>
<div>
<NuxtLink :to="status === 'suspended' ? '' : `/hosting/manage/${props.server_id}`">
<div
class="transition-all"
:class="{
pressable: !isDisabled,
hoverable: !isDisabled,
'cursor-pointer': !isDisabled,
}"
:role="!isDisabled ? 'link' : undefined"
:tabindex="!isDisabled ? 0 : undefined"
@click="navigateToServer"
@keydown.enter.self="navigateToServer"
@keydown.space.prevent.self="navigateToServer"
>
<div
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-surface-4 bg-bg-raised p-4 transition-all duration-150"
:class="{
'!rounded-b-none border-b-0': hasNotice,
'bg-surface-2': isDisabled,
}"
data-pyro-server-listing
:data-pyro-server-listing-id="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="{
'!rounded-b-none border-b-0': status === 'suspended' || !!pendingChange,
'opacity-75': status === 'suspended',
'active:scale-95': status !== 'suspended' && !pendingChange,
}"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
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"
>
<ServerIcon v-if="status !== 'suspended'" :image="image" />
<ServerIcon :image="image ?? undefined" :disabled="isDisabled" class="!rounded-xl" />
<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-1.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 text-xl font-bold text-contrast" :class="{ 'opacity-50': isDisabled }">
{{ name }}
</h2>
<div
v-if="isConfiguring && noticeType !== 'cancelled' && noticeType !== 'setToCancel'"
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" />
{{ formatMessage(messages.newLabel) }}
</div>
</div>
<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="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium"
:class="{ 'opacity-50': isDisabled }"
>
<LockIcon class="size-12 text-secondary" />
</div>
<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 />
</div>
<div
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
alt="Server Icon"
/>
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 }"
: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"
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
:alt="formatMessage(messages.serverIconAlt)"
/>
{{ formatMessage(messages.usingProjectLabel, { projectTitle: projectData?.title }) }}
</div>
<ServerInfoLabels
:server-data="
isConfiguring
? { net }
: {
game,
mc_version,
loader,
loader_version,
net,
online,
players: playerCount
? { current: playerCount.current, max: playerCount.max }
: undefined,
}
"
:server-id="server_id"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:show-player-count="showPlayerCount"
:class="{ 'opacity-50': isDisabled }"
:linked="false"
class="flex w-full flex-row flex-wrap items-center gap-2 text-primary *:hidden sm:flex-row sm:*:flex"
/>
</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">
{{ formatMessage(messages.provisioningNotice) }}
</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">
{{ formatMessage(messages.upgradingNotice) }}
</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'">
<IntlFormatted
v-if="noticeType === 'paymentfailed' && cancellationDate"
:message-id="messages.subscriptionCancelledPaymentFailedOnDate"
:values="{ formattedDate: formatDate(cancellationDate) }"
>
<template #date="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
<span v-else-if="noticeType === 'paymentfailed'">
{{ formatMessage(messages.subscriptionCancelledPaymentFailed) }}
</span>
<IntlFormatted
v-else-if="cancellationDate"
:message-id="messages.subscriptionCancelledOnDate"
:values="{ formattedDate: formatDate(cancellationDate) }"
>
<template #date="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
<span v-else>
{{ formatMessage(messages.subscriptionCancelled) }}
</span>
{{ ' ' }}
<IntlFormatted
v-if="!isFilesExpired"
:message-id="messages.filesKeptForDownload"
:values="{ daysRemaining: filesRemainingDays }"
>
<template #days-remaining="{ children }">
<span class="font-medium text-red">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</div>
<div v-else-if="noticeType === 'setToCancel'">
<IntlFormatted
v-if="cancellationDate"
:message-id="messages.subscriptionSetToCancelOnDate"
:values="{ formattedDate: formatDate(cancellationDate) }"
>
<template #date="{ children }">
<span class="font-medium text-contrast">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
<span v-else>{{ formatMessage(messages.subscriptionSetToCancel) }}</span>
<template v-if="!isFilesExpired">
{{ ' ' }}
{{ formatMessage(messages.filesPreservedAfterCancellation) }}
</template>
</div>
<div v-else-if="noticeType === 'moderated'">
{{ formatMessage(messages.moderatedNotice) }}
</div>
<div v-else>
{{ formatMessage(messages.suspendedNotice) }}
</div>
<div v-if="noticeButtons" class="flex gap-2">
<ButtonStyled
v-if="noticeButtons.downloadBackup && onDownloadBackup && isBackupDownloadEnabled"
type="outlined"
circular
>
<button
v-tooltip="formatMessage(messages.downloadLatestBackupTooltip)"
class="!border-surface-4"
data-server-listing-button
@click="onDownloadBackup"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.copyId" type="outlined">
<button
v-tooltip="formatMessage(messages.copyCodeToClipboardTooltip)"
class="!border-surface-4"
data-server-listing-button
@click="copyToClipboard(server_id)"
>
<template v-if="copied">
{{ formatMessage(messages.copiedLabel) }} <CheckIcon class="text-green" />
</template>
<template v-else> {{ formatMessage(messages.copyIdLabel) }} <CopyIcon /> </template>
</button>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.support">
<a href="https://support.modrinth.com/en/" target="_blank" data-server-listing-button
><MessagesSquareIcon /> {{ formatMessage(messages.supportLabel) }}
</a>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.manageBilling" color="brand">
<AutoLink :to="`/settings/billing#server-${server_id}`" data-server-listing-button>
<CardIcon /> {{ formatMessage(messages.manageBillingLabel) }}
</AutoLink>
</ButtonStyled>
<ButtonStyled v-if="noticeButtons.resubscribe && onResubscribe" color="brand">
<button data-server-listing-button @click="onResubscribe">
<RotateCounterClockwiseIcon /> {{ formatMessage(messages.resubscribeLabel) }}
</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) }}.
<IntlFormatted
:message-id="messages.pendingChangeNotice"
:values="{
verb: pendingChange.verb.toLowerCase(),
planSize: pendingChange.planSize,
formattedDate: formatDate(pendingChange.date),
}"
>
<template #date="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</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,24 +241,134 @@
<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 { useRouter } from 'vue-router'
import {
CardIcon,
CheckIcon,
CopyIcon,
RotateCounterClockwiseIcon,
} from '../../../../assets/generated-icons'
import { useFormatDateTime } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers/api-client'
import Avatar from '../base/Avatar.vue'
import CopyCode from '../base/CopyCode.vue'
import IntlFormatted from '../base/IntlFormatted.vue'
import ServersSpecs from '../billing/ServersSpecs.vue'
import ServerIcon from './icons/ServerIcon.vue'
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
const formatDate = useFormatDateTime({ dateStyle: 'long' })
const { formatMessage } = useVIntl()
const messages = defineMessages({
newLabel: {
id: 'servers.listing.new-label',
defaultMessage: 'New',
},
serverIconAlt: {
id: 'servers.listing.server-icon-alt',
defaultMessage: 'Server icon',
},
usingProjectLabel: {
id: 'servers.listing.using-project-label',
defaultMessage: 'Using {projectTitle}',
},
provisioningNotice: {
id: 'servers.listing.notice.provisioning',
defaultMessage: 'Please wait while we set up your server. This can take up to 10 minutes.',
},
upgradingNotice: {
id: 'servers.listing.notice.upgrading',
defaultMessage:
"Your server's hardware is currently being upgraded and will be back online shortly.",
},
subscriptionCancelled: {
id: 'servers.listing.notice.subscription-cancelled',
defaultMessage: 'Your subscription was cancelled.',
},
subscriptionCancelledOnDate: {
id: 'servers.listing.notice.subscription-cancelled-on-date',
defaultMessage: 'Your subscription was cancelled on <date>{formattedDate}</date>. ',
},
subscriptionCancelledPaymentFailed: {
id: 'servers.listing.notice.subscription-cancelled-payment-failed',
defaultMessage: 'Your subscription was cancelled due to payment failure.',
},
subscriptionCancelledPaymentFailedOnDate: {
id: 'servers.listing.notice.subscription-cancelled-payment-failed-on-date',
defaultMessage:
'Your subscription was cancelled on <date>{formattedDate}</date> due to payment failure. ',
},
filesKeptForDownload: {
id: 'servers.listing.notice.files-kept-for-download',
defaultMessage:
'Your files will be kept for <days-remaining>{daysRemaining} more {daysRemaining, plural, one {day} other {days} }</days-remaining>. Contact support to download the files before they are deleted. ',
},
subscriptionSetToCancel: {
id: 'servers.listing.notice.subscription-set-to-cancel',
defaultMessage: 'Your subscription is set to cancel.',
},
subscriptionSetToCancelOnDate: {
id: 'servers.listing.notice.subscription-set-to-cancel-on-date',
defaultMessage: 'Your subscription is set to cancel on <date>{formattedDate}</date>. ',
},
filesPreservedAfterCancellation: {
id: 'servers.listing.notice.files-preserved-after-cancellation',
defaultMessage: 'Your files will be preserved for 30 days after cancellation.',
},
moderatedNotice: {
id: 'servers.listing.notice.moderated',
defaultMessage: 'Your server has been suspended by moderation action. ',
},
suspendedNotice: {
id: 'servers.listing.notice.suspended',
defaultMessage:
'Your server has been suspended. Please contact Modrinth Support for more information.',
},
downloadLatestBackupTooltip: {
id: 'servers.listing.download-latest-backup-tooltip',
defaultMessage: 'Download latest backup',
},
copyCodeToClipboardTooltip: {
id: 'servers.listing.copy-code-tooltip',
defaultMessage: 'Copy code to clipboard',
},
copiedLabel: {
id: 'servers.listing.copied-label',
defaultMessage: 'Copied',
},
copyIdLabel: {
id: 'servers.listing.copy-id-label',
defaultMessage: 'Copy ID',
},
supportLabel: {
id: 'servers.listing.support-label',
defaultMessage: 'Support',
},
manageBillingLabel: {
id: 'servers.listing.manage-billing-label',
defaultMessage: 'Manage billing',
},
resubscribeLabel: {
id: 'servers.listing.resubscribe-label',
defaultMessage: 'Resubscribe',
},
pendingChangeNotice: {
id: 'servers.listing.notice.pending-change',
defaultMessage:
'Your server will {verb} to the {planSize} Plan on <date>{formattedDate}</date>. ',
},
})
export type PendingChange = {
planSize: string
@@ -159,14 +395,99 @@ 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 router = useRouter()
const { archon, kyros, labrinth } = injectModrinthClient()
const showGameLabel = computed(() => !!props.game)
const showLoaderLabel = computed(() => !!props.loader)
const isBackupDownloadEnabled = false
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 { downloadBackup: true, 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,17 +528,30 @@ const { data: image } = useQuery({
if (!props.server_id || props.status !== 'available') return null
try {
const auth = await archon.servers_v0.getFilesystemAuth(props.server_id)
const fsAuth = 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.downloadFileWithAuth(fsAuth, '/server-icon.png')
return await processImageBlob(blob, 64)
} catch (error) {
const statusCode = (error as { statusCode?: number })?.statusCode
if (statusCode != null && statusCode !== 404) {
throw error
}
try {
const originalBlob = await kyros.files_v0.downloadFileWithAuth(
fsAuth,
'/server-icon-original.png',
)
return await processImageBlob(originalBlob, 64)
} catch (originalError) {
const originalStatusCode = (originalError as { statusCode?: number })?.statusCode
if (originalStatusCode != null && originalStatusCode !== 404) {
throw originalError
}
}
return await processImageBlob(blob, 512)
} catch {
const projectIcon = iconUrl.value
if (projectIcon) {
const response = await fetch(projectIcon)
@@ -227,30 +561,62 @@ 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)
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
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,
)
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', originalFile)
.promise
return scaledDataUrl
}
}
return null
} catch (error) {
console.debug('Icon processing failed:', error)
return null
}
return null
},
enabled: computed(() => !!props.server_id && props.status === 'available'),
})
const isConfiguring = computed(() => props.flows?.intro)
const copied = ref(false)
function navigateToServer(event: MouseEvent | KeyboardEvent) {
if (isDisabled.value) return
const target = event.target
if (
target instanceof HTMLElement &&
target.closest('[data-subdomain-label], [data-server-listing-button]')
) {
return
}
router.push(`/hosting/manage/${props.server_id}`)
}
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-4 bg-bg-raised text-primary;
}
.hoverable:hover:not(:has([data-subdomain-label]:hover, [data-server-listing-button]:hover)) {
filter: brightness(1.2);
}
.pressable:active:not(:has([data-subdomain-label]:active, [data-server-listing-button]:active)) {
transform: scale(0.985);
}
</style>

View File

@@ -0,0 +1,232 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { ChevronRightIcon } from '@modrinth/assets'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref } from 'vue'
import type { TabbedModalTab } from '#ui/components'
import { TabbedModal } from '#ui/components'
import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n'
import {
ServerSettingsAdvancedPage,
ServerSettingsGeneralPage,
ServerSettingsInstallationPage,
ServerSettingsNetworkPage,
ServerSettingsPropertiesPage,
serverSettingsTabDefinitions,
type ServerSettingsTabId,
} from '#ui/layouts/shared/server-settings'
import { provideServerSettings } from '#ui/layouts/shared/server-settings/providers/server-settings'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
type ShowOptions = {
serverId: string
tabIndex?: number
tabId?: ServerSettingsTabId
}
const props = defineProps<{
resolveViewer: () => Promise<{ userId: string | null; userRole: string | null }>
browseModpacks?: (args: {
serverId: string
worldId: string | null
from: 'reset-server'
}) => void | Promise<void>
}>()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
failedToLoadServer: {
id: 'app.server-settings.failed-to-load-server',
defaultMessage: 'Failed to load server settings',
},
})
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
const { serverId: currentServerId, worldId, server } = injectModrinthServerContext()
const currentUserId = ref<string | null>(null)
const currentUserRole = ref<string | null>(null)
const isApp = ref(true)
const serverSettingsTabComponentMap = {
general: ServerSettingsGeneralPage,
installation: ServerSettingsInstallationPage,
network: ServerSettingsNetworkPage,
properties: ServerSettingsPropertiesPage,
advanced: ServerSettingsAdvancedPage,
} as const
provideServerSettings({
isApp,
currentUserId,
currentUserRole,
browseModpacks: props.browseModpacks ?? (() => {}),
closeModal: () => hide(),
})
const ownerId = computed(() => server.value?.owner_id ?? 'Ghost')
const isOwner = computed(() => currentUserId.value != null && currentUserId.value === ownerId.value)
const isAdmin = computed(() => currentUserRole.value === 'admin')
const tabs = computed<TabbedModalTab[]>(() =>
serverSettingsTabDefinitions.map((tab) => {
const ctx = {
serverId: currentServerId,
ownerId: ownerId.value,
serverStatus: server.value?.status,
isOwner: isOwner.value,
isAdmin: isAdmin.value,
}
const name = defineMessage({
id: `server.settings.tabs.${tab.id}`,
defaultMessage: tab.label,
})
const shown = tab.shown ? tab.shown(ctx) : true
if (tab.external) {
return {
name,
icon: tab.icon,
href: tab.href ? `https://modrinth.com${tab.href(ctx)}` : undefined,
shown,
}
}
return {
name,
icon: tab.icon,
content: serverSettingsTabComponentMap[tab.id as keyof typeof serverSettingsTabComponentMap],
shown,
}
}),
)
async function fetchViewer() {
currentUserId.value = null
currentUserRole.value = null
const result = await props.resolveViewer()
currentUserId.value = result.userId
currentUserRole.value = result.userRole
}
async function show({ serverId, tabIndex, tabId }: ShowOptions) {
try {
const targetServerId = currentServerId
if (serverId !== targetServerId) {
console.warn(
`[ServerSettingsModal] Ignoring mismatched serverId "${serverId}" in favor of context "${targetServerId}"`,
)
}
const cachedServer = queryClient.getQueryData<Archon.Servers.v0.Server>([
'servers',
'detail',
targetServerId,
])
const cachedFull = queryClient.getQueryData<Archon.Servers.v1.ServerFull>([
'servers',
'v1',
'detail',
targetServerId,
])
modal.value?.show()
const visibleTabs = tabs.value.filter((tab) => tab.shown !== false)
let requestedTab = tabIndex ?? 0
if (tabId) {
const defIndex = serverSettingsTabDefinitions.findIndex((d) => d.id === tabId)
if (defIndex >= 0) {
const visibleIndex = visibleTabs.findIndex(
(_, i) => tabs.value.indexOf(visibleTabs[i]) === defIndex,
)
if (visibleIndex >= 0) requestedTab = visibleIndex
}
}
const clampedTab = Math.min(Math.max(requestedTab, 0), Math.max(visibleTabs.length - 1, 0))
nextTick(() => modal.value?.setTab(clampedTab))
const fetchPromises: Promise<unknown>[] = [fetchViewer()]
if (!cachedServer) {
fetchPromises.push(
queryClient.fetchQuery({
queryKey: ['servers', 'detail', targetServerId],
queryFn: () => client.archon.servers_v0.get(targetServerId),
}),
)
}
if (!cachedFull) {
fetchPromises.push(
queryClient.fetchQuery({
queryKey: ['servers', 'v1', 'detail', targetServerId],
queryFn: () => client.archon.servers_v1.get(targetServerId),
}),
)
}
await Promise.all(fetchPromises)
if (worldId.value) {
queryClient.prefetchQuery({
queryKey: ['servers', 'properties', 'v1', targetServerId, worldId.value],
queryFn: () => client.archon.properties_v1.getProperties(targetServerId, worldId.value!),
})
queryClient.prefetchQuery({
queryKey: ['content', 'list', 'v1', targetServerId],
queryFn: () =>
client.archon.content_v1.getAddons(targetServerId, worldId.value!, {
from_modpack: false,
}),
})
queryClient.prefetchQuery({
queryKey: ['servers', 'startup', 'v1', targetServerId, worldId.value],
queryFn: () => client.archon.options_v1.getStartup(targetServerId, worldId.value!),
})
}
} catch (error) {
console.error(error)
addNotification({
type: 'error',
title: formatMessage(messages.failedToLoadServer),
})
}
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
ref="modal"
:tabs="tabs"
:max-width="'min(980px, calc(95vw - 2rem))'"
:width="'min(980px, calc(95vw - 2rem))'"
>
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
{{ server.name || 'Server' }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{
formatMessage(commonMessages.settingsLabel)
}}</span>
</span>
</template>
</TabbedModal>
</template>

View File

@@ -16,21 +16,12 @@
@browse-modpacks="$emit('browse-modpacks')"
/>
<NewModal
ref="uploadModal"
:header="formatMessage(messages.uploadingModpackHeader)"
:closable="false"
>
<div class="flex flex-col gap-4 md:w-[400px]">
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.uploadWarningText) }}</p>
</div>
</NewModal>
<UploadProgressModal ref="uploadProgressModal" />
</template>
<script setup lang="ts">
import type { Archon, ModrinthApiError } from '@modrinth/api-client'
import { computed, nextTick, ref, useTemplateRef } from 'vue'
import { computed, useTemplateRef } from 'vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
@@ -38,22 +29,13 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
import { injectModrinthClient } from '../../providers/api-client'
import { injectModrinthServerContext } from '../../providers/server-context'
import { injectNotificationManager } from '../../providers/web-notifications'
import { AppearingProgressBar } from '../base'
import type { CreationFlowContextValue } from '../flows/creation-flow-modal/creation-flow-context'
import CreationFlowModal from '../flows/creation-flow-modal/index.vue'
import { NewModal } from '../modal'
import { UploadProgressModal } from '../modal'
const { formatMessage } = useVIntl()
const messages = defineMessages({
uploadingModpackHeader: {
id: 'servers.setup.uploading-modpack.header',
defaultMessage: 'Uploading modpack',
},
uploadWarningText: {
id: 'servers.setup.upload-warning',
defaultMessage: "Please don't close this page while uploading.",
},
rateLimitTitle: {
id: 'servers.setup.rate-limit.title',
defaultMessage: 'Cannot reinstall server',
@@ -117,10 +99,8 @@ const initialLoader = computed(() => {
const initialGameVersion = computed(() => serverContext.server.value.mc_version ?? undefined)
const creationFlowRef = useTemplateRef<InstanceType<typeof CreationFlowModal>>('creationFlowRef')
const uploadModal = useTemplateRef<InstanceType<typeof NewModal>>('uploadModal')
const uploadedBytes = ref(0)
const totalBytes = ref(0)
const uploadProgressModal =
useTemplateRef<InstanceType<typeof UploadProgressModal>>('uploadProgressModal')
async function onFlowComplete(ctx: CreationFlowContextValue) {
debug('onFlowComplete:', {
@@ -210,32 +190,15 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
}
async function handleMrpackUpload(file: File, properties: Archon.Content.v1.PropertiesFields) {
uploadedBytes.value = 0
totalBytes.value = file.size
creationFlowRef.value?.hide()
await nextTick()
uploadModal.value?.show()
try {
const handle = client.kyros.content_v1.uploadModpackFile(
serverContext.worldId.value!,
file,
properties,
{
softOverride: false,
onProgress: ({ loaded, total }) => {
uploadedBytes.value = loaded
totalBytes.value = total
},
},
)
await handle.promise
emitReinstall()
} finally {
uploadModal.value?.hide()
}
const handle = client.kyros.content_v1.uploadModpackFile(
serverContext.worldId.value!,
file,
properties,
{ softOverride: false },
)
await uploadProgressModal.value!.track(handle)
emitReinstall()
}
function emitReinstall(args?: { loader: string; lVersion: string; mVersion: string | null }) {

View File

@@ -0,0 +1,264 @@
<template>
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">Icon</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="'Edit icon'"
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
:disabled="isIconActionLoading"
:options="[
{
id: 'upload',
action: () => triggerFileInput(),
},
{
id: 'sync',
action: () => resetIcon(),
},
]"
>
<ServerIcon
class="size-28 transition-[filter] group-hover:brightness-[0.50]"
:class="isIconActionLoading ? 'brightness-[0.50]' : ''"
:image="displayIcon"
/>
<div
class="absolute top-0 h-full w-full flex items-center justify-center"
:class="isIconActionLoading ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
>
<SpinnerIcon
v-if="isIconActionLoading"
aria-hidden="true"
class="h-10 w-10 animate-spin text-primary"
/>
<EditIcon v-else aria-hidden="true" class="h-10 w-10 text-primary" />
</div>
<template #upload> <UploadIcon /> Upload icon </template>
<template #sync> <TransferIcon /> Sync icon </template>
</OverflowMenu>
</div>
</div>
</template>
<script setup lang="ts">
import { EditIcon, SpinnerIcon, TransferIcon, UploadIcon } from '@modrinth/assets'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import { OverflowMenu, ServerIcon } from '#ui/components'
import { useServerImage } from '#ui/composables'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { serverId, server } = injectModrinthServerContext()
const queryClient = useQueryClient()
const isUploadingIcon = ref(false)
const isSyncingIcon = ref(false)
const isIconActionLoading = computed(() => isUploadingIcon.value || isSyncingIcon.value)
const {
image: displayIcon,
refetch: refetchRemoteIcon,
setImage,
clearImage,
} = useServerImage(
serverId,
computed(() => server.value?.upstream ?? null),
{
includeProjectFallback: false,
},
)
function getStatusCode(error: unknown): number | undefined {
const err = error as { statusCode?: number; response?: { status?: number } }
return err.statusCode ?? err.response?.status
}
function isNotFound(error: unknown): boolean {
return getStatusCode(error) === 404
}
const uploadFile = async (e: Event) => {
if (isIconActionLoading.value) return
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) {
addNotification({
type: 'error',
title: 'No file selected',
text: 'Please select a file to upload.',
})
return
}
isUploadingIcon.value = true
try {
const scaledFile = await new Promise<File>((resolve, reject) => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.onload = () => {
canvas.width = 64
canvas.height = 64
ctx?.drawImage(img, 0, 0, 64, 64)
canvas.toBlob((blob) => {
if (blob) {
resolve(new File([blob], 'server-icon.png', { type: 'image/png' }))
} else {
reject(new Error('Canvas toBlob failed'))
}
}, 'image/png')
URL.revokeObjectURL(img.src)
}
img.onerror = reject
img.src = URL.createObjectURL(file)
})
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
try {
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
} catch (scaledUploadError) {
// Node FS may reject create when file already exists. Delete and retry once.
try {
await client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false)
} catch (deleteError) {
if (!isNotFound(deleteError)) {
throw scaledUploadError
}
}
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
}
// Keep original file in sync when possible, but don't block icon updates on failures here.
try {
await client.kyros.files_v0.deleteFileOrFolderWithAuth(
fsAuth,
'/server-icon-original.png',
false,
)
} catch (deleteOriginalError) {
if (!isNotFound(deleteOriginalError)) {
// best effort
}
}
try {
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', file)
.promise
} catch (originalUploadError) {
if (!isNotFound(originalUploadError)) {
// best effort
}
}
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
await new Promise<void>((resolve) => {
img.onload = () => {
canvas.width = 512
canvas.height = 512
ctx?.drawImage(img, 0, 0, 512, 512)
const dataURL = canvas.toDataURL('image/png')
setImage(dataURL)
queryClient.setQueriesData({ queryKey: ['servers', 'detail', serverId, 'icon'] }, dataURL)
resolve()
URL.revokeObjectURL(img.src)
}
img.src = URL.createObjectURL(file)
})
await refetchRemoteIcon()
addNotification({
type: 'success',
title: 'Server icon updated',
text: 'Your server icon was successfully changed.',
})
} catch {
addNotification({
type: 'error',
title: 'Upload failed',
text: 'Failed to upload server icon.',
})
} finally {
isUploadingIcon.value = false
}
}
const resetIcon = async () => {
if (isIconActionLoading.value) return
isSyncingIcon.value = true
try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
const deleteResults = await Promise.allSettled([
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false),
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon-original.png', false),
])
for (const result of deleteResults) {
if (result.status === 'rejected' && !isNotFound(result.reason)) {
throw result.reason
}
}
// Force default icon state across all useServerImage instances via the shared query cache.
// Use `null` (not `undefined`) because TanStack Query v5 treats setQueriesData(undefined)
// as a no-op. The `null` sentinel is handled by useServerImage's image computed.
clearImage()
await queryClient.cancelQueries({ queryKey: ['servers', 'detail', serverId, 'icon'] })
queryClient.setQueriesData({ queryKey: ['servers', 'detail', serverId, 'icon'] }, null)
addNotification({
type: 'success',
title: 'Server icon reset',
text: 'Your server icon was successfully reset.',
})
} catch {
addNotification({
type: 'error',
title: 'Reset failed',
text: 'Failed to reset server icon.',
})
} finally {
isSyncingIcon.value = false
}
}
const triggerFileInput = () => {
if (isIconActionLoading.value) return
const input = document.createElement('input')
input.type = 'file'
input.id = 'server-icon-field'
input.accept = 'image/png,image/jpeg,image/gif,image/webp'
const cleanup = () => {
input.remove()
window.removeEventListener('focus', handleWindowFocus)
}
const handleWindowFocus = () => {
// If picker was cancelled there is no change event; clean up on focus return.
setTimeout(() => {
if (!input.value) cleanup()
}, 0)
}
input.onchange = async (event) => {
try {
await uploadFile(event)
} finally {
cleanup()
}
}
document.body.appendChild(input)
window.addEventListener('focus', handleWindowFocus, { once: true })
input.click()
}
</script>

View File

@@ -1,8 +1,8 @@
<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-2xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<client-only>
<template v-if="hasMounted">
<img
v-if="image"
class="h-full w-full select-none object-fill"
@@ -15,14 +15,29 @@
alt="Server Icon"
:src="MinecraftServerIcon"
/>
</client-only>
</template>
<img
v-else
class="h-full w-full select-none object-fill"
alt="Server Icon"
:src="MinecraftServerIcon"
/>
<div v-if="disabled" class="absolute inset-0 bg-surface-1 opacity-50" />
</div>
</template>
<script setup lang="ts">
import { MinecraftServerIcon } from '@modrinth/assets'
import { onMounted, ref } from 'vue'
const hasMounted = ref(false)
onMounted(() => {
hasMounted.value = true
})
defineProps<{
image: string | undefined
disabled?: boolean
}>()
</script>

View File

@@ -4,7 +4,10 @@ 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 ServerListing } from './ServerListing.vue'
export { default as SaveBanner } from './SaveBanner.vue'
export * from './server-header'
export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue'
export { type PendingChange, default as ServerListing } from './ServerListing.vue'
export { default as ServerSettingsModal } from './ServerSettingsModal.vue'
export { default as ServerSetupModal } from './ServerSetupModal.vue'
export { default as ServersPromo } from './ServersPromo.vue'

View File

@@ -0,0 +1,3 @@
<template>
<div class="experimental-styles-within h-1.5 w-1.5 bg-button-border rounded-full"></div>
</template>

View File

@@ -1,15 +1,13 @@
<template>
<div
v-if="game"
v-tooltip="'Change server version'"
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
>
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
<div v-if="game" class="min-w-0 flex-none flex-row items-center gap-1.5 first:!flex">
<Separator v-if="!noSeparator" />
<GameIcon aria-hidden="true" />
<AutoLink
v-if="isLink"
:to="serverId ? `/hosting/manage/${serverId}/options/loader` : ''"
class="flex min-w-0 items-center truncate text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
:to="settingsLinkTarget"
class="flex min-w-0 items-center truncate text-sm font-medium"
:class="settingsLinkTarget ? 'hover:underline' : ''"
>
<div class="flex flex-row items-center gap-1">
{{ game[0].toUpperCase() + game.slice(1) }}
@@ -17,7 +15,11 @@
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
</div>
</AutoLink>
<div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
<div
v-else
v-tooltip="'Change server version'"
class="pointer-events-none flex min-w-0 flex-row items-center gap-1 truncate text-sm font-medium"
>
{{ game[0].toUpperCase() + game.slice(1) }}
<span v-if="mcVersion">{{ mcVersion }}</span>
<span v-else class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"></span>
@@ -27,16 +29,25 @@
<script setup lang="ts">
import { GameIcon } from '@modrinth/assets'
import { useRoute } from 'vue-router'
import { computed } from 'vue'
import { injectServerSettingsModal } from '#ui/providers/server-settings-modal'
import AutoLink from '../../base/AutoLink.vue'
import Separator from './Separator.vue'
defineProps<{
game: string
mcVersion: string
isLink?: boolean
noSeparator?: boolean
}>()
const route = useRoute()
const serverId = route.params.id as string
const settingsModal = injectServerSettingsModal(null)
const settingsLinkTarget = computed(() => {
if (settingsModal) {
return () => settingsModal.openServerSettings({ tabId: 'installation' })
}
return ''
})
</script>

View File

@@ -1,21 +1,30 @@
<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"
:server-id="serverId"
:no-separator="column || (!showLoaderLabel && !showGameLabel)"
:is-link="linked"
/>
<ServerUptimeLabel
@@ -29,14 +38,17 @@
<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'
interface ServerInfoLabelsProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serverData: Record<string, any>
serverId?: string
showGameLabel: boolean
showLoaderLabel: boolean
showPlayerCount?: boolean
uptimeSeconds?: number
column?: boolean
linked?: boolean

View File

@@ -1,14 +1,15 @@
<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" />
<div class="flex min-w-0 flex-row items-center gap-2 truncate">
<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"
:to="serverId ? `/hosting/manage/${serverId}/options/loader` : ''"
class="flex min-w-0 items-center text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
v-tooltip="'Change server loader'"
:to="settingsLinkTarget"
class="flex min-w-0 items-center font-medium text-sm"
:class="settingsLinkTarget ? 'hover:underline' : ''"
>
<span v-if="loader">
{{ loader }}
@@ -19,7 +20,7 @@
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
</span>
</AutoLink>
<div v-else class="min-w-0 text-sm font-semibold">
<div v-else class="pointer-events-none min-w-0 font-medium text-sm">
<span v-if="loader">
{{ loader }}
<span v-if="loaderVersion">{{ loaderVersion }}</span>
@@ -34,10 +35,13 @@
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { computed } from 'vue'
import { injectServerSettingsModal } from '#ui/providers/server-settings-modal'
import AutoLink from '../../base/AutoLink.vue'
import LoaderIcon from '../icons/LoaderIcon.vue'
import Separator from './Separator.vue'
defineProps<{
noSeparator?: boolean
@@ -46,6 +50,11 @@ defineProps<{
isLink?: boolean
}>()
const route = useRoute()
const serverId = route.params.id as string
const settingsModal = injectServerSettingsModal(null)
const settingsLinkTarget = computed(() => {
if (settingsModal) {
return () => settingsModal.openServerSettings({ tabId: 'installation' })
}
return ''
})
</script>

View File

@@ -0,0 +1,17 @@
<template>
<div class="flex gap-1 text-sm font-medium">
<!-- indicator icon -->
{{ currentPlayers }} / {{ maxPlayers }} Players
</div>
</template>
<script setup lang="ts">
interface ServerPlayerCountProps {
currentPlayers: number
maxPlayers: number
online: boolean
}
defineProps<ServerPlayerCountProps>()
</script>

View File

@@ -2,14 +2,17 @@
<div
v-if="subdomain && !isHidden"
v-tooltip="'Copy custom URL'"
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer"
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer hover:underline"
data-subdomain-label
@click.stop.prevent
>
<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' : ''"
class="flex min-w-0 font-medium text-sm text-nowrap"
:class="props.subdomain ? 'hover:underline' : ''"
@click="copySubdomain"
>
{{ subdomain }}.modrinth.gg
@@ -25,10 +28,13 @@ 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<{
subdomain: string
serverId?: string
noSeparator?: boolean
}>()
@@ -42,9 +48,9 @@ const copySubdomain = () => {
}
const route = useRoute()
const serverId = computed(() => route.params.id as string)
const serverId = props.serverId || (route.params.id as string)
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
hideSubdomainLabel: false,
})

View File

@@ -5,11 +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" />
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
<div class="flex gap-1.5">
<time class="truncate text-sm font-medium" :aria-label="verboseUptime">
{{ formattedUptime }}
</time>
</div>
@@ -17,9 +16,10 @@
</template>
<script setup lang="ts">
import { TimerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Separator from './Separator.vue'
const props = defineProps<{
uptimeSeconds: number
noSeparator?: boolean

View File

@@ -0,0 +1,168 @@
<template>
<div
class="medal-promotion relative flex w-full flex-row items-center justify-between rounded-2xl p-4 shadow-xl"
>
<MedalBackgroundImage />
<div class="z-10 mr-2 flex flex-col gap-1">
<Transition
enter-from-class="opacity-0 translate-y-1"
enter-active-class="transition-all duration-300"
enter-to-class="opacity-100 translate-y-0"
leave-from-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150"
leave-to-class="opacity-0 -translate-y-1"
>
<div
v-if="expiryDate"
class="flex items-center gap-2 whitespace-nowrap font-semibold text-contrast"
>
<ClockIcon class="clock-glow text-medal-orange size-5 shrink-0" />
<span class="w-full text-wrap text-lg">
Your <span class="text-medal-orange">Medal</span>-powered Modrinth Server will expire in
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.days }}</span> days
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.hours }}</span> hours
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.minutes }}</span> minutes
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.seconds }}</span>
seconds.
</span>
</div>
</Transition>
</div>
<ButtonStyled color="medal-promo" type="outlined" size="large">
<button class="z-10 my-auto" @click="openUpgradeModal"><RocketIcon /> Upgrade</button>
</ButtonStyled>
</div>
<ServersUpgradeModalWrapper
ref="upgradeModal"
:stripe-publishable-key="props.stripePublishableKey ?? ''"
:site-url="props.siteUrl ?? ''"
:products="props.products ?? []"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClockIcon, RocketIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import dayjsDuration from 'dayjs/plugin/duration'
import { type ComponentPublicInstance, computed, onMounted, onUnmounted, ref } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
import { injectModrinthClient } from '#ui/providers'
import MedalBackgroundImage from './MedalBackgroundImage.vue'
dayjs.extend(dayjsDuration)
type UpgradeWrapperRef = ComponentPublicInstance<{ open: (id?: string) => void | Promise<void> }>
const upgradeModal = ref<UpgradeWrapperRef | null>(null)
const props = defineProps<{
serverId?: string
stripePublishableKey?: string
siteUrl?: string
products?: Labrinth.Billing.Internal.Product[]
}>()
const client = injectModrinthClient()
const { data: subscriptions } = useQuery({
queryKey: ['billing', 'subscriptions'],
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
})
const expiryDate = computed(() => {
for (const subscription of subscriptions.value || []) {
if (subscription.metadata?.id === props.serverId) {
return dayjs(subscription.created).add(5, 'days')
}
}
return undefined
})
function openUpgradeModal() {
upgradeModal.value?.open(props.serverId)
}
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 })
function updateCountdown() {
if (!expiryDate.value) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
return
}
const now = dayjs()
const diff = expiryDate.value.diff(now)
if (diff <= 0) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
return
}
const duration = dayjs.duration(diff)
timeLeftCountdown.value = {
days: duration.days(),
hours: duration.hours(),
minutes: duration.minutes(),
seconds: duration.seconds(),
}
}
updateCountdown()
const intervalId = ref<NodeJS.Timeout | null>(null)
onMounted(() => {
intervalId.value = setInterval(updateCountdown, 1000)
})
onUnmounted(() => {
if (intervalId.value) clearInterval(intervalId.value)
})
</script>
<style scoped lang="scss">
.medal-promotion {
position: relative;
border: 1px solid var(--medal-promotion-bg-orange);
background: inherit; // allows overlay + pattern to take over
overflow: hidden;
}
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--medal-promotion-bg-gradient);
z-index: 1;
border-radius: inherit;
}
.background-pattern {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
background-color: var(--medal-promotion-bg);
border-radius: inherit;
color: var(--medal-promotion-text-orange);
}
.clock-glow {
filter: drop-shadow(0 0 72px var(--color-orange)) drop-shadow(0 0 36px var(--color-orange))
drop-shadow(0 0 18px var(--color-orange));
}
.text-medal-orange {
color: var(--medal-promotion-text-orange);
font-weight: bold;
}
</style>

View File

@@ -1,127 +1,143 @@
<template>
<div class="rounded-2xl shadow-xl">
<div
class="transition-all"
:class="{
pressable: !isDisabled,
hoverable: !isDisabled,
'cursor-pointer': !isDisabled,
}"
:role="!isDisabled ? 'link' : undefined"
:tabindex="!isDisabled ? 0 : undefined"
@click="navigateToServer"
@keydown.enter.self="navigateToServer"
@keydown.space.prevent.self="navigateToServer"
>
<div
class="medal-promotion flex flex-row items-center overflow-x-hidden rounded-t-2xl p-4 transition-transform duration-100"
:class="status === 'suspended' ? 'rounded-b-none border-b-0 opacity-75' : 'rounded-b-2xl'"
class="medal-promotion flex flex-row items-center overflow-x-hidden rounded-2xl p-4 transition-all duration-150"
:class="{
'!rounded-b-none border-b-0': hasNotice,
'!bg-surface-2': isDisabled,
}"
data-pyro-server-listing
:data-pyro-server-listing-id="server_id"
>
<MedalBackgroundImage />
<AutoLink
:to="status === 'suspended' ? '' : `/hosting/manage/${props.server_id}`"
class="z-10 flex flex-grow flex-row items-center overflow-x-hidden"
:class="status !== 'suspended' && 'active:scale-95'"
<div
v-if="isDisabled"
class="relative z-10 flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
>
<Avatar
v-if="status !== 'suspended'"
src="https://cdn-raw.modrinth.com/medal_icon.webp"
size="64px"
class="z-10"
<Avatar src="https://cdn-raw.modrinth.com/medal_icon.webp" size="64px" class="opacity-50" />
<SpinnerIcon
v-if="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>
<Avatar v-else src="https://cdn-raw.modrinth.com/medal_icon.webp" size="64px" class="z-10" />
<div class="z-10 ml-4 flex min-w-0 flex-col gap-1.5">
<div class="flex flex-row items-center gap-2">
<h2
class="m-0 truncate text-xl font-bold text-contrast"
:class="{ 'opacity-50': isDisabled }"
>
{{ name }}
</h2>
<span class="truncate" :class="{ 'opacity-50': isDisabled }">
<IntlFormatted
:message-id="messages.countdownRemaining"
:values="{
days: timeLeftCountdown.days,
hours: timeLeftCountdown.hours,
minutes: timeLeftCountdown.minutes,
seconds: timeLeftCountdown.seconds,
}"
>
<template #days-count="{ children }">
<span class="text-medal-orange"><component :is="() => children" /></span>
</template>
<template #hours-count="{ children }">
<span class="text-medal-orange"><component :is="() => children" /></span>
</template>
<template #minutes-count="{ children }">
<span class="text-medal-orange"><component :is="() => children" /></span>
</template>
<template #seconds-count="{ children }">
<span class="text-medal-orange"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</span>
</div>
<div
v-else
class="bg-bg-secondary z-10 flex size-16 shrink-0 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium"
:class="{ 'opacity-50': isDisabled }"
>
<LockIcon class="size-12 text-secondary" />
</div>
<div class="z-10 ml-4 flex min-w-0 flex-col gap-2.5">
<div class="flex flex-row items-center gap-2">
<h2 class="m-0 truncate text-xl font-bold text-contrast">{{ name }}</h2>
<ChevronRightIcon />
<span class="truncate">
<span class="text-medal-orange">
{{ timeLeftCountdown.days }}
</span>
days
<span class="text-medal-orange">
{{ timeLeftCountdown.hours }}
</span>
hours
<span class="text-medal-orange">
{{ timeLeftCountdown.minutes }}
</span>
minutes
<span class="text-medal-orange">
{{ timeLeftCountdown.seconds }}
</span>
seconds remaining...
</span>
</div>
<div
v-if="projectData?.title"
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
>
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
alt="Server Icon"
/>
Using {{ projectData?.title || 'Unknown' }}
</div>
<div
v-if="isConfiguring"
class="text-medal-orange flex min-w-0 items-center gap-2 truncate text-sm font-semibold"
>
<SparklesIcon class="size-5 shrink-0" /> New server
</div>
<ServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
: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"
<Avatar
:src="iconUrl"
no-shadow
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
:alt="formatMessage(messages.serverIconAlt)"
/>
{{ formatMessage(messages.usingProjectLabel, { projectTitle: projectData?.title }) }}
</div>
</AutoLink>
<div v-if="isNuxt" class="z-10 ml-auto">
<div
v-if="isConfiguring"
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-blue h-[28px] w-max"
>
<SparklesIcon class="size-5 shrink-0 font-semibold" />
{{ formatMessage(messages.newServerLabel) }}
</div>
<ServerInfoLabels
v-else
:server-data="{ game, mc_version, loader, loader_version, net }"
:server-id="server_id"
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:linked="false"
:class="{ 'opacity-50': isDisabled }"
class="flex w-full flex-row flex-wrap items-center gap-2 text-primary *:hidden sm:flex-row sm:*:flex"
/>
</div>
<div class="z-10 ml-auto">
<ButtonStyled color="medal-promo" type="outlined" size="large">
<button class="my-auto" @click="handleUpgrade"><RocketIcon /> Upgrade</button>
<button class="my-auto" data-server-listing-button @click="handleUpgrade">
<RocketIcon /> {{ formatMessage(messages.upgradeButton) }}
</button>
</ButtonStyled>
</div>
</div>
<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"
class="server-listing-notice"
>
<LoaderCircleIcon class="size-5 animate-spin" />
Your server's hardware is currently being upgraded and will be back online shortly.
<div class="flex gap-2">
{{ formatMessage(messages.upgradingNotice) }}
</div>
</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"
class="server-listing-notice"
>
<div class="flex flex-row gap-2">
<TriangleAlertIcon class="!size-5" /> Your Medal server trial has ended and your server has
been suspended. Please upgrade to continue to use your server.
</div>
<div>{{ formatMessage(messages.medalTrialEndedNotice) }}</div>
</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="status === 'suspended' && suspension_reason" class="server-listing-notice">
<div>
{{
formatMessage(messages.suspendedWithReasonNotice, {
reason: suspension_reason,
})
}}
</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>
<div v-else-if="status === 'suspended'" class="server-listing-notice">
<div>{{ formatMessage(messages.suspendedNotice) }}</div>
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
</div>
</div>
@@ -129,25 +145,19 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { NuxtModrinthClient } from '@modrinth/api-client'
import {
ChevronRightIcon,
LoaderCircleIcon,
LockIcon,
RocketIcon,
SparklesIcon,
TriangleAlertIcon,
} from '@modrinth/assets'
import { LockIcon, RocketIcon, SparklesIcon, SpinnerIcon } from '@modrinth/assets'
import { useQuery } from '@tanstack/vue-query'
import dayjs from 'dayjs'
import dayjsDuration from 'dayjs/plugin/duration'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectModrinthClient } from '../../../providers/api-client'
import AutoLink from '../../base/AutoLink.vue'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import CopyCode from '../../base/CopyCode.vue'
import IntlFormatted from '../../base/IntlFormatted.vue'
import ServerInfoLabels from '../labels/ServerInfoLabels.vue'
import MedalBackgroundImage from './MedalBackgroundImage.vue'
@@ -171,13 +181,21 @@ type MedalServerListingProps = {
const props = defineProps<MedalServerListingProps>()
const emit = defineEmits<{ (e: 'upgrade'): void }>()
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const router = useRouter()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
// const isNuxt = computed(() => client instanceof NuxtModrinthClient)
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')
const hasNotice = computed(() => props.status === 'suspended')
const { data: projectData } = useQuery({
queryKey: ['server-project', props.server_id, props.upstream?.project_id],
@@ -189,7 +207,50 @@ const { data: projectData } = useQuery({
})
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
const isConfiguring = computed(() => props.flows?.intro)
const messages = defineMessages({
countdownRemaining: {
id: 'servers.medal-listing.countdown.remaining',
defaultMessage:
'<days-count>{days}</days-count> {days, plural, one {day} other {days}} <hours-count>{hours}</hours-count> {hours, plural, one {hour} other {hours}} <minutes-count>{minutes}</minutes-count> {minutes, plural, one {minute} other {minutes}} <seconds-count>{seconds}</seconds-count> {seconds, plural, one {second} other {seconds}} remaining...',
},
serverIconAlt: {
id: 'servers.medal-listing.server-icon-alt',
defaultMessage: 'Server icon',
},
usingProjectLabel: {
id: 'servers.medal-listing.using-project-label',
defaultMessage: 'Using {projectTitle}',
},
newServerLabel: {
id: 'servers.medal-listing.new-server-label',
defaultMessage: 'New server',
},
upgradeButton: {
id: 'servers.medal-listing.upgrade-button',
defaultMessage: 'Upgrade',
},
upgradingNotice: {
id: 'servers.medal-listing.notice.upgrading',
defaultMessage:
"Your server's hardware is currently being upgraded and will be back online shortly.",
},
medalTrialEndedNotice: {
id: 'servers.medal-listing.notice.medal-trial-ended',
defaultMessage:
'Your Medal server trial has ended and your server has been suspended. Please upgrade to continue using your server.',
},
suspendedWithReasonNotice: {
id: 'servers.medal-listing.notice.suspended-with-reason',
defaultMessage:
'Your server has been suspended: {reason}. Please update your billing information or contact Modrinth Support for more information.',
},
suspendedNotice: {
id: 'servers.medal-listing.notice.suspended',
defaultMessage:
'Your server has been suspended. Please update your billing information or contact Modrinth Support for more information.',
},
})
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 })
const expiryDate = computed(() => (props.medal_expires ? dayjs(props.medal_expires) : null))
@@ -199,6 +260,20 @@ function handleUpgrade(event: Event) {
emit('upgrade')
}
function navigateToServer(event: MouseEvent | KeyboardEvent) {
if (isDisabled.value) return
const target = event.target
if (
target instanceof HTMLElement &&
target.closest('[data-subdomain-label], [data-server-listing-button]')
) {
return
}
router.push(`/hosting/manage/${props.server_id}`)
}
function updateCountdown() {
if (!expiryDate.value) {
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
@@ -250,4 +325,17 @@ onUnmounted(() => {
.border-medal-orange {
border-color: var(--medal-promotion-bg-orange);
}
.server-listing-notice {
@apply relative flex w-full rounded-b-2xl border-[1px] border-t-0 border-solid p-4 flex-col gap-4 border-surface-4 bg-bg-raised text-primary;
}
.hoverable:hover:not(:has([data-subdomain-label]:hover, [data-server-listing-button]:hover))
.medal-promotion {
filter: brightness(1.05) saturate(1.1);
}
.pressable:active:not(:has([data-subdomain-label]:active, [data-server-listing-button]:active)) {
transform: scale(0.985);
}
</style>

View File

@@ -1,2 +1,3 @@
export { default as MedalBackgroundImage } from './MedalBackgroundImage.vue'
export { default as MedalServerCountdown } from './MedalServerCountdown.vue'
export { default as MedalServerListing } from './MedalServerListing.vue'

View File

@@ -0,0 +1,64 @@
<template>
<div class="contents">
<div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
<button disabled class="flex-shrink-0">
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
</button>
</ButtonStyled>
<template v-else>
<ButtonStyled v-if="showStopButton" type="transparent" size="large">
<button
v-tooltip="busyTooltip"
:disabled="!canTakeAction"
@click="initiateAction('Stop')"
>
<div class="flex gap-1">
<StopCircleIcon class="h-5 w-5" />
<span>Stop</span>
</div>
</button>
</ButtonStyled>
<ButtonStyled type="standard" color="brand" size="large">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<component :is="isRunning || showStopButton ? UpdatedIcon : PlayIcon" />
<span>{{ primaryActionText }}</span>
</button>
</ButtonStyled>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import { LoaderCircleIcon, PlayIcon, StopCircleIcon, UpdatedIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { ButtonStyled } from '#ui/components'
import { useServerPowerAction } from './use-server-power-action'
const props = withDefaults(
defineProps<{
disabled?: boolean
}>(),
{
disabled: false,
},
)
const {
isInstalling,
isRunning,
showStopButton,
busyTooltip,
canTakeAction,
primaryActionText,
initiateAction,
handlePrimaryAction,
} = useServerPowerAction({
disabled: computed(() => props.disabled),
})
</script>

View File

@@ -0,0 +1,85 @@
<template>
<div class="contents">
<ButtonStyled circular type="transparent" size="large">
<TeleportOverflowMenu :options="menuOptions">
<MoreVerticalIcon aria-hidden="true" />
<template #kill>
<SlashIcon class="h-5 w-5" />
<span>Kill server</span>
</template>
<template #allServers>
<ServerIcon class="h-5 w-5" />
<span>All servers</span>
</template>
<template #copy-id>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
<span>Copy ID</span>
</template>
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { ClipboardCopyIcon, MoreVerticalIcon, ServerIcon, SlashIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ButtonStyled } from '#ui/components'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { injectModrinthServerContext } from '#ui/providers'
import { useServerPowerAction } from './use-server-power-action'
const props = withDefaults(
defineProps<{
disabled?: boolean
showCopyIdAction?: boolean
showDebugInfo?: boolean
uptimeSeconds?: number
}>(),
{
disabled: false,
showCopyIdAction: false,
showDebugInfo: false,
uptimeSeconds: 0,
},
)
const router = useRouter()
const { serverId } = injectModrinthServerContext()
const { isInstalling, initiateAction } = useServerPowerAction({
disabled: computed(() => props.disabled),
})
const menuOptions = computed(() => [
...(isInstalling.value
? []
: [
{
id: 'kill',
label: 'Kill server',
icon: SlashIcon,
action: () => initiateAction('Kill'),
},
]),
{
id: 'allServers',
label: 'All servers',
icon: ServerIcon,
action: () => router.push('/hosting/manage'),
},
{
id: 'copy-id',
label: 'Copy ID',
icon: ClipboardCopyIcon,
action: () => copyId(),
shown: props.showCopyIdAction,
},
])
async function copyId() {
await navigator.clipboard.writeText(serverId)
}
</script>

View File

@@ -0,0 +1,179 @@
<template>
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
<ContentPageHeader :class="props.headerClass">
<template #icon>
<ServerIcon
:image="headerImage"
:class="isNuxt ? 'size-20 !rounded-2xl' : 'size-16 !rounded-xl'"
/>
</template>
<template #title>
{{ props.server?.name || 'Server' }}
</template>
<template #stats>
<div
v-if="props.server?.flows?.intro"
class="flex items-center gap-2 font-semibold text-secondary"
>
<SettingsIcon />
Configuring server...
</div>
<div v-else class="flex flex-wrap items-center gap-2">
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium capitalize">
<LoaderIcon :loader="props.server.loader" class="flex shrink-0 [&&]:size-5" />
{{ props.server.loader }} {{ props.server.mc_version }}
</div>
<div
v-if="
props.server?.loader &&
props.server?.net?.domain &&
!userPreferences.hideSubdomainLabel
"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
/>
<div
v-if="props.server?.net?.domain && !userPreferences.hideSubdomainLabel"
v-tooltip="'Copy server address'"
class="flex cursor-pointer items-center gap-2 font-medium hover:underline text-nowrap"
@click="copyServerAddress"
>
<LinkIcon class="flex size-5 shrink-0" />
{{ props.server.net.domain }}.modrinth.gg
</div>
<div v-if="showUptime" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
<div v-if="showUptime" class="flex items-center gap-2 font-medium">
<TimerIcon class="flex size-5 shrink-0" />
{{ formattedUptime }}
</div>
<div
v-if="showProject && (props.server?.loader || props.server?.net?.domain || showUptime)"
class="h-1.5 w-1.5 rounded-full bg-surface-5"
/>
<div
v-if="showProject"
class="flex items-center gap-1.5 font-medium text-primary text-nowrap"
>
Linked to
<Avatar
:src="props.serverProject?.icon_url ?? undefined"
:alt="props.serverProject?.title ?? ''"
size="24px"
/>
<AutoLink :to="serverProjectLink" class="truncate text-primary hover:underline">
{{ props.serverProject?.title }}
</AutoLink>
</div>
</div>
</template>
<template #actions>
<slot name="actions" />
</template>
</ContentPageHeader>
</div>
</template>
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import { NuxtModrinthClient } from '@modrinth/api-client'
import { LinkIcon, LoaderIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { AutoLink, Avatar, ContentPageHeader, ServerIcon } from '#ui/components'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
type ServerProjectSummary = {
id: string
slug?: string | null
title: string
icon_url?: string | null
}
const props = withDefaults(
defineProps<{
server: Archon.Servers.v0.Server | null | undefined
serverImage?: string | null
serverProject?: ServerProjectSummary | null
serverProjectLink?: string
uptimeSeconds?: number
showUptime?: boolean
backHref?: string
breadcrumbClass?: string
headerClass?: string
}>(),
{
serverImage: null,
serverProject: null,
serverProjectLink: '',
uptimeSeconds: 0,
showUptime: true,
backHref: '/hosting/manage',
breadcrumbClass: 'breadcrumb goto-link flex w-fit items-center',
headerClass: '',
},
)
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const { serverId } = injectModrinthServerContext()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
hideSubdomainLabel: false,
})
const headerImage = computed(() => {
if (props.server?.is_medal) {
return 'https://cdn-raw.modrinth.com/medal_icon.webp'
}
return props.serverImage ?? undefined
})
const showUptime = computed(() => props.showUptime && (props.uptimeSeconds ?? 0) > 0)
const formattedUptime = computed(() => {
const uptime = props.uptimeSeconds ?? 0
const days = Math.floor(uptime / (24 * 3600))
const hours = Math.floor((uptime % (24 * 3600)) / 3600)
const minutes = Math.floor((uptime % 3600) / 60)
const seconds = uptime % 60
let formatted = ''
if (days > 0) formatted += `${days}d `
if (hours > 0 || days > 0) formatted += `${hours}h `
formatted += `${minutes}m ${seconds}s`
return formatted.trim()
})
const showProject = computed(() => !!props.serverProject)
const serverProjectLink = computed(() => {
if (props.serverProjectLink) {
return props.serverProjectLink
}
if (!props.serverProject) {
return ''
}
return `/project/${props.serverProject.slug ?? props.serverProject.id}`
})
function copyServerAddress() {
if (!props.server?.net?.domain) return
navigator.clipboard.writeText(`${props.server.net.domain}.modrinth.gg`)
addNotification({
title: 'Server address copied',
text: "Your server's address has been copied to your clipboard.",
type: 'success',
})
}
</script>

View File

@@ -0,0 +1,3 @@
export { default as PanelServerActionButton } from './PanelServerActionButton.vue'
export { default as PanelServerOverflowMenu } from './PanelServerOverflowMenu.vue'
export { default as ServerManageHeader } from './ServerManageHeader.vue'

View File

@@ -0,0 +1,80 @@
import { computed, type Ref } from 'vue'
import { useVIntl } from '#ui/composables/i18n'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '#ui/providers'
export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const isInstalling = computed(() => server.value.status === 'installing')
const isRunning = computed(() => powerState.value === 'running')
const isStopping = computed(() => powerState.value === 'stopping')
const isStarting = computed(() => powerState.value === 'starting')
const isTransitioning = computed(() => isStarting.value || isStopping.value)
const showStopButton = computed(() => isRunning.value || isStarting.value)
const busyTooltip = computed(() => {
if (isStopping.value) return 'Server is currently stopping'
if (isStarting.value) return 'Your server is starting'
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined
})
const canTakeAction = computed(
() => !isTransitioning.value && !options?.disabled?.value && busyReasons.value.length === 0,
)
const primaryActionText = computed(() => {
switch (powerState.value) {
case 'running':
case 'starting':
return 'Restart'
default:
return 'Start'
}
})
async function sendPowerAction(action: PowerAction) {
try {
await client.archon.servers_v0.power(serverId, action)
} catch (error) {
console.error(`Error performing ${action} on server:`, error)
addNotification({
type: 'error',
title: `Failed to ${action.toLowerCase()} server`,
text: 'An error occurred while performing this action.',
})
}
}
function initiateAction(action: PowerAction) {
if (!canTakeAction.value) return
void sendPowerAction(action)
}
function handlePrimaryAction() {
initiateAction(isRunning.value ? 'Restart' : 'Start')
}
return {
isInstalling,
isRunning,
isStopping,
isTransitioning,
showStopButton,
busyTooltip,
canTakeAction,
primaryActionText,
sendPowerAction,
initiateAction,
handlePrimaryAction,
}
}

View File

@@ -0,0 +1,313 @@
<template>
<div class="grid grid-cols-2 gap-8 items-center justify-center py-10 max-w-[760px]">
<!-- Left column -->
<div class="flex flex-col gap-8 items-start pr-8 shrink-0">
<!-- Heading -->
<div class="flex flex-col gap-2 items-start w-[300px]">
<p class="text-3xl leading-9 font-semibold text-contrast">
{{ formatMessage(messages.modrinthHostingLabel) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.noServersDescription) }}
</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-solid 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">
{{ formatMessage(messages.oneClickModInstallsTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.oneClickModInstallsDescription) }}
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-solid 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">
{{ formatMessage(messages.simpleSetupTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.simpleSetupDescription) }}
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-solid 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">
{{ formatMessage(messages.playWithFriendsTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.playWithFriendsDescription) }}
</p>
</div>
</div>
</div>
<!-- CTA section -->
<div class="flex flex-col gap-6 items-start">
<div class="flex flex-col gap-3 items-start">
<ButtonStyled color="brand">
<button @click="onClickNewServer?.()">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.newServerButton) }}
</button>
</ButtonStyled>
<AutoLink
to="https://modrinth.com/hosting"
target="_blank"
class="flex items-center gap-1 hover:brightness-125"
>
{{ formatMessage(messages.learnMoreLink) }}
<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">
{{ formatMessage(messages.alreadyHaveServerLabel) }}
</p>
<ButtonStyled>
<button @click="onClickSignIn?.()">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
</template>
</div>
</div>
<!-- Right column - mod icon grid -->
<div
class="relative flex h-[617px] shrink-0 items-center justify-center overflow-hidden rounded-[40px] pointer-events-none select-none [mask-image:linear-gradient(to_bottom,black_0%,black_35%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,black_0%,black_35%,transparent_100%)]"
>
<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>
</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 { defineMessages, useVIntl } from '../../../composables/i18n'
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 { formatMessage } = useVIntl()
const messages = defineMessages({
modrinthHostingLabel: {
id: 'servers.list-empty.modrinth-hosting-label',
defaultMessage: 'Modrinth Hosting',
},
noServersTitle: {
id: 'servers.list-empty.no-servers-title',
defaultMessage: 'No servers yet',
},
noServersDescription: {
id: 'servers.list-empty.no-servers-description',
defaultMessage: 'Install mods, invite friends, and play together all from the Modrinth App.',
},
oneClickModInstallsTitle: {
id: 'servers.list-empty.one-click-mod-installs-title',
defaultMessage: 'One-click mod installs',
},
oneClickModInstallsDescription: {
id: 'servers.list-empty.one-click-mod-installs-description',
defaultMessage: 'Pick your favourite mods and we handle the rest.',
},
simpleSetupTitle: {
id: 'servers.list-empty.simple-setup-title',
defaultMessage: 'Simple setup',
},
simpleSetupDescription: {
id: 'servers.list-empty.simple-setup-description',
defaultMessage: 'Set up your server just like a single player world.',
},
playWithFriendsTitle: {
id: 'servers.list-empty.play-with-friends-title',
defaultMessage: 'Play with friends',
},
playWithFriendsDescription: {
id: 'servers.list-empty.play-with-friends-description',
defaultMessage: 'Invite friends and get them set up right in the Modrinth App.',
},
newServerButton: {
id: 'servers.list-empty.new-server-button',
defaultMessage: 'New server',
},
learnMoreLink: {
id: 'servers.list-empty.learn-more-link',
defaultMessage: 'Learn more about Modrinth Hosting',
},
alreadyHaveServerLabel: {
id: 'servers.list-empty.already-have-server-label',
defaultMessage: 'Already have a server?',
},
signInButton: {
id: 'servers.list-empty.sign-in-button',
defaultMessage: 'Sign in',
},
})
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