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>
68
packages/ui/src/components/servers/SaveBanner.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
232
packages/ui/src/components/servers/ServerSettingsModal.vue
Normal 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>
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
3
packages/ui/src/components/servers/labels/Separator.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within h-1.5 w-1.5 bg-button-border rounded-full"></div>
|
||||
</template>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 990 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |