feat: server management in app (#5628)

* start new server settings tabs

* update properties tab to match design

* better stying in general tab

* feat: add suffix input for hostname field

* implement tables for allocations and DNS records

* add tags for dns record type

* small gap adjustment

* polish advanced page

* adjust properties page hierarchy

* fix searching properties, empty state and projection radius appearing

* pnpm prepr

* update copy to match designs

* fix suffix input component

* style fixes and match heading size

* small fix

* fix search allocations placeholder

* adjust table styles

* move all installation settings helper text to below input

* update icon to use overflow menu buttons

* fix modal to be consistent

* open advanced properties when search

* remove other and custom properties, and update styles

* remove hide/show all java versions

* handle mc 26

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

* hook up server pages for app

* add server page header to app

* hook up server settings modal

* use large size

* fix card box shadow style

* fix hostname input for app

* fix app/website card containers

* implement external tabs for billing and admin billing

* fix save banner fixed to parent instead of page body

* remove unused prop to FriendsList causing warning in app

* fix client-only not available for app

* fix bottom cut off

* wire node auth

* implement full copy buttons

* dedup copy button tailwind styles

* fix hover class not working in @apply

* fix spacing

* fix error validation styles

* apply consistent styles and spacing

* feat: update hosting server card (#5609)

* fix type errors

* fix some stylesheets not imported for storybook

* add server listing stories

* add fix for frontend stylesheet imports

* remove props.

* convert copy code to use tailwind

* update server listing component styles

* update server info label styles

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

* add new server card buttons

* hook up server cards and implement updated styles

* hook up on download button

* fix tauri throwing error when api returns 204 No Content

* hook up purchase server modal in app

* fix upgrading state loading icon

* pnpm prepr

* filter out servers past 30 days after cancellation

* do not apply opacity on lock or spiner icons

* fix disabled server icon background

* update pending change stage

* handle known suspension states

* refactor: reduce code duplication for server listing

* update disabled state text color

* fix loading icon color

* clean up copy

* fix disabled opacity for server card

* update server listing files kept to be countdown

* implement resubscribe modal

* implement proper provisioning state for resubscribe

* fix duplicate attribute and pnpm prepr

* feat: add shared UI package auth DI

* feat: update purchase server flow (#5714)

* implement server list empty state component

* fix stories and adjust spacing

* implement select plan design refresh

* implement auth for empty server list

* use refs instead of reactive

* pnpm prepr

* fix auth usage for empty servers list

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

* pnpm prepr

* fix max height

* style fix

* fix getCreds no auth is blocking api client

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

* refactor guest plan select logic into provider

* implement sign in or create account popup

* remove force empty serverList

* add download button for suspended mod and generic

* add handling for when user logs out

* QA pass style fixes

* more consistent page styles

* fix duplicate export

* refactor: remove all fallback stuff from resubscribe modal

* implement shared download latest backup util

* i18n pass

* pnpm prepr

* fix region being selected if ping failed

* pnpm prepr

* feat: servers in app finalization (#5744)

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

* fix: terminal gap issues

* feat: swap word wrap for full screen

* fix: stats cards alignment

* fix: stats

* feat: fix console clear + remove copy

* fix: lint

* fix: use reset not clear

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

* feat: implement shared server header for app and website

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

* pnpm prepr

* fix: bugs

* qa: cleanup

* feat: root.vue shared layout

* feat: delete old options pages + fix discovery frontend

* fix: discovery

* fix: misc style/layout issues

* fix page padding

* fix: modal height jankiness

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

* fix: spacing

* remove servers in app feature flag

* Revert "remove servers in app feature flag"

This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2.

* fix: qa

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

---------

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

* qa pass (#5738)

* fix: qa

* feat: qa

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

* fix: lint

* fix: server icon upload/sync and centralize logic

* fix: server settings modal not closing for server reset

* fix: better server sorting

* feat: copy address in server listing card

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

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

* feat: use floating action bar for save banner

* fix: saving state in save bar

* fix: edit server icon styling

* fix: confirm modal to have consistent buttons

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

* pnpm prepr

* feat: search page deduplication (#5754)

* fix: action bar behind modal

* fix: remove warning modal for stopping

* fix: server cards states

* we hate webkit we hate webkit

* fix: update allocation creation to not use modal

* fix: properties tab spacing and styles

* feat: add files tab copy

* fix: advanced properties icon

* fix: remove back to all servers link

* feat: add files tab link in copy

* fix: server header styles to be consistent with instance

* fix: add header icons back

* feat: update instance settings icon to be consistent

* fix: icon container

* feat: upload state persistence across tabs

* fix: server labels text wrapping

* fix: use surface-5 border

* fix: loading spinner showing with onboarding below

* feat: new server button shows purchase modal in website

* fix: billing page not showing quarterly interval

* fix: server downgrade not showing updated subscription notification

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

* pnpm prepr

* add stripe publishable key to app build

* feat: console highlighting

* fix: rename servers title to modrinth hosting

* feat: search fix

* fix: qa/styles

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

* fix: qa

* feat: highlighting fix console

* fix: disable conflicts action

* fix: error dismiss bug

* feat: modal clarification

* fix: files perms issue

* fix: lint

* feat: modal fix

* enable show uptime

* fix: add loading state to edit server icon

* fix: notification panel take in has sidebar from settings

* fix: consistency pass on app settings

* fix: consistency pass on instance settings

* pnpm prepr

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

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

* refactor: better show polling UI code

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

* fix: buttonstyled story

* fix: button styling

* fix: content.vue regression

* feat: project url redirects

* fix: breadcrumbs

* fix: purchase with newly added card

* fix: console ordering problems

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

* fix: log syncing for instances and server panel accidentally

* fix: QA issues

* fix: server page loading state

* fix: stats card logic

* fix: lint

* fix: qa

* fix: console height padding

* fix: terminal padding + loading indicator

* feat: update medal server listing styling

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

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

* fix: qa

* fix: teleport modals to body

* fix: logs tab + qa

* fix: local storage for user preferences

* fix: qa loading indic

* feat: considitonal debug and trace

* fix: jump to top on install bug

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

* feat: servers in app feature flag default true

* fix: highlight row ufll

* fix: webkit thing onto a tag

* fix: input field

* fix: clear fix

* fix: lint

* fix: fmt

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

* pnpm prepr

* fix: menu overflowing

* feat: remove servers in app feature flag

* fix: server stat charts no longer showing color

* fix: library nav no primary state

* fix: better modal height and width

* fix: highlighting bugs

* fix: empty states

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

* fix: medal server listing too bright on light mode

* fix: admon analysis + fix logs

* fix: bug

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

* performance: improve server manage stats loading by splitting reactivity

* fix: deploy + admon + disable highlighting

* fix: clippy

---------

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

* feat: temp wrangler

* fix: lint

* fix: logs upload

* fix: console empty state and admon regressions

* fix: fields

* feat: log deleting + prefetch for Logs.vue

* feat: move delete before share

* feat: clear endpoint

* feat: we ball!

---------

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

View File

@@ -1,12 +1,14 @@
<template>
<div
class="flex size-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
class="flex w-full flex-col bg-surface-2 overflow-hidden rounded-[20px] border border-solid border-surface-4"
:style="!fullscreen && componentHeight ? { minHeight: componentHeight + 'px' } : {}"
:class="{ 'h-full': fullscreen }"
>
<div class="relative min-h-0 pb-1 flex-1 overflow-hidden">
<div ref="containerRef" class="size-full pl-2" />
<div v-if="!isAtBottom" class="absolute bottom-4 right-4">
<ButtonStyled circular type="highlight">
<button class="!shadow-none" aria-label="Scroll to bottom" @click="scrollToBottom">
<div ref="wrapperRef" class="relative min-h-0 flex-1 overflow-hidden pb-2 pt-1">
<div ref="containerRef" class="size-full" />
<div v-if="!isAtBottom" class="absolute bottom-4 right-4 z-10">
<ButtonStyled circular type="highlight" size="large">
<button class="!shadow-2xl" aria-label="Scroll to bottom" @click="scrollToBottom">
<ChevronDownIcon />
</button>
</ButtonStyled>
@@ -14,12 +16,14 @@
</div>
<div
v-if="showInput"
class="border-t border-solid border-b-0 border-x-0 border-surface-5 bg-surface-3 p-4"
ref="inputRef"
class="border-t border-solid border-b-0 border-x-0 border-surface-4 bg-surface-3 p-4"
>
<StyledInput
v-model="commandInput"
:icon="TerminalSquareIcon"
placeholder="Send a command"
:placeholder="disableInput ? 'Server is not running' : 'Send a command'"
:disabled="disableInput"
wrapper-class="w-full"
input-class="!h-10"
@keydown.enter="submitCommand"
@@ -31,7 +35,7 @@
<script setup lang="ts">
import { ChevronDownIcon, TerminalSquareIcon } from '@modrinth/assets'
import type { Terminal } from '@xterm/xterm'
import { ref } from 'vue'
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
@@ -41,27 +45,173 @@ const props = withDefaults(
defineProps<{
scrollback?: number
showInput?: boolean
disableInput?: boolean
fullscreen?: boolean
emptyStateType?: 'server' | 'instance'
}>(),
{
scrollback: 10000,
scrollback: Infinity,
showInput: false,
disableInput: false,
fullscreen: false,
emptyStateType: undefined,
},
)
const FROG = [
'\x1B[32m _ _ \x1B[37m',
'\x1B[32m (o)--(o) \x1B[37m',
'\x1B[32m /.______.\\\x1B[37m',
'\x1B[32m \\________/ \x1B[37m',
'\x1B[32m ./ \\. \x1B[37m',
'\x1B[32m ( . , )\x1B[37m',
'\x1B[32m \\ \\_\\\\ //_/ /\x1B[37m',
'\x1B[32m ~~ ~~ ~~\x1B[37m',
]
const EMPTY_STATE_BUBBLES: Record<string, string[]> = {
server: [
' __________________________________________________',
' / Welcome to your \x1B[32mModrinth Server\x1B[37m! \\',
'| Press the green start button to start your server! |',
' \\____________________________________________________/',
],
instance: [
' _____________________________________________________________',
' / Start your instance in the top right to start \\',
'| recieving live logs! |',
' \\_____________________________________________________________/',
],
}
const emit = defineEmits<{
command: [command: string]
ready: [terminal: Terminal]
}>()
const containerRef = ref<HTMLElement | null>(null)
const wrapperRef = ref<HTMLElement | null>(null)
const inputRef = ref<HTMLElement | null>(null)
const commandInput = ref('')
const componentHeight = ref(0)
const { terminal, searchAddon, isAtBottom, write, writeln, clear, reset, fit, scrollToBottom } =
useTerminal({
container: containerRef,
scrollback: props.scrollback,
onReady: (term) => emit('ready', term),
})
const snappedHeight = ref<number | null>(null)
const showingEmptyState = ref(false)
const {
terminal,
searchAddon,
isAtBottom,
write,
writeln,
clear,
reset,
fit: rawFit,
scrollToBottom,
} = useTerminal({
container: containerRef,
scrollback: props.scrollback,
onReady: (term) => {
nextTick(() => {
updateComponentHeight()
snapToRows()
})
emit('ready', term)
},
onResize: () => {
updateComponentHeight()
},
})
function writeEmptyState() {
if (!terminal.value || !props.emptyStateType) return
terminal.value.reset()
const bubble = EMPTY_STATE_BUBBLES[props.emptyStateType]
if (bubble) {
for (const line of [...bubble, ...FROG]) {
terminal.value.writeln(line)
}
}
showingEmptyState.value = true
}
function clearEmptyState() {
if (!showingEmptyState.value) return
terminal.value?.reset()
showingEmptyState.value = false
}
function getWrapperMargins() {
if (!wrapperRef.value) return 0
const style = getComputedStyle(wrapperRef.value)
return parseFloat(style.marginTop) + parseFloat(style.marginBottom)
}
function snapToRows() {
if (!props.fullscreen) {
snappedHeight.value = null
return
}
const screen = containerRef.value?.querySelector('.xterm-screen') as HTMLElement | null
if (!screen) {
snappedHeight.value = null
return
}
const inputH = inputRef.value?.offsetHeight ?? 0
const borderW = 2
snappedHeight.value = screen.offsetHeight + getWrapperMargins() + inputH + borderW
}
let resizeDebounce: ReturnType<typeof setTimeout> | null = null
function handleWindowResize() {
if (!props.fullscreen) return
if (resizeDebounce) clearTimeout(resizeDebounce)
snappedHeight.value = null
resizeDebounce = setTimeout(() => {
rawFit()
nextTick(() => snapToRows())
}, 50)
}
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleWindowResize)
if (resizeDebounce) clearTimeout(resizeDebounce)
})
function fit() {
rawFit()
snapToRows()
}
watch(
() => props.fullscreen,
() => {
if (props.fullscreen) {
nextTick(() => {
rawFit()
nextTick(() => snapToRows())
})
} else {
snappedHeight.value = null
componentHeight.value = 0
}
},
)
function updateComponentHeight() {
const screen = containerRef.value?.querySelector('.xterm-screen') as HTMLElement | null
if (!screen) return
const screenH = screen.offsetHeight
const inputH = inputRef.value?.offsetHeight ?? 0
const borderW = 2
componentHeight.value = screenH + getWrapperMargins() + inputH + borderW
}
const submitCommand = () => {
const cmd = commandInput.value.trim()
@@ -81,6 +231,9 @@ defineExpose({
searchAddon,
isAtBottom,
commandInput,
showingEmptyState,
writeEmptyState,
clearEmptyState,
})
</script>
@@ -89,13 +242,39 @@ defineExpose({
height: 100% !important;
}
.xterm .xterm-scrollable-element {
height: 100% !important;
.xterm-viewport {
background-color: var(--surface-2) !important;
}
.xterm .xterm-screen {
min-height: 100% !important;
margin-left: auto !important;
margin-right: auto !important;
width: 100%;
margin-left: 8px;
margin-right: auto;
}
.xterm .xterm-rows {
position: relative;
z-index: 7;
}
.xterm .xterm-decoration-container {
overflow: visible !important;
}
.xterm .xterm-decoration-container > div {
box-sizing: content-box !important;
margin-left: -12px !important;
padding-left: 12px !important;
padding-right: 12px !important;
}
.xterm-scrollable-element > .scrollbar.vertical {
width: 8px !important;
}
.xterm-scrollable-element > .scrollbar.vertical > div {
width: 6px !important;
border-radius: 8px !important;
contain: layout style !important;
}
</style>

View File

@@ -204,6 +204,9 @@ const colorVariables = computed(() => {
if (props.type === 'outlined' || props.type === 'transparent') {
colors.bg = 'transparent'
if (props.hoverColorFill === 'none') {
hoverColors.bg = 'transparent'
}
colors = setColorFill(colors, props.colorFill === 'auto' ? 'text' : props.colorFill)
hoverColors = setColorFill(
hoverColors,
@@ -263,8 +266,10 @@ const fontSize = computed(() => {
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
@apply flex cursor-pointer flex-row items-center justify-center border-solid border border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
-webkit-font-smoothing: antialiased;
will-change: filter;
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,

View File

@@ -0,0 +1,196 @@
<template>
<Transition name="collapsible-admonition">
<div
v-if="!dismissed"
:data-type="type"
class="collapsible-admonition flex flex-col rounded-2xl border border-solid text-contrast overflow-hidden"
>
<div
class="flex w-full cursor-pointer items-center gap-6 p-4"
:class="headerBgClasses[type]"
@click="expanded = !expanded"
>
<div class="flex flex-1 items-center gap-3">
<TriangleAlertIcon :class="['h-5 w-5 flex-none', iconClasses[type]]" />
<span class="text-base font-semibold text-contrast">
<slot name="header">{{ header }}</slot>
</span>
</div>
<div class="flex items-center gap-2">
<ButtonStyled circular type="highlight-colored-text" :color="buttonColors[type]">
<button aria-label="Toggle" @click.stop="expanded = !expanded">
<ChevronDownIcon
class="h-4 w-4 transition-transform duration-300"
:class="expanded && 'rotate-180'"
/>
</button>
</ButtonStyled>
<ButtonStyled
v-if="dismissible"
circular
type="highlight-colored-text"
:color="buttonColors[type]"
>
<button aria-label="Dismiss" @click.stop="handleDismiss">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
</div>
<div
class="grid transition-[grid-template-rows] duration-300 ease-in-out"
:class="expanded ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="overflow-hidden">
<slot>
<div
v-for="(item, index) in items"
:key="index"
class="collapsible-admonition__item collapsible-admonition__item--bordered flex flex-col gap-1 p-4"
>
<p class="m-0 text-base font-semibold text-contrast">
{{ item.title }}
</p>
<div
v-for="(desc, di) in item.descriptions"
:key="di"
class="flex items-start gap-1.5"
>
<LightBulbIcon :class="['mt-0.5 h-5 w-5 flex-none', iconClasses[type]]" />
<span class="text-base text-contrast/85">{{ desc }}</span>
</div>
</div>
</slot>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ChevronDownIcon, LightBulbIcon, TriangleAlertIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import ButtonStyled from './ButtonStyled.vue'
export interface CollapsibleAdmonitionItem {
title: string
descriptions?: string[]
}
withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical' | 'success'
header?: string
items?: CollapsibleAdmonitionItem[]
dismissible?: boolean
}>(),
{
type: 'critical',
header: '',
items: () => [],
dismissible: false,
},
)
const emit = defineEmits<{
dismiss: []
}>()
const expanded = defineModel<boolean>({ default: false })
const dismissed = ref(false)
function handleDismiss() {
dismissed.value = true
emit('dismiss')
}
const headerBgClasses = {
info: 'bg-bg-blue',
warning: 'bg-bg-orange',
critical: 'bg-bg-red',
success: 'bg-bg-green',
}
const iconClasses = {
info: 'text-brand-blue',
warning: 'text-brand-orange',
critical: 'text-brand-red',
success: 'text-brand-green',
}
const buttonColors: Record<string, 'blue' | 'orange' | 'red' | 'green'> = {
info: 'blue',
warning: 'orange',
critical: 'red',
success: 'green',
}
</script>
<style scoped>
.collapsible-admonition[data-type='critical'] {
border-color: rgba(255, 73, 110, 0.6);
}
.collapsible-admonition[data-type='critical'] .collapsible-admonition__item {
background: rgba(255, 73, 110, 0.1);
}
.collapsible-admonition[data-type='critical'] .collapsible-admonition__item--bordered {
border-top: 1px solid rgba(255, 73, 110, 0.6);
}
.collapsible-admonition[data-type='info'] {
border-color: rgba(47, 158, 255, 0.6);
}
.collapsible-admonition[data-type='info'] .collapsible-admonition__item {
background: rgba(47, 158, 255, 0.1);
}
.collapsible-admonition[data-type='info'] .collapsible-admonition__item--bordered {
border-top: 1px solid rgba(47, 158, 255, 0.6);
}
.collapsible-admonition[data-type='warning'] {
border-color: rgba(255, 163, 71, 0.6);
}
.collapsible-admonition[data-type='warning'] .collapsible-admonition__item {
background: rgba(255, 163, 71, 0.1);
}
.collapsible-admonition[data-type='warning'] .collapsible-admonition__item--bordered {
border-top: 1px solid rgba(255, 163, 71, 0.6);
}
.collapsible-admonition[data-type='success'] {
border-color: rgba(27, 217, 106, 0.6);
}
.collapsible-admonition[data-type='success'] .collapsible-admonition__item {
background: rgba(27, 217, 106, 0.1);
}
.collapsible-admonition[data-type='success'] .collapsible-admonition__item--bordered {
border-top: 1px solid rgba(27, 217, 106, 0.6);
}
.collapsible-admonition-enter-active,
.collapsible-admonition-leave-active {
transition:
opacity 300ms ease-in-out,
transform 300ms ease-in-out;
}
.collapsible-admonition-enter-from {
opacity: 0;
transform: translateY(-10px);
}
.collapsible-admonition-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>

View File

@@ -1,45 +1,37 @@
<template>
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
<div class="grid grid-cols-[1fr_auto] gap-y-6">
<div class="flex gap-4 w-full">
<div class="flex flex-wrap items-start gap-4">
<div class="flex min-w-0 flex-1 gap-4">
<slot name="icon" />
<div class="flex flex-col gap-2 justify-center w-full">
<div class="flex justify-between items-start gap-2">
<div class="flex flex-col gap-1.5 justify-center">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
<slot name="title" />
</h1>
<slot name="title-suffix" />
</div>
<p
v-if="$slots.summary"
class="m-0 max-w-[44rem] empty:hidden"
:class="[disableLineClamp ? '' : 'line-clamp-2']"
>
<slot name="summary" />
</p>
</div>
<div v-if="$slots.summary" class="flex gap-2 items-start max-md:hidden">
<slot name="actions" />
<div class="flex min-w-0 flex-col gap-2 justify-center">
<div class="flex flex-col gap-1.5 justify-center">
<div class="flex flex-wrap items-center gap-2">
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
<slot name="title" />
</h1>
<slot name="title-suffix" />
</div>
<p
v-if="$slots.summary"
class="m-0 max-w-[44rem] empty:hidden"
:class="[disableLineClamp ? '' : 'line-clamp-2']"
>
<slot name="summary" />
</p>
</div>
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
<slot name="stats" />
</div>
</div>
</div>
<div v-if="!$slots.summary" class="flex gap-2 items-start max-md:hidden">
<div class="flex flex-wrap gap-2 items-center">
<slot name="actions" />
</div>
</div>
<div class="flex justify-between">
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden md:hidden">
<div v-if="$slots.stats" class="flex justify-between md:hidden">
<div class="flex flex-wrap gap-3 empty:hidden">
<slot name="stats" />
</div>
<div class="flex gap-2 items-start self-end md:hidden">
<slot name="actions" />
</div>
</div>
</div>
</template>

View File

@@ -1,5 +1,9 @@
<template>
<button class="code" :class="{ copied }" :title="formatMessage(copiedMessage)" @click="copyText">
<button
class="!m-0 inline-flex w-fit select-text items-center gap-2 rounded-[10px] bg-[var(--color-button-bg)] px-2 py-1 font-mono text-sm text-primary transition-[opacity,filter,transform,outline] duration-200 ease-in-out hover:brightness-[1.25] active:scale-95 active:brightness-[0.8] motion-reduce:transition-none [&>svg]:h-[1em] [&>svg]:w-[1em]"
:title="formatMessage(copiedMessage)"
@click="copyText"
>
<span>{{ text }}</span>
<CheckIcon v-if="copied" />
<ClipboardCopyIcon v-else />
@@ -27,42 +31,3 @@ async function copyText() {
copied.value = true
}
</script>
<style lang="scss" scoped>
.code {
color: var(--color-text);
display: inline-flex;
grid-gap: 0.5rem;
font-family: var(--mono-font);
font-size: var(--font-size-sm);
margin: 0;
padding: 0.25rem 0.5rem;
background-color: var(--color-button-bg);
width: fit-content;
border-radius: 10px;
user-select: text;
transition:
opacity 0.5s ease-in-out,
filter 0.2s ease-in-out,
transform 0.05s ease-in-out,
outline 0.2s ease-in-out;
@media (prefers-reduced-motion) {
transition: none !important;
}
svg {
width: 1em;
height: 1em;
}
&:hover {
filter: brightness(0.85);
}
&:active {
transform: scale(0.95);
filter: brightness(0.8);
}
}
</style>

View File

@@ -0,0 +1,53 @@
<template>
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<button
:class="pillClass(modelValue.length === 0)"
:aria-pressed="modelValue.length === 0"
@click="modelValue = []"
>
<slot name="all"> All </slot>
</button>
<button
v-for="option in options"
:key="option.id"
:class="pillClass(modelValue.includes(option.id))"
:aria-pressed="modelValue.includes(option.id)"
@click="toggle(option.id)"
>
{{ option.label }}
</button>
</div>
</template>
<script setup lang="ts">
import { FilterIcon } from '@modrinth/assets'
export interface FilterPillOption {
id: string
label: string
}
const modelValue = defineModel<string[]>({ required: true })
defineProps<{
options: FilterPillOption[]
}>()
function pillClass(active: boolean) {
return [
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
active
? 'border-green bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
]
}
function toggle(id: string) {
if (modelValue.value.includes(id)) {
modelValue.value = modelValue.value.filter((f) => f !== id)
} else {
modelValue.value = [...modelValue.value, id]
}
}
</script>

View File

@@ -61,7 +61,7 @@ onUnmounted(() => {
<Transition name="floating-action-bar" appear>
<div
v-if="shown"
class="floating-action-bar drop-shadow-2xl fixed z-10 p-4 bottom-0"
class="floating-action-bar drop-shadow-2xl fixed z-[21] p-4 bottom-0"
aria-live="polite"
>
<div

View File

@@ -2,7 +2,7 @@
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold drop-shadow-xl"
:class="{ 'shadow-sm': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">

View File

@@ -1,7 +1,7 @@
<template>
<div class="overflow-hidden rounded-2xl border border-solid border-surface-3">
<table class="w-full border-separate border-spacing-0">
<thead>
<div class="overflow-hidden rounded-2xl border border-solid border-surface-5">
<table class="w-full table-fixed border-separate border-spacing-0 border-surface-5">
<thead class="">
<tr class="bg-surface-3">
<th v-if="showSelection" class="w-10 pl-4">
<Checkbox
@@ -50,7 +50,7 @@
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td v-if="showSelection" class="w-10">
<td v-if="showSelection" class="w-10 border-solid border-0 border-t border-surface-5">
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4"
@@ -60,7 +60,7 @@
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 first:pl-4 last:pr-4"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-5"
:class="`text-${column.align ?? 'left'}`"
:style="column.width ? { width: column.width } : undefined"
>

View File

@@ -163,7 +163,7 @@ const calculateMenuPosition = () => {
const triggerRect = triggerRef.value.getBoundingClientRect()
const menuRect = menuRef.value.getBoundingClientRect()
const menuWidth = menuRect.width
const menuWidth = menuRect.width + 16
const menuHeight = menuRect.height
const margin = 8
@@ -180,10 +180,8 @@ const calculateMenuPosition = () => {
if (triggerRect.right - menuWidth >= margin) {
left = triggerRect.right - menuWidth
} else if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
left = triggerRect.left
} else {
left = Math.max(margin, window.innerWidth - menuWidth - margin)
left = Math.max(margin, triggerRect.left)
}
return {

View File

@@ -14,6 +14,8 @@ export { default as Card } from './Card.vue'
export { default as Checkbox } from './Checkbox.vue'
export { default as Chips } from './Chips.vue'
export { default as Collapsible } from './Collapsible.vue'
export type { CollapsibleAdmonitionItem } from './CollapsibleAdmonition.vue'
export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
export type { ComboboxOption } from './Combobox.vue'
export { default as Combobox } from './Combobox.vue'
@@ -29,6 +31,8 @@ export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
export { default as FileInput } from './FileInput.vue'
export type { FilterBarOption } from './FilterBar.vue'
export { default as FilterBar } from './FilterBar.vue'
export type { FilterPillOption } from './FilterPills.vue'
export { default as FilterPills } from './FilterPills.vue'
export { default as FloatingActionBar } from './FloatingActionBar.vue'
export { default as FloatingPanel } from './FloatingPanel.vue'
export { default as FormattedTag } from './FormattedTag.vue'

View File

@@ -1,149 +0,0 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { InfoIcon } from '@modrinth/assets'
import { Menu } from 'floating-vue'
import { computed, inject, type Ref } from 'vue'
import { useFormatPrice } from '../../composables'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const props = withDefaults(
defineProps<{
plan: Labrinth.Billing.Internal.Product
title: MessageDescriptor
description: MessageDescriptor
buttonColor?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
mostPopular?: boolean
selected?: boolean
}>(),
{
buttonColor: 'standard',
mostPopular: false,
selected: false,
},
)
const emit = defineEmits<{
(e: 'select', plan: Labrinth.Billing.Internal.Product): void
}>()
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
// TODO: Use DI framework when merged.
const selectedInterval = inject<Ref<ServerBillingInterval>>('selectedInterval')
const currency = inject<string>('currency')
const perMonth = computed(() => {
if (!props.plan || !currency || !selectedInterval?.value) return undefined
const total = getPriceForInterval(props.plan, currency, selectedInterval.value)
if (!total) return undefined
return total / monthsInInterval[selectedInterval.value]
})
const planSpecs = computed(() => {
const metadata = props.plan.metadata
if (metadata.type === 'pyro' || metadata.type === 'medal') {
return {
ram: metadata.ram,
storage: metadata.storage,
cpu: metadata.cpu,
}
}
return null
})
const mostPopularStyle = computed(() => {
if (!props.mostPopular) return undefined
const style: Record<string, string> = {
backgroundImage:
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
boxShadow: '0px 12px 38.1px rgba(27, 217, 106, 0.13)',
}
if (!props.selected) {
style.borderColor = 'rgba(12, 107, 52, 0.55)'
}
return style
})
</script>
<template>
<div
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
:class="{
'bg-brand-highlight border-brand': selected,
'bg-button-bg border-transparent': !selected,
'!bg-bg': mostPopular,
}"
:style="mostPopularStyle"
role="button"
tabindex="0"
:aria-pressed="selected"
@click="emit('select', plan)"
@keydown.enter.prevent="emit('select', plan)"
@keydown.space.prevent="emit('select', plan)"
>
<div class="flex h-full flex-col justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center justify-between">
<span class="text-2xl font-semibold text-contrast">
{{ formatMessage(title) }}
</span>
<div
v-if="mostPopular"
class="relative w-fit rounded-full bg-highlight-green px-3 py-1 text-sm font-bold text-brand backdrop-blur-lg"
>
Most Popular
</div>
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(perMonth, currency, true) }}
<span class="text-sm font-semibold text-secondary">
/ month{{ selectedInterval !== 'monthly' ? `, billed ${selectedInterval}` : '' }}
</span>
</span>
</div>
<span class="text-sm">{{ formatMessage(description) }}</span>
<div class="w-fit">
<Menu
placement="bottom-start"
:triggers="['hover', 'focus']"
:auto-hide="true"
:delay="{ show: 100, hide: 120 }"
:distance="6"
>
<template #default="{ shown }">
<div
class="flex w-fit items-center gap-2 cursor-help text-sm font-medium cursor-default select-none outline-none"
:class="shown ? 'text-primary' : 'text-secondary'"
role="button"
tabindex="0"
aria-haspopup="true"
:aria-expanded="shown"
>
<InfoIcon />
View plan details
</div>
</template>
<template #popper>
<div v-if="planSpecs" class="w-fit rounded-md border border-contrast/10 p-3 shadow-lg">
<ServersSpecs
:ram="planSpecs.ram"
:storage="planSpecs.storage"
:cpus="planSpecs.cpu"
/>
</div>
</template>
</Menu>
</div>
</div>
</div>
</template>

View File

@@ -8,9 +8,12 @@ import {
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { useQueryClient } from '@tanstack/vue-query'
import type Stripe from 'stripe'
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
import { useStripe } from '../../composables/stripe'
import { commonMessages } from '../../utils'
@@ -23,6 +26,8 @@ import PaymentMethodSelector from './ServersPurchase2PaymentMethod.vue'
import ConfirmPurchase from './ServersPurchase3Review.vue'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const queryClient = useQueryClient()
export type RegionPing = {
region: string
@@ -34,7 +39,7 @@ export type ServerBillingInterval = 'monthly' | 'quarterly' | 'yearly'
const props = defineProps<{
publishableKey: string
returnUrl: string
returnUrl?: string
paymentMethods: Stripe.PaymentMethod[]
customer: Stripe.Customer
currency: string
@@ -121,6 +126,16 @@ const titles: Record<Step, MessageDescriptor> = {
review: defineMessage({ id: 'servers.purchase.step.review.title', defaultMessage: 'Review' }),
}
const purchaseSuccessTitle = defineMessage({
id: 'servers.purchase.notification.success.title',
defaultMessage: 'Purchase success',
})
const purchaseSuccessText = defineMessage({
id: 'servers.purchase.notification.success.text',
defaultMessage: 'Your Modrinth Hosting purchase was completed successfully.',
})
const currentRegion = computed(() => {
return props.regions.find((region) => region.shortcode === selectedRegion.value)
})
@@ -215,7 +230,22 @@ async function afterProceed(step: string) {
async function setStep(step: Step | undefined, skipValidation = false) {
if (!step) {
await submitPayment(props.returnUrl)
const success = await submitPayment(props.returnUrl)
if (success) {
modal.value?.hide()
addNotification({
title: formatMessage(purchaseSuccessTitle),
text: formatMessage(purchaseSuccessText),
type: 'success',
})
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['servers'] }),
queryClient.invalidateQueries({ queryKey: ['servers', 'v1'] }),
])
emit('purchase-success')
}
return
}
@@ -264,7 +294,8 @@ function begin(
selectedInterval.value = interval
customServer.value = !selectedPlan.value
selectedPaymentMethod.value = undefined
currentStep.value = steps[0]
const skipPlanStep = props.planStage && plan !== undefined
currentStep.value = skipPlanStep ? (steps[1] ?? steps[0]) : steps[0]
skipPaymentMethods.value = true
projectId.value = project
modal.value?.show()
@@ -274,8 +305,8 @@ defineExpose({
show: begin,
})
defineEmits<{
(e: 'hide'): void
const emit = defineEmits<{
(e: 'hide' | 'purchase-success'): void
}>()
function handleChooseCustom() {
@@ -283,6 +314,10 @@ function handleChooseCustom() {
selectedPlan.value = undefined
}
function handleProceed() {
setStep(nextStep.value)
}
// When the user explicitly wants to change or add a payment method from Review
// we must disable the auto-skip behavior, clear any selected method, and
// navigate to the Payment step so Stripe Elements can mount.
@@ -328,7 +363,7 @@ function goToBreadcrumbStep(id: string) {
</template>
</div>
</template>
<div class="w-[40rem] max-w-full">
<div :class="currentStep === 'plan' ? 'w-[56rem] max-w-full' : 'w-[40rem] max-w-full'">
<PlanSelector
v-if="currentStep === 'plan'"
v-model:plan="selectedPlan"
@@ -337,6 +372,7 @@ function goToBreadcrumbStep(id: string) {
:available-products="availableProducts"
:currency="currency"
@choose-custom="handleChooseCustom"
@proceed="handleProceed"
/>
<RegionSelector
v-else-if="currentStep === 'region'"
@@ -374,7 +410,7 @@ function goToBreadcrumbStep(id: string) {
:ping="currentPing"
:loading="paymentMethodLoading"
:selected-payment-method="selectedPaymentMethod || inputtedPaymentMethod"
:has-payment-method="hasPaymentMethod"
:has-payment-method="!!hasPaymentMethod"
:tax="tax"
:total="total"
:no-payment-required="noPaymentRequired"
@@ -410,12 +446,12 @@ function goToBreadcrumbStep(id: string) {
<button v-if="previousStep" @click="previousStep && setStep(previousStep, true)">
<LeftArrowIcon /> {{ formatMessage(commonMessages.backButton) }}
</button>
<button v-else @click="modal?.hide()">
<button v-else-if="currentStep !== 'plan'" @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<ButtonStyled v-if="currentStep !== 'plan'" color="brand">
<button
v-tooltip="
currentStep === 'review' && !acceptedEula && !noPaymentRequired

View File

@@ -0,0 +1,287 @@
<template>
<NewModal ref="modal" max-width="550px">
<template #title>
<div class="text-2xl font-semibold text-contrast">{{ formatMessage(messages.title) }}</div>
</template>
<div class="flex w-[44rem] max-w-full flex-col gap-6">
<template v-if="modalData">
<p class="m-0 text-secondary leading-relaxed">
<IntlFormatted
:message-id="messages.description"
:values="{ serverName: modalData.serverName }"
>
<template #server-name="{ children }">
<span class="font-semibold text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<div v-if="formattedPrice" class="flex flex-col gap-2.5">
<span class="text-contrast font-semibold">{{ formatMessage(messages.planLabel) }}</span>
<div
class="flex items-center justify-between gap-4 rounded-2xl border border-solid border-surface-5 bg-surface-2 p-5"
>
<div class="flex flex-col gap-1">
<div class="truncate font-semibold text-contrast">{{ modalData.planName }}</div>
<div
v-if="
modalData.ramGb != null ||
modalData.storageGb != null ||
modalData.sharedCpus != null
"
class="text-secondary flex gap-1.5 font-medium text-sm items-center"
>
<template v-if="modalData.ramGb != null">
{{ formatMessage(messages.ramLabel, { ramGb: modalData.ramGb }) }}
</template>
<template v-if="modalData.storageGb != null">
<div
v-if="modalData.ramGb != null"
class="h-1.5 w-1.5 bg-button-border rounded-full"
></div>
{{ formatMessage(messages.storageLabel, { storageGb: modalData.storageGb }) }}
</template>
<template v-if="modalData.sharedCpus != null">
<div
v-if="modalData.ramGb != null || modalData.storageGb != null"
class="h-1.5 w-1.5 bg-button-border rounded-full"
></div>
{{ formatMessage(messages.cpusLabel, { sharedCpus: modalData.sharedCpus }) }}
</template>
</div>
</div>
<div class="flex flex-col gap-1 items-end">
<div class="font-semibold text-contrast">
{{ formattedPrice }}
</div>
<div v-if="intervalLabel" class="text-secondary">{{ intervalLabel }}</div>
</div>
</div>
</div>
<p v-if="formattedNextChargeDate" class="m-0 text-primary">
<IntlFormatted
:message-id="messages.nextCharge"
:values="{ date: formattedNextChargeDate }"
>
<template #charge-date="{ children }">
<span class="font-semibold text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
</template>
<template v-else>
<p class="m-0 text-secondary">{{ formatMessage(messages.failedLoad) }}</p>
</template>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-5" @click="handleCancel">
<XIcon />
{{ formatMessage(messages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canResubscribe" @click="handleResubscribe">
<RotateCounterClockwiseIcon />
{{ formatMessage(messages.resubscribeButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { RotateCounterClockwiseIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
import { useFormatDateTime, useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import IntlFormatted from '../base/IntlFormatted.vue'
import { ButtonStyled, NewModal } from '../index'
const { addNotification } = injectNotificationManager()
const { formatMessage } = useVIntl()
type BillingInterval = Labrinth.Billing.Internal.PriceDuration
export type ResubscribeModalPayload = {
subscriptionId: string
wasSuspended: boolean
serverName: string
planName: string
ramGb?: number
storageGb?: number
sharedCpus?: number
priceCents?: number
currencyCode?: string
interval: BillingInterval
nextChargeDate?: string | number | Date
}
type ResubscribeModalState = {
subscriptionId: string
wasSuspended: boolean
serverName: string
planName: string
ramGb?: number
storageGb?: number
sharedCpus?: number
priceCents?: number
currencyCode?: string
interval: BillingInterval
nextChargeDate?: string | number | Date
}
const emit = defineEmits<{
(e: 'cancel'): void
(e: 'resubscribe', payload: { subscriptionId: string; wasSuspended: boolean }): void
}>()
const formatDate = useFormatDateTime({ dateStyle: 'long' })
const formatPrice = useFormatPrice()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const modalData = ref<ResubscribeModalState | null>(null)
const messages = defineMessages({
title: { id: 'billing.resubscribe-modal.title', defaultMessage: 'Resubscribe to Server' },
description: {
id: 'billing.resubscribe-modal.description',
defaultMessage:
'You are about to resubscribe to <server-name>{serverName}</server-name>. Your subscription will be reactivated and your server will continue running without interruption.',
},
planLabel: { id: 'billing.resubscribe-modal.plan-label', defaultMessage: 'Plan' },
ramLabel: { id: 'billing.resubscribe-modal.ram', defaultMessage: '{ramGb} GB RAM' },
storageLabel: {
id: 'billing.resubscribe-modal.storage',
defaultMessage: '{storageGb} GB Storage',
},
cpusLabel: {
id: 'billing.resubscribe-modal.cpus',
defaultMessage: '{sharedCpus} Shared CPUs',
},
nextCharge: {
id: 'billing.resubscribe-modal.next-charge',
defaultMessage: 'Your next charge will be on <charge-date>{date}</charge-date>.',
},
failedLoad: {
id: 'billing.resubscribe-modal.failed-load',
defaultMessage: 'Failed to load subscription details.',
},
cancelButton: { id: 'billing.resubscribe-modal.cancel', defaultMessage: 'Cancel' },
resubscribeButton: {
id: 'billing.resubscribe-modal.resubscribe',
defaultMessage: 'Resubscribe',
},
intervalMonthly: { id: 'billing.resubscribe-modal.interval.monthly', defaultMessage: '/month' },
intervalQuarterly: {
id: 'billing.resubscribe-modal.interval.quarterly',
defaultMessage: '/quarter',
},
intervalYearly: { id: 'billing.resubscribe-modal.interval.yearly', defaultMessage: '/year' },
intervalFiveDays: {
id: 'billing.resubscribe-modal.interval.five-days',
defaultMessage: '/5 days',
},
errorTitle: { id: 'billing.resubscribe-modal.error.title', defaultMessage: 'Error' },
errorText: {
id: 'billing.resubscribe-modal.error.text',
defaultMessage: 'Cannot resubscribe, failed to load subscription details.',
},
})
const canResubscribe = computed(() => !!modalData.value?.subscriptionId)
const intervalLabel = computed(() => {
switch (modalData.value?.interval) {
case 'monthly':
return formatMessage(messages.intervalMonthly)
case 'quarterly':
return formatMessage(messages.intervalQuarterly)
case 'yearly':
return formatMessage(messages.intervalYearly)
case 'five-days':
return formatMessage(messages.intervalFiveDays)
default:
return null
}
})
const formattedPrice = computed(() => {
const { priceCents, currencyCode } = modalData.value ?? {}
if (priceCents == null || currencyCode == null) return ''
return formatPrice(priceCents, currencyCode)
})
const normalizedNextChargeDate = computed(() => {
if (!modalData.value?.nextChargeDate) return null
const date = new Date(modalData.value.nextChargeDate)
if (Number.isNaN(date.getTime())) {
return null
}
return date
})
const formattedNextChargeDate = computed(() =>
normalizedNextChargeDate.value ? formatDate(normalizedNextChargeDate.value) : '',
)
function show(payload: ResubscribeModalPayload) {
if (!payload) {
addNotification({
type: 'error',
title: formatMessage(messages.errorTitle),
text: formatMessage(messages.errorText),
})
return
}
modalData.value = {
subscriptionId: payload.subscriptionId,
wasSuspended: payload.wasSuspended,
serverName: payload.serverName.trim(),
planName: payload.planName,
ramGb: payload.ramGb,
storageGb: payload.storageGb,
sharedCpus: payload.sharedCpus,
priceCents: payload.priceCents,
currencyCode: payload.currencyCode,
interval: payload.interval,
nextChargeDate: payload.nextChargeDate,
}
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function handleCancel() {
hide()
emit('cancel')
}
function handleResubscribe() {
if (!canResubscribe.value || !modalData.value?.subscriptionId) return
hide()
emit('resubscribe', {
subscriptionId: modalData.value.subscriptionId,
wasSuspended: modalData.value.wasSuspended,
})
}
defineExpose({
show,
hide,
})
</script>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon, ExternalIcon, XIcon } from '@modrinth/assets'
import { computed, ref, useTemplateRef } from 'vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import NewModal from '../modal/NewModal.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import PlanSelector from './ServersPurchase0Plan.vue'
const props = withDefaults(
defineProps<{
availableProducts: Labrinth.Billing.Internal.Product[]
currency: string
loggedIn?: boolean
}>(),
{
loggedIn: false,
},
)
const emit = defineEmits<{
(e: 'continue', payload: { interval: ServerBillingInterval; planId: string | null }): void
}>()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const selectedPlan = ref<Labrinth.Billing.Internal.Product>()
const selectedInterval = ref<ServerBillingInterval>('quarterly')
const showSignInPrompt = ref(false)
const defaultPlan = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
return (
props.availableProducts.find((p) => p?.metadata?.type === 'pyro' && p.metadata.ram === 6144) ??
props.availableProducts.find((p) => p?.metadata?.type === 'pyro') ??
props.availableProducts[0]
)
})
function emitSelection() {
emit('continue', {
interval: selectedInterval.value,
planId: selectedPlan.value?.id ?? null,
})
}
function continueWithSelection() {
if (!props.loggedIn) {
showSignInPrompt.value = true
return
}
emitSelection()
modal.value?.hide()
}
function chooseCustom() {
selectedPlan.value = undefined
}
function closeSignInPrompt() {
showSignInPrompt.value = false
}
function continueToAuth() {
emitSelection()
closeSignInPrompt()
modal.value?.hide()
}
function handleModalHide() {
closeSignInPrompt()
}
function show(initialInterval?: ServerBillingInterval, initialPlanId?: string | null) {
closeSignInPrompt()
selectedInterval.value = initialInterval ?? 'quarterly'
if (initialPlanId === null) {
selectedPlan.value = undefined
} else if (initialPlanId) {
selectedPlan.value = props.availableProducts.find((product) => product.id === initialPlanId)
} else {
selectedPlan.value = defaultPlan.value
}
modal.value?.show()
}
defineExpose({
show,
})
</script>
<template>
<NewModal ref="modal" :on-hide="handleModalHide" no-padding>
<template #title>
<div class="flex items-center gap-1 font-bold text-secondary">
<span class="text-contrast">Plan</span>
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
<span class=""> Region </span>
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
<span class=""> Payment method </span>
<ChevronRightIcon class="h-5 w-5 text-secondary" stroke-width="3" />
<span class=""> Review </span>
</div>
</template>
<div class="relative w-[56rem] max-w-full">
<div class="w-full h-full p-6">
<PlanSelector
v-model:plan="selectedPlan"
v-model:interval="selectedInterval"
:available-products="availableProducts"
:currency="currency"
@choose-custom="chooseCustom"
@proceed="continueWithSelection"
/>
</div>
<Transition
enter-active-class="transition-opacity duration-150 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showSignInPrompt"
class="pointer-events-auto absolute inset-0 z-20 bg-black/60"
@click="closeSignInPrompt"
/>
</Transition>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="translate-y-4 opacity-0"
enter-to-class="translate-y-0 opacity-100"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="translate-y-0 opacity-100"
leave-to-class="translate-y-4 opacity-0"
>
<div
v-if="showSignInPrompt"
class="absolute inset-x-0 bottom-0 -m-px z-30 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-6 shadow-2xl"
>
<div class="absolute right-4 top-4">
<ButtonStyled circular type="transparent">
<button aria-label="Close sign in prompt" @click="closeSignInPrompt">
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="mx-auto flex max-w-xl flex-col items-center gap-4 text-center">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Sign in to continue your purchase</div>
<div class="">You need a Modrinth account to add your billing details.</div>
</div>
<ButtonStyled color="brand" class="mt-2">
<button @click="continueToAuth">
Sign in or create an account
<ExternalIcon class="size-4" />
</button>
</ButtonStyled>
</div>
</div>
</Transition>
</div>
</NewModal>
</template>

View File

@@ -1,13 +1,15 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { computed, provide } from 'vue'
import { RightArrowIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { useFormatPrice } from '../../composables'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { getPriceForInterval, monthsInInterval } from '../../utils/product-utils'
import ButtonStyled from '../base/ButtonStyled.vue'
import OptionGroup from '../base/OptionGroup.vue'
import ModalBasedServerPlan from './ModalBasedServerPlan.vue'
import type { ServerBillingInterval } from './ModrinthServersPurchaseModal.vue'
import ServersSpecs from './ServersSpecs.vue'
const { formatMessage } = useVIntl()
const formatPrice = useFormatPrice()
@@ -23,18 +25,10 @@ const availableBillingIntervals = ['monthly', 'quarterly']
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
const selectedInterval = defineModel<ServerBillingInterval>('interval')
const emit = defineEmits<{
(e: 'choose-custom'): void
(e: 'choose-custom' | 'proceed'): void
}>()
const messages = defineMessages({
title: {
id: 'servers.purchase.step.plan.prompt',
defaultMessage: 'Choose a plan',
},
subtitle: {
id: 'servers.purchase.step.plan.subtitle',
defaultMessage: 'Pick the amount of RAM and specs that fit your needs.',
},
selectPlan: {
id: 'servers.purchase.step.plan.select',
defaultMessage: 'Select Plan',
@@ -43,9 +37,17 @@ const messages = defineMessages({
id: 'servers.purchase.step.plan.get-started',
defaultMessage: 'Get started',
},
billed: {
id: 'servers.purchase.step.plan.billed',
defaultMessage: 'billed {interval}',
smallTitle: {
id: 'servers.purchase.step.plan.small',
defaultMessage: 'Small',
},
mediumTitle: {
id: 'servers.purchase.step.plan.medium',
defaultMessage: 'Medium',
},
largeTitle: {
id: 'servers.purchase.step.plan.large',
defaultMessage: 'Large',
},
smallDesc: {
id: 'servers.purchase.step.plan.small.desc',
@@ -67,6 +69,18 @@ const messages = defineMessages({
id: 'servers.purchase.step.plan.most-popular',
defaultMessage: 'Most Popular',
},
billingSubtitle: {
id: 'servers.purchase.step.plan.billing-subtitle',
defaultMessage: 'Available in North America, Europe, and Southeast Asia.',
},
customHeading: {
id: 'servers.purchase.step.plan.custom.heading',
defaultMessage: 'Know exactly what you need?',
},
yourCurrentPlan: {
id: 'servers.purchase.step.plan.your-current-plan',
defaultMessage: 'Your current plan',
},
})
const isSameAsExistingPlan = computed(() => {
@@ -95,8 +109,12 @@ const plansByRam = computed(() => {
return byName
})
function handleCustomPlan() {
emit('choose-custom')
function planSpecs(plan: Labrinth.Billing.Internal.Product) {
const m = plan.metadata
if (m.type === 'pyro' || m.type === 'medal') {
return { ram: m.ram, storage: m.storage, cpus: m.cpu }
}
return null
}
function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
@@ -106,22 +124,6 @@ function pricePerMonth(plan?: Labrinth.Billing.Internal.Product) {
return total / monthsInInterval[selectedInterval.value]
}
const customPricePerGb = computed(() => {
// Calculate lowest price per GB among products for current interval
let min: number | undefined
for (const p of props.availableProducts) {
const perMonth = pricePerMonth(p)
const metadata = p?.metadata
if (!metadata || (metadata.type !== 'pyro' && metadata.type !== 'medal')) continue
const ramGb = metadata.ram / 1024
if (perMonth && ramGb > 0) {
const perGb = perMonth / ramGb
if (min === undefined || perGb < min) min = perGb
}
}
return min
})
const customStartingPrice = computed(() => {
let min: number | undefined
for (const p of props.availableProducts) {
@@ -131,26 +133,47 @@ const customStartingPrice = computed(() => {
return min
})
provide('currency', props.currency)
provide('selectedInterval', selectedInterval)
const smallPrice = computed(() => pricePerMonth(plansByRam.value.small))
const mediumPrice = computed(() => pricePerMonth(plansByRam.value.medium))
const largePrice = computed(() => pricePerMonth(plansByRam.value.large))
const smallSpecs = computed(() =>
plansByRam.value.small ? planSpecs(plansByRam.value.small) : null,
)
const mediumSpecs = computed(() =>
plansByRam.value.medium ? planSpecs(plansByRam.value.medium) : null,
)
const largeSpecs = computed(() =>
plansByRam.value.large ? planSpecs(plansByRam.value.large) : null,
)
function selectPlan(plan: Labrinth.Billing.Internal.Product) {
selectedPlan.value = plan
emit('proceed')
}
function selectCustom() {
emit('choose-custom')
emit('proceed')
}
</script>
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 mb-5 !mt-0">
<span></span>
<div class="flex flex-col items-center gap-2 mb-5 !mt-0">
<OptionGroup
v-slot="{ option }"
v-model="selectedInterval"
class="!bg-button-bg !shadow-none"
:options="availableBillingIntervals"
>
<template v-if="option === 'monthly'"> Pay monthly </template>
<span v-else-if="option === 'quarterly'"> Pay quarterly </span>
<span v-else-if="option === 'yearly'"> Pay yearly </span>
<template v-if="option === 'monthly'">Monthly</template>
<span v-else-if="option === 'quarterly'">
Quarterly <span class="text-brand">(Save 16%)</span>
</span>
</OptionGroup>
<span class="bg-transparent p-0 text-sm text-xs font-bold text-brand">
{{ selectedInterval !== 'quarterly' ? 'Save' : 'Saving' }} 16% with quarterly billing!
</span>
<div class="text-sm text-secondary text-center">
{{ formatMessage(messages.billingSubtitle) }}
</div>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-out"
@@ -164,68 +187,182 @@ provide('selectedInterval', selectedInterval)
Your server is already on this plan, choose a different plan.
</div>
</Transition>
<div class="grid grid-cols-1 sm:grid-cols-2 !gap-4">
<ModalBasedServerPlan
v-if="plansByRam.small"
:plan="plansByRam.small"
:title="{ id: 'servers.purchase.step.plan.small', defaultMessage: 'Small' }"
:description="messages.smallDesc"
:button-color="'blue'"
:selected="selectedPlan?.id === plansByRam.small.id"
@select="selectedPlan = $event"
/>
<ModalBasedServerPlan
v-if="plansByRam.medium"
:plan="plansByRam.medium"
:title="{ id: 'servers.purchase.step.plan.medium', defaultMessage: 'Medium' }"
:description="messages.mediumDesc"
most-popular
:button-color="'brand'"
:selected="selectedPlan?.id === plansByRam.medium.id"
@select="selectedPlan = $event"
/>
<ModalBasedServerPlan
v-if="plansByRam.large"
:plan="plansByRam.large"
:title="{ id: 'servers.purchase.step.plan.large', defaultMessage: 'Large' }"
:description="messages.largeDesc"
:button-color="'purple'"
:selected="selectedPlan?.id === plansByRam.large.id"
@select="selectedPlan = $event"
/>
<div class="grid grid-cols-3 gap-4 items-start">
<!-- Small -->
<div
v-if="customStartingPrice"
class="rounded-2xl p-4 font-semibold transition-all duration-300 experimental-styles-within h-full border-2 border-solid cursor-pointer select-none"
:class="!selectedPlan ? 'bg-brand-highlight border-brand' : 'bg-button-bg border-transparent'"
role="button"
tabindex="0"
:aria-pressed="!selectedPlan"
@click="handleCustomPlan"
@keydown.enter.prevent="handleCustomPlan"
@keydown.space.prevent="handleCustomPlan"
v-if="plansByRam.small && smallPrice"
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
>
<div class="flex h-full flex-col justify-between">
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-2xl font-semibold text-contrast">Custom</span>
<div>
<div class="text-3xl font-semibold text-contrast leading-none">
{{ formatMessage(messages.smallTitle) }}
</div>
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(smallPrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.smallDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="blue" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.small.id"
@click="selectPlan(plansByRam.small!)"
>
{{
existingPlan?.id === plansByRam.small.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="smallSpecs"
:ram="smallSpecs.ram"
:storage="smallSpecs.storage"
:cpus="smallSpecs.cpus"
/>
</div>
<!-- Medium (Most Popular) -->
<div v-if="plansByRam.medium && mediumPrice" class="flex flex-col items-center relative h-full">
<div
class="z-10 -mb-3.5 rounded-full text-sm font-medium text-brand whitespace-nowrap absolute -top-3 right-4 bg-surface-3"
>
<div
class="bg-brand-highlight border border-solid border-highlight-green px-2.5 py-0.5 rounded-full"
>
{{ formatMessage(messages.mostPopular) }}
</div>
</div>
<div
class="w-full flex flex-col gap-4 rounded-2xl bg-brand-inverted border-brand-highlight border border-solid p-5 h-full"
:style="{
backgroundImage:
'radial-gradient(86.12% 101.64% at 95.97% 94.07%, rgba(27, 217, 106, 0.23) 0%, rgba(14, 115, 56, 0.2) 100%)',
}"
>
<div>
<div class="text-3xl font-semibold text-contrast leading-none">
{{ formatMessage(messages.mediumTitle) }}
</div>
<span class="m-0 text-lg font-bold text-contrast">
{{ formatPrice(customStartingPrice, currency, true) }}
<span class="text-sm font-semibold text-secondary">
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(mediumPrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</span>
<span class="text-sm">{{ formatMessage(messages.customDesc) }}</span>
</div>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-3">
<span v-if="customPricePerGb" class="text-sm text-secondary">
From {{ formatPrice(customPricePerGb, currency, true) }} / GB
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.mediumDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="brand" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.medium.id"
@click="selectPlan(plansByRam.medium!)"
>
{{
existingPlan?.id === plansByRam.medium.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="mediumSpecs"
:ram="mediumSpecs.ram"
:storage="mediumSpecs.storage"
:cpus="mediumSpecs.cpus"
/>
</div>
</div>
<!-- Large -->
<div
v-if="plansByRam.large && largePrice"
class="flex flex-col gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5 h-full"
>
<div>
<div class="text-3xl font-semibold text-contrast leading-none">
{{ formatMessage(messages.largeTitle) }}
</div>
<div class="mt-1">
<span class="text-2xl font-bold text-contrast">
{{ formatPrice(largePrice, currency, true) }}
</span>
<span class="text-sm">
/ month<template v-if="selectedInterval !== 'monthly'"
>, billed {{ selectedInterval }}</template
>
</span>
</div>
<div class="mt-2 text-sm text-primary">
{{ formatMessage(messages.largeDesc) }}
</div>
</div>
<div class="w-full">
<ButtonStyled color="purple" class="w-full">
<button
class="w-full"
:disabled="existingPlan?.id === plansByRam.large.id"
@click="selectPlan(plansByRam.large!)"
>
{{
existingPlan?.id === plansByRam.large.id
? formatMessage(messages.yourCurrentPlan)
: formatMessage(messages.selectPlan)
}}
</button>
</ButtonStyled>
</div>
<ServersSpecs
v-if="largeSpecs"
:ram="largeSpecs.ram"
:storage="largeSpecs.storage"
:cpus="largeSpecs.cpus"
/>
</div>
</div>
<!-- Custom plan banner -->
<div
v-if="customStartingPrice"
class="mt-4 flex items-center justify-between gap-4 rounded-2xl bg-surface-2 border-2 border-solid border-transparent p-5"
>
<div class="flex flex-col gap-1">
<div class="text-2xl font-semibold text-contrast">
{{ formatMessage(messages.customHeading) }}
</div>
<div class="text-sm text-secondary">
{{ formatMessage(messages.customDesc) }}
</div>
</div>
<div class="flex flex-col items-end gap-2 shrink-0">
<ButtonStyled>
<button class="flex items-center gap-2" @click="selectCustom">
{{ formatMessage(messages.getStarted) }} <RightArrowIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<div class="text-sm text-secondary whitespace-nowrap">
Starting at {{ formatPrice(customStartingPrice, currency, true) }}/mo
</div>
</div>
</div>

View File

@@ -30,7 +30,7 @@ const props = defineProps<{
availableProducts: Labrinth.Billing.Internal.Product[]
}>()
const loading = ref(true)
const loading = ref(false)
const checkingCustomStock = ref(false)
const selectedPlan = defineModel<Labrinth.Billing.Internal.Product>('plan')
const selectedRegion = defineModel<string>('region')
@@ -214,12 +214,11 @@ async function updateStock() {
onMounted(() => {
// auto select region with lowest ping
loading.value = true
bestPing.value =
props.pings.length > 0
? props.pings.reduce((acc, cur) => {
return acc.ping < cur.ping ? acc : cur
})?.region
: undefined
bestPing.value = [...props.pings].sort((a, b) => {
if (a.ping <= 0) return 1
if (b.ping <= 0) return -1
return a.ping - b.ping
})[0]?.region
selectedRegion.value = undefined
selectedRam.value = minRam.value
checkingCustomStock.value = true

View File

@@ -179,7 +179,7 @@ function setInterval(newInterval: ServerBillingInterval) {
<template>
<div class="grid sm:grid-cols-[3fr_2fr] gap-4">
<div class="bg-table-alternateRow p-4 rounded-2xl">
<div class="bg-surface-2 p-4 rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<ModrinthServersIcon class="flex h-5 w-fit" />
<TagItem>{{ planName }}</TagItem>
@@ -194,9 +194,7 @@ function setInterval(newInterval: ServerBillingInterval) {
/>
</div>
</div>
<div
class="bg-table-alternateRow p-4 rounded-2xl flex flex-col gap-2 items-center justify-center"
>
<div class="bg-surface-2 p-4 rounded-2xl flex flex-col gap-2 items-center justify-center">
<img
v-if="flag"
class="aspect-[16/10] max-w-12 w-full object-cover rounded-md border-1 border-button-border border-solid"
@@ -304,10 +302,7 @@ function setInterval(newInterval: ServerBillingInterval) {
"
/>
</template>
<div
v-else
class="p-4 rounded-2xl bg-table-alternateRow text-sm text-secondary leading-relaxed"
>
<div v-else class="p-4 rounded-2xl bg-surface-2 text-sm text-secondary leading-relaxed">
No payment required. Your downgrade will apply at the end of the current billing period.
</div>
</div>

View File

@@ -9,7 +9,7 @@
:customer="customer"
:payment-methods="paymentMethods"
:currency="selectedCurrency"
:return-url="`${props.siteUrl}/hosting/manage`"
:return-url="checkoutReturnUrl"
:pings="regionPings"
:regions="regionsData"
:refresh-payment-methods="fetchPaymentData"
@@ -40,10 +40,14 @@ import { computed, ref, watch } from 'vue'
const props = defineProps<{
stripePublishableKey: string
siteUrl: string
siteUrl?: string
products: Labrinth.Billing.Internal.Product[]
}>()
const checkoutReturnUrl = computed(() => {
return props.siteUrl ? `${props.siteUrl}/hosting/manage` : undefined
})
const { addNotification } = injectNotificationManager()
const { labrinth, archon } = injectModrinthClient()
const debug = useDebugLogger('ServersUpgradeModalWrapper')
@@ -185,11 +189,6 @@ function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
}
const subscription = ref<Labrinth.Billing.Internal.UserSubscription | null>(null)
// Dry run state
const dryRunResponse = ref<{
requires_payment: boolean
required_payment_is_proration: boolean
} | null>(null)
const pendingDowngradeBody = ref<Labrinth.Billing.Internal.EditSubscriptionRequest | null>(null)
const currentPlanFromSubscription = computed<Labrinth.Billing.Internal.Product | undefined>(() => {
return subscription.value
@@ -246,20 +245,16 @@ async function initiatePayment(
dry: true,
})
if (dry && typeof dry === 'object' && 'payment_intent_id' in dry) {
dryRunResponse.value = {
requires_payment: !!dry.payment_intent_id,
required_payment_is_proration: true,
}
pendingDowngradeBody.value = transformedBody
if (dry.payment_intent_id) {
return await finalizeImmediate(transformedBody)
} else {
return null
}
} else {
// Fallback if dry run not supported
const requiresPayment =
dry && typeof dry === 'object' && 'requires_payment' in dry && dry.requires_payment
if (requiresPayment) {
// Upgrade: requires payment — finalize to create the payment intent
return await finalizeImmediate(transformedBody)
} else {
// Downgrade or no payment change — defer until user confirms
pendingDowngradeBody.value = transformedBody
return null
}
} catch (e) {
debug('Dry run failed, attempting immediate patch', e)
@@ -291,8 +286,10 @@ async function finalizeImmediate(body: Labrinth.Billing.Internal.EditSubscriptio
}
async function finalizeDowngrade() {
if (!subscription.value || !pendingDowngradeBody.value) return
try {
if (!subscription.value || !pendingDowngradeBody.value)
throw new Error('Missing subscription or pending downgrade body')
await finalizeImmediate(pendingDowngradeBody.value)
addNotification({
title: 'Subscription updated',
@@ -307,7 +304,6 @@ async function finalizeDowngrade() {
})
throw e
} finally {
dryRunResponse.value = null
pendingDowngradeBody.value = null
}
}

View File

@@ -1,5 +1,7 @@
export { default as AddPaymentMethodModal } from './AddPaymentMethodModal.vue'
export { default as ModrinthServersPurchaseModal } from './ModrinthServersPurchaseModal.vue'
export { default as PurchaseModal } from './PurchaseModal.vue'
export { default as ResubscribeModal } from './ResubscribeModal.vue'
export { default as ServersGuestPlanModal } from './ServersGuestPlanModal.vue'
export { default as ServersSpecs } from './ServersSpecs.vue'
export { default as ServersUpgradeModalWrapper } from './ServersUpgradeModalWrapper.vue'

View File

@@ -1,5 +1,5 @@
<template>
<NewModal ref="modal" :noblur="noblur" :danger="danger" :on-hide="onHide">
<NewModal ref="modal" :noblur="noblur" :danger="danger" :on-hide="onHide" max-width="550px">
<template #title>
<slot name="title">
<span class="font-extrabold text-contrast text-lg">{{ title }}</span>
@@ -30,19 +30,19 @@
placeholder="Type here..."
wrapper-class="max-w-[20rem]"
/>
<div class="flex gap-2">
<div class="flex gap-2 justify-end">
<ButtonStyled>
<button class="!shadow-none" @click="hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
<ButtonStyled :color="danger ? 'red' : 'brand'">
<button :disabled="action_disabled" @click="proceed">
<component :is="proceedIcon" />
{{ proceedLabel }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>

View File

@@ -11,7 +11,9 @@ import {
import QrcodeVue from 'qrcode.vue'
import { computed, nextTick, ref } from 'vue'
import { Button, Modal, StyledInput } from '../index'
import { injectNotificationManager } from '#ui/providers'
import { Button, ButtonStyled, NewModal, StyledInput } from '../index'
const props = defineProps({
header: {
@@ -38,6 +40,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
socialButtons: {
type: Boolean,
default: true,
},
onHide: {
type: Function,
default() {
@@ -47,6 +53,7 @@ const props = defineProps({
})
const shareModal = ref(null)
const { addNotification } = injectNotificationManager()
const qrCode = ref(null)
const qrImage = ref(null)
@@ -94,7 +101,21 @@ const copyImage = async () => {
}
const copyText = async () => {
await navigator.clipboard.writeText(url.value ?? content.value)
try {
await navigator.clipboard.writeText(url.value ?? content.value)
addNotification({
type: 'success',
title: 'Link copied',
text: 'The link has been copied to your clipboard.',
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
addNotification({
type: 'error',
title: 'Failed to copy text',
text: message,
})
}
}
const sendEmail = computed(
@@ -125,50 +146,59 @@ defineExpose({
</script>
<template>
<Modal ref="shareModal" :header="header" :noblur="noblur" :on-hide="onHide">
<div class="share-body">
<div v-if="link" class="qr-wrapper">
<NewModal ref="shareModal" :header="header" :noblur="noblur" :on-hide="onHide">
<div class="flex flex-row flex-wrap items-center gap-2">
<div v-if="link" class="group relative mx-auto">
<div ref="qrCode">
<QrcodeVue :value="url" class="qr-code" margin="3" />
<QrcodeVue :value="url" class="!bg-white rounded-[var(--radius-md)]" margin="3" />
</div>
<Button
v-tooltip="'Copy QR code'"
icon-only
class="copy-button"
aria-label="Copy QR code"
@click="copyImage"
>
<ClipboardCopyIcon aria-hidden="true" />
</Button>
<ButtonStyled circular>
<button
v-tooltip="'Copy QR code'"
class="absolute top-0 right-0 m-2 opacity-0 transition-all duration-200 ease-in-out group-hover:opacity-100 group-focus-within:opacity-100 motion-reduce:transition-none"
aria-label="Copy QR code"
@click="copyImage"
>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<StyledInput v-else v-model="content" multiline resize="vertical" wrapper-class="h-full">
<template #right>
<Button
<button
v-tooltip="'Copy Text'"
icon-only
type="button"
aria-label="Copy Text"
class="copy-button transparent"
class="absolute top-0 right-0 m-2 grid h-10 w-10 cursor-pointer place-content-center rounded-lg border-none bg-button-bg text-primary transition-all hover:bg-button-bg-hover hover:brightness-125 active:scale-95"
@click="copyText"
>
<ClipboardCopyIcon aria-hidden="true" />
</Button>
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</button>
</template>
</StyledInput>
<div class="all-buttons">
<StyledInput v-if="link" type="text" :model-value="url" readonly wrapper-class="w-full">
<template #right>
<Button v-tooltip="'Copy Text'" aria-label="Copy Text" class="r-btn" @click="copyText">
<ClipboardCopyIcon aria-hidden="true" />
</Button>
</template>
</StyledInput>
<div class="button-row">
<div class="flex flex-grow flex-col justify-center gap-2">
<button
v-if="link"
v-tooltip="'Copy Link'"
type="button"
aria-label="Copy Link"
class="flex h-10 w-full cursor-pointer items-center justify-between gap-2 rounded-lg border-none bg-button-bg px-3 pr-1.5 text-primary transition-all hover:bg-button-bg-hover hover:brightness-125 active:scale-95"
@click="copyText"
>
<span class="cursor-pointer truncate text-left font-semibold text-primary">
{{ url }}
</span>
<div class="grid h-10 w-10 place-content-center">
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
</div>
</button>
<div v-if="socialButtons" class="flex flex-row gap-2">
<Button v-if="canShare" v-tooltip="'Share'" aria-label="Share" icon-only @click="share">
<ShareIcon aria-hidden="true" />
</Button>
<a
v-tooltip="'Send as an email'"
class="btn icon-only"
class="btn icon-only fill-contrast text-contrast"
:href="sendEmail"
:target="targetParameter"
aria-label="Send as an email"
@@ -178,7 +208,7 @@ defineExpose({
<a
v-if="link"
v-tooltip="'Open link in browser'"
class="btn icon-only"
class="btn icon-only fill-contrast text-contrast"
:target="targetParameter"
:href="url"
aria-label="Open link in browser"
@@ -187,7 +217,7 @@ defineExpose({
</a>
<a
v-tooltip="'Toot about it'"
class="btn mastodon icon-only"
class="btn icon-only fill-contrast text-contrast bg-[#563acc]"
:target="targetParameter"
:href="sendToot"
aria-label="Toot about it"
@@ -196,7 +226,7 @@ defineExpose({
</a>
<a
v-tooltip="'Tweet about it'"
class="btn twitter icon-only"
class="btn icon-only fill-contrast text-contrast bg-[#1da1f2]"
:target="targetParameter"
:href="sendTweet"
aria-label="Tweet about it"
@@ -205,7 +235,7 @@ defineExpose({
</a>
<a
v-tooltip="'Share on Reddit'"
class="btn reddit icon-only"
class="btn icon-only fill-contrast text-contrast bg-[#ff4500]"
:target="targetParameter"
:href="postOnReddit"
aria-label="Share on Reddit"
@@ -215,76 +245,5 @@ defineExpose({
</div>
</div>
</div>
</Modal>
</NewModal>
</template>
<style scoped lang="scss">
.share-body {
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
gap: var(--gap-sm);
padding: var(--gap-lg);
}
.all-buttons {
display: flex;
flex-direction: column;
gap: var(--gap-sm);
flex-grow: 1;
justify-content: center;
}
.button-row {
display: flex;
flex-direction: row;
gap: var(--gap-sm);
.btn {
fill: var(--color-contrast);
color: var(--color-contrast);
&.reddit {
background-color: #ff4500;
}
&.mastodon {
background-color: #563acc;
}
&.twitter {
background-color: #1da1f2;
}
}
}
.qr-wrapper {
position: relative;
margin: 0 auto;
&:hover {
.copy-button {
opacity: 1;
}
}
}
.qr-code {
background-color: white !important;
border-radius: var(--radius-md);
}
.copy-button {
position: absolute;
top: 0;
right: 0;
margin: var(--gap-sm);
transition: all 0.2s ease-in-out;
opacity: 0;
@media (prefers-reduced-motion) {
transition: none !important;
}
}
</style>

View File

@@ -1,6 +1,7 @@
<script lang="ts"></script>
<script setup lang="ts">
import { RightArrowIcon } from '@modrinth/assets'
import { type Component, computed, nextTick, ref } from 'vue'
import { type MessageDescriptor, useVIntl } from '../../composables/i18n'
@@ -9,7 +10,8 @@ import NewModal from './NewModal.vue'
export interface Tab {
name: MessageDescriptor
icon: Component
content: Component
content?: Component
href?: string
badge?: MessageDescriptor
shown?: boolean
}
@@ -75,15 +77,19 @@ defineExpose({ show, hide, selectedTab, setTab })
<template v-if="$slots.title" #title>
<slot name="title" />
</template>
<div class="grid grid-cols-[auto_1fr] p-4">
<div class="grid grid-cols-[auto_1fr] p-6 pb-3 pr-0">
<div
class="flex flex-col gap-1 border-solid pr-4 border-0 border-r-[1px] border-divider min-w-[200px]"
>
<button
<component
:is="tab.href ? 'a' : 'button'"
v-for="(tab, index) in visibleTabs"
:key="index"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all ${selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="() => setTab(index)"
:href="tab.href ?? undefined"
:target="tab.href ? '_blank' : undefined"
:rel="tab.href ? 'noopener noreferrer' : undefined"
:class="`flex gap-2 items-center text-left rounded-xl px-4 py-2 border-none text-nowrap font-semibold cursor-pointer active:scale-[0.97] transition-all no-underline ${!tab.href && selectedTab === index ? 'bg-button-bgSelected text-button-textSelected' : 'bg-transparent text-button-text hover:bg-button-bg hover:text-contrast'}`"
@click="!tab.href && setTab(index)"
>
<component :is="tab.icon" class="w-4 h-4 flex-shrink-0" />
<span>{{ formatMessage(tab.name) }}</span>
@@ -93,7 +99,8 @@ defineExpose({ show, hide, selectedTab, setTab })
>
{{ formatMessage(tab.badge) }}
</span>
</button>
<RightArrowIcon v-if="tab.href" class="size-4 ml-auto" />
</component>
<slot name="footer" />
</div>
@@ -101,38 +108,41 @@ defineExpose({ show, hide, selectedTab, setTab })
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-10"
enter-to-class="opacity-100 max-h-4"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-10"
leave-from-class="opacity-100 max-h-4"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-10 bg-gradient-to-b from-bg-raised to-transparent"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-4 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="scrollContainer"
class="min-w-[400px] h-[500px] overflow-y-auto px-4"
class="overflow-y-auto px-6 pb-6 h-screen max-h-[min(65vh,600px)]"
@scroll="checkScrollState"
>
<Suspense>
<component :is="visibleTabs[selectedTab].content" />
<component
:is="visibleTabs[selectedTab]?.content"
v-if="visibleTabs[selectedTab]?.content"
/>
</Suspense>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-10"
enter-to-class="opacity-100 max-h-16"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-10"
leave-from-class="opacity-100 max-h-16"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-10 bg-gradient-to-t from-bg-raised to-transparent"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-16 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>

View File

@@ -0,0 +1,50 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" :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.warningText) }}</p>
</div>
</NewModal>
</template>
<script setup lang="ts">
import type { UploadHandle } from '@modrinth/api-client'
import { ref } from 'vue'
import { AppearingProgressBar } from '#ui/components/base'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import NewModal from './NewModal.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'servers.setup.uploading-modpack.header',
defaultMessage: 'Uploading modpack',
},
warningText: {
id: 'servers.setup.upload-warning',
defaultMessage: "Please don't close this page while uploading.",
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const uploadedBytes = ref(0)
const totalBytes = ref(0)
function track<T>(handle: UploadHandle<T>): Promise<T> {
uploadedBytes.value = 0
totalBytes.value = 0
modal.value?.show()
handle.onProgress(({ loaded, total }) => {
uploadedBytes.value = loaded
totalBytes.value = total
})
return handle.promise.finally(() => {
modal.value?.hide()
})
}
defineExpose({ track })
</script>

View File

@@ -8,3 +8,4 @@ export { default as OpenInAppModal } from './OpenInAppModal.vue'
export { default as ShareModal } from './ShareModal.vue'
export type { Tab as TabbedModalTab } from './TabbedModal.vue'
export { default as TabbedModal } from './TabbedModal.vue'
export { default as UploadProgressModal } from './UploadProgressModal.vue'

View File

@@ -5,7 +5,7 @@
'intercom-present': isIntercomPresent,
'location-left': notificationLocation === 'left',
'location-right': notificationLocation === 'right',
'has-sidebar': hasSidebar,
'has-sidebar': hasSidebar && !hasModalActive,
}"
>
<transition-group name="notifs">
@@ -92,6 +92,8 @@ import {
} from '@modrinth/assets'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useModalStack } from '#ui/composables/modal-stack.ts'
import { injectNotificationManager, type WebNotification } from '../../providers'
import ButtonStyled from '../base/ButtonStyled.vue'
@@ -151,6 +153,8 @@ onMounted(() => {
})
})
const { hasModal: hasModalActive } = useModalStack()
withDefaults(
defineProps<{
hasSidebar?: boolean
@@ -167,9 +171,11 @@ withDefaults(
bottom: 1.5rem;
z-index: 200;
width: 450px;
transition: bottom 0.25s ease-in-out;
&.location-right {
right: 1.5rem;
transition: right 0.25s ease-in-out;
&.has-sidebar {
right: 325px;
@@ -242,4 +248,8 @@ withDefaults(
transform: translateX(-100%) scale(0.8);
}
}
body.floating-action-bar-shown .vue-notification-group {
bottom: calc(90px);
}
</style>

View File

@@ -192,6 +192,7 @@
import type { ProjectStatus } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { AutoLink, Avatar } from '../../base'
import { SmartClickable } from '../../base/index.ts'
@@ -217,7 +218,7 @@ defineEmits<{
const props = defineProps<{
layout: 'list' | 'grid'
link?: string | (() => void)
link?: string | RouteLocationRaw | (() => void)
iconUrl?: string
title: string
author?: {

View File

@@ -22,7 +22,11 @@
(x) => x.provided && !overriddenProvidedFilterTypes.includes(x.type),
)"
:key="`provided-filter-${providedItem.type}-${providedItem.option}`"
v-tooltip="formatMessage(providedMessage ?? defaultProvidedMessage)"
v-tooltip="
typeof providedMessage === 'string'
? providedMessage
: formatMessage(providedMessage ?? defaultProvidedMessage)
"
:style="{ '--_bg-color': `var(--color-raised-bg)` }"
>
<LockIcon />
@@ -47,7 +51,7 @@ const props = defineProps<{
filters: FilterType[]
providedFilters: FilterValue[]
overriddenProvidedFilterTypes: string[]
providedMessage?: MessageDescriptor
providedMessage?: MessageDescriptor | string
}>()
const defaultProvidedMessage = defineMessage({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,10 @@ export * from './icons'
export { default as InstallingBanner } from './InstallingBanner.vue'
export * from './labels'
export * from './marketing'
export type { PendingChange } from './ServerListing.vue'
export { default as ServerListing } from './ServerListing.vue'
export { default as SaveBanner } from './SaveBanner.vue'
export * from './server-header'
export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue'
export { type PendingChange, default as ServerListing } from './ServerListing.vue'
export { default as ServerSettingsModal } from './ServerSettingsModal.vue'
export { default as ServerSetupModal } from './ServerSetupModal.vue'
export { default as ServersPromo } from './ServersPromo.vue'

View File

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

View File

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

View File

@@ -1,21 +1,30 @@
<template>
<div>
<ServerPlayerCount
v-if="showPlayerCount"
:current-players="serverData.players.current"
:max-players="serverData.players.max"
:online="serverData.online"
/>
<ServerGameLabel
v-if="showGameLabel"
:game="serverData.game"
:mc-version="serverData.mc_version ?? ''"
:no-separator="column || !showPlayerCount"
:is-link="linked"
/>
<ServerLoaderLabel
v-if="showLoaderLabel"
:loader="serverData.loader"
:loader-version="serverData.loader_version ?? ''"
:no-separator="column"
:no-separator="column || !showGameLabel"
:is-link="linked"
/>
<ServerSubdomainLabel
v-if="serverData.net?.domain"
:subdomain="serverData.net.domain"
:no-separator="column"
:server-id="serverId"
:no-separator="column || (!showLoaderLabel && !showGameLabel)"
:is-link="linked"
/>
<ServerUptimeLabel
@@ -29,14 +38,17 @@
<script setup lang="ts">
import ServerGameLabel from './ServerGameLabel.vue'
import ServerLoaderLabel from './ServerLoaderLabel.vue'
import ServerPlayerCount from './ServerPlayerCount.vue'
import ServerSubdomainLabel from './ServerSubdomainLabel.vue'
import ServerUptimeLabel from './ServerUptimeLabel.vue'
interface ServerInfoLabelsProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
serverData: Record<string, any>
serverId?: string
showGameLabel: boolean
showLoaderLabel: boolean
showPlayerCount?: boolean
uptimeSeconds?: number
column?: boolean
linked?: boolean

View File

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

View File

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

View File

@@ -2,14 +2,17 @@
<div
v-if="subdomain && !isHidden"
v-tooltip="'Copy custom URL'"
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer"
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer hover:underline"
data-subdomain-label
@click.stop.prevent
>
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex flex-row items-center gap-2">
<LinkIcon class="flex size-5 shrink-0" />
<Separator v-if="!noSeparator" />
<div class="flex flex-row items-center gap-1.5">
<LinkIcon />
<div
class="flex min-w-0 text-sm font-semibold"
:class="serverId ? 'hover:underline' : ''"
class="flex min-w-0 font-medium text-sm text-nowrap"
:class="props.subdomain ? 'hover:underline' : ''"
@click="copySubdomain"
>
{{ subdomain }}.modrinth.gg
@@ -25,10 +28,13 @@ import { useStorage } from '@vueuse/core'
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import Separator from './Separator.vue'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
subdomain: string
serverId?: string
noSeparator?: boolean
}>()
@@ -42,9 +48,9 @@ const copySubdomain = () => {
}
const route = useRoute()
const serverId = computed(() => route.params.id as string)
const serverId = props.serverId || (route.params.id as string)
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
hideSubdomainLabel: false,
})

View File

@@ -5,11 +5,10 @@
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-2"
data-pyro-uptime
>
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<Separator v-if="!noSeparator" />
<div class="flex gap-2">
<TimerIcon class="flex size-5 shrink-0" />
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
<div class="flex gap-1.5">
<time class="truncate text-sm font-medium" :aria-label="verboseUptime">
{{ formattedUptime }}
</time>
</div>
@@ -17,9 +16,10 @@
</template>
<script setup lang="ts">
import { TimerIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Separator from './Separator.vue'
const props = defineProps<{
uptimeSeconds: number
noSeparator?: boolean

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,313 @@
<template>
<div class="grid grid-cols-2 gap-8 items-center justify-center py-10 max-w-[760px]">
<!-- Left column -->
<div class="flex flex-col gap-8 items-start pr-8 shrink-0">
<!-- Heading -->
<div class="flex flex-col gap-2 items-start w-[300px]">
<p class="text-3xl leading-9 font-semibold text-contrast">
{{ formatMessage(messages.modrinthHostingLabel) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.noServersDescription) }}
</p>
</div>
<!-- Feature list -->
<div class="flex flex-col gap-4 items-start w-full">
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center"
>
<PackageOpenIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">
{{ formatMessage(messages.oneClickModInstallsTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.oneClickModInstallsDescription) }}
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
>
<GlobeIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">
{{ formatMessage(messages.simpleSetupTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.simpleSetupDescription) }}
</p>
</div>
</div>
<div class="flex gap-3 items-start">
<div
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
>
<UsersIcon class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="flex flex-col gap-0.5">
<p class="text-base font-semibold text-contrast">
{{ formatMessage(messages.playWithFriendsTitle) }}
</p>
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.playWithFriendsDescription) }}
</p>
</div>
</div>
</div>
<!-- CTA section -->
<div class="flex flex-col gap-6 items-start">
<div class="flex flex-col gap-3 items-start">
<ButtonStyled color="brand">
<button @click="onClickNewServer?.()">
<PlusIcon aria-hidden="true" />
{{ formatMessage(messages.newServerButton) }}
</button>
</ButtonStyled>
<AutoLink
to="https://modrinth.com/hosting"
target="_blank"
class="flex items-center gap-1 hover:brightness-125"
>
{{ formatMessage(messages.learnMoreLink) }}
<RightArrowIcon class="size-5 shrink-0" aria-hidden="true" />
</AutoLink>
</div>
<template v-if="!loggedIn">
<div class="h-px w-full bg-surface-5" />
<div class="flex gap-3 items-center flex-wrap">
<p class="text-base font-normal text-primary">
{{ formatMessage(messages.alreadyHaveServerLabel) }}
</p>
<ButtonStyled>
<button @click="onClickSignIn?.()">
<LogInIcon aria-hidden="true" />
{{ formatMessage(messages.signInButton) }}
</button>
</ButtonStyled>
</div>
</template>
</div>
</div>
<!-- Right column - mod icon grid -->
<div
class="relative flex h-[617px] shrink-0 items-center justify-center overflow-hidden rounded-[40px] pointer-events-none select-none [mask-image:linear-gradient(to_bottom,black_0%,black_35%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,black_0%,black_35%,transparent_100%)]"
>
<div class="rotate-[15deg]">
<div class="flex flex-col gap-4">
<div
v-for="row in GRID_ROWS"
:key="row"
class="flex gap-4 items-center shrink-0"
:class="animated ? (row % 2 === 1 ? 'drift-left' : 'drift-right relative left-14') : ''"
>
<div class="hidden drift-right drift-left"></div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
<div
v-for="col in GRID_COLS"
:key="col"
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
>
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {
GlobeIcon,
LogInIcon,
PackageOpenIcon,
PlusIcon,
RightArrowIcon,
UsersIcon,
} from '@modrinth/assets'
import { AutoLink } from '@modrinth/ui'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import imgAircraft from './grid-images/aircraft.png'
import imgAlexs from "./grid-images/alex's.png"
import imgArtifacts from './grid-images/artifacts.png'
import imgBiomes from './grid-images/biomes.png'
import imgCatac from './grid-images/catac.png'
import imgCobble from './grid-images/cobble.png'
import imgComforts from './grid-images/comforts.png'
import imgCreate from './grid-images/create.png'
import imgCreate1 from './grid-images/create1.png'
import imgCreate2 from './grid-images/create2.png'
import imgCreate3 from './grid-images/create3.png'
import imgCreeper from './grid-images/creeper.png'
import imgFriends from './grid-images/friends.png'
import imgGeo from './grid-images/geo.png'
import imgNaturalist from './grid-images/naturalist.png'
import imgSeasons from './grid-images/seasons.png'
import imgTravellers from './grid-images/travellers.png'
import imgTree from './grid-images/tree.png'
import imgYum1 from './grid-images/yum1.png'
import imgYum2 from './grid-images/yum2.png'
import imgYum3 from './grid-images/yum3.png'
import imgYung from './grid-images/yung.png'
withDefaults(
defineProps<{
animated?: boolean
onClickNewServer?: () => void
onClickSignIn?: () => void
loggedIn?: boolean
}>(),
{ animated: false },
)
const GRID_ROWS = 6
const GRID_COLS = 5
const { formatMessage } = useVIntl()
const messages = defineMessages({
modrinthHostingLabel: {
id: 'servers.list-empty.modrinth-hosting-label',
defaultMessage: 'Modrinth Hosting',
},
noServersTitle: {
id: 'servers.list-empty.no-servers-title',
defaultMessage: 'No servers yet',
},
noServersDescription: {
id: 'servers.list-empty.no-servers-description',
defaultMessage: 'Install mods, invite friends, and play together all from the Modrinth App.',
},
oneClickModInstallsTitle: {
id: 'servers.list-empty.one-click-mod-installs-title',
defaultMessage: 'One-click mod installs',
},
oneClickModInstallsDescription: {
id: 'servers.list-empty.one-click-mod-installs-description',
defaultMessage: 'Pick your favourite mods and we handle the rest.',
},
simpleSetupTitle: {
id: 'servers.list-empty.simple-setup-title',
defaultMessage: 'Simple setup',
},
simpleSetupDescription: {
id: 'servers.list-empty.simple-setup-description',
defaultMessage: 'Set up your server just like a single player world.',
},
playWithFriendsTitle: {
id: 'servers.list-empty.play-with-friends-title',
defaultMessage: 'Play with friends',
},
playWithFriendsDescription: {
id: 'servers.list-empty.play-with-friends-description',
defaultMessage: 'Invite friends and get them set up right in the Modrinth App.',
},
newServerButton: {
id: 'servers.list-empty.new-server-button',
defaultMessage: 'New server',
},
learnMoreLink: {
id: 'servers.list-empty.learn-more-link',
defaultMessage: 'Learn more about Modrinth Hosting',
},
alreadyHaveServerLabel: {
id: 'servers.list-empty.already-have-server-label',
defaultMessage: 'Already have a server?',
},
signInButton: {
id: 'servers.list-empty.sign-in-button',
defaultMessage: 'Sign in',
},
})
const GRID_IMAGES = [
imgYum1,
imgYum2,
imgYum3,
imgYung,
imgCreeper,
imgFriends,
imgNaturalist,
imgBiomes,
imgCatac,
imgCobble,
imgGeo,
imgCreate,
imgCreate1,
imgCreate2,
imgCreate3,
imgAircraft,
imgArtifacts,
imgComforts,
imgTravellers,
imgAlexs,
imgSeasons,
imgTree,
]
function getGridImage(row: number, col: number): string {
return GRID_IMAGES[(row * GRID_COLS + col) % GRID_IMAGES.length]
}
</script>
<style scoped>
p {
margin: 0;
}
@keyframes drift-right {
from {
transform: translateX(-33%);
}
to {
transform: translateX(33%);
}
}
@keyframes drift-left {
from {
transform: translateX(33%);
}
to {
transform: translateX(-33%);
}
}
.drift-left {
animation: drift-left linear infinite alternate;
animation-duration: 400s;
}
.drift-right {
animation: drift-right linear infinite alternate;
animation-duration: 400s;
}
</style>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 990 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -201,7 +201,7 @@ function getCategoryName(category: Category): string {
<div ref="$languagesList" class="flex flex-col gap-2.5">
<template v-for="[category, categoryLocales] in $displayCategories" :key="category">
<strong class="mt-4 font-bold">
<strong class="mt-4 font-semibold text-contrast">
{{ getCategoryName(category) }}
</strong>
@@ -234,7 +234,7 @@ function getCategoryName(category: Category): string {
<RadioButtonIcon v-else class="size-6" />
<div class="flex flex-1 flex-wrap justify-between gap-x-6">
<div class="font-bold">
<div class="font-medium">
{{ loc.displayName }}
</div>