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>
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
196
packages/ui/src/components/base/CollapsibleAdmonition.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
53
packages/ui/src/components/base/FilterPills.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
287
packages/ui/src/components/billing/ResubscribeModal.vue
Normal 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>
|
||||
169
packages/ui/src/components/billing/ServersGuestPlanModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
packages/ui/src/components/modal/UploadProgressModal.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
68
packages/ui/src/components/servers/SaveBanner.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<FloatingActionBar :shown="props.isVisible">
|
||||
<p class="m-0 font-semibold text-sm md:text-base">You have unsaved changes.</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button :disabled="props.isUpdating" @click="props.reset"><HistoryIcon /> Reset</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled :color="props.restart ? 'standard' : 'brand'">
|
||||
<button :disabled="props.isUpdating" @click="props.save">
|
||||
<SpinnerIcon v-if="props.isUpdating" class="animate-spin" />
|
||||
<SaveIcon v-else />
|
||||
{{ props.isUpdating ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="props.restart" color="brand">
|
||||
<button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
|
||||
<SpinnerIcon v-if="props.isUpdating || isTransitioning" class="animate-spin" />
|
||||
{{ powerButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HistoryIcon, SaveIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
const props = defineProps<{
|
||||
isUpdating: boolean
|
||||
restart?: boolean
|
||||
save: () => void | Promise<void>
|
||||
reset: () => void
|
||||
isVisible: boolean
|
||||
serverId: string
|
||||
}>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const { powerState } = injectModrinthServerContext()
|
||||
|
||||
const isStopped = computed(() => powerState.value === 'stopped' || powerState.value === 'crashed')
|
||||
|
||||
const isTransitioning = computed(
|
||||
() => powerState.value === 'starting' || powerState.value === 'stopping',
|
||||
)
|
||||
|
||||
const powerButtonLabel = computed(() => {
|
||||
if (props.isUpdating) return 'Saving...'
|
||||
if (isTransitioning.value) return isStopped.value ? 'Starting...' : 'Restarting...'
|
||||
return isStopped.value ? 'Save & start' : 'Save & restart'
|
||||
})
|
||||
|
||||
const saveAndPower = async () => {
|
||||
try {
|
||||
await props.save()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await client.archon.servers_v0.power(props.serverId, isStopped.value ? 'Start' : 'Restart')
|
||||
}
|
||||
</script>
|
||||
@@ -1,108 +1,234 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtLink :to="status === 'suspended' ? '' : `/hosting/manage/${props.server_id}`">
|
||||
<div
|
||||
class="transition-all"
|
||||
:class="{
|
||||
pressable: !isDisabled,
|
||||
hoverable: !isDisabled,
|
||||
'cursor-pointer': !isDisabled,
|
||||
}"
|
||||
:role="!isDisabled ? 'link' : undefined"
|
||||
:tabindex="!isDisabled ? 0 : undefined"
|
||||
@click="navigateToServer"
|
||||
@keydown.enter.self="navigateToServer"
|
||||
@keydown.space.prevent.self="navigateToServer"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-surface-4 bg-bg-raised p-4 transition-all duration-150"
|
||||
:class="{
|
||||
'!rounded-b-none border-b-0': hasNotice,
|
||||
'bg-surface-2': isDisabled,
|
||||
}"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<div
|
||||
class="flex flex-row items-center overflow-x-hidden rounded-2xl border-[1px] border-solid border-button-bg bg-bg-raised p-4 transition-transform duration-100"
|
||||
:class="{
|
||||
'!rounded-b-none border-b-0': status === 'suspended' || !!pendingChange,
|
||||
'opacity-75': status === 'suspended',
|
||||
'active:scale-95': status !== 'suspended' && !pendingChange,
|
||||
}"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
v-if="hasIconOverlay"
|
||||
class="flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<ServerIcon v-if="status !== 'suspended'" :image="image" />
|
||||
<ServerIcon :image="image ?? undefined" :disabled="isDisabled" class="!rounded-xl" />
|
||||
<SpinnerIcon
|
||||
v-if="isProvisioning || isUpgrading"
|
||||
class="size-8 animate-spin absolute text-contrast"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
/>
|
||||
<LockIcon v-else class="size-8 absolute" :class="{ 'opacity-50': isDisabled }" />
|
||||
</div>
|
||||
<ServerIcon v-else :image="image ?? undefined" :disabled="isDisabled" />
|
||||
<div class="ml-4 flex flex-col gap-1.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 text-xl font-bold text-contrast" :class="{ 'opacity-50': isDisabled }">
|
||||
{{ name }}
|
||||
</h2>
|
||||
<div
|
||||
v-if="isConfiguring && noticeType !== 'cancelled' && noticeType !== 'setToCancel'"
|
||||
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-brand rounded-full bg-brand-highlight border border-solid border-brand px-2.5 h-[28px]"
|
||||
>
|
||||
<SparklesIcon class="size-5 shrink-0 font-semibold" />
|
||||
{{ formatMessage(messages.newLabel) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="bg-bg-secondary flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
>
|
||||
<LockIcon class="size-12 text-secondary" />
|
||||
</div>
|
||||
<div class="ml-4 flex flex-col gap-2.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 text-xl font-bold text-contrast">{{ name }}</h2>
|
||||
<ChevronRightIcon />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
>
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
alt="Server Icon"
|
||||
/>
|
||||
Using {{ projectData?.title || 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isConfiguring"
|
||||
class="flex min-w-0 items-center gap-2 truncate text-sm font-semibold text-brand"
|
||||
>
|
||||
<SparklesIcon class="size-5 shrink-0" /> New server
|
||||
</div>
|
||||
<ServerInfoLabels
|
||||
v-else
|
||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:linked="false"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
:alt="formatMessage(messages.serverIconAlt)"
|
||||
/>
|
||||
{{ formatMessage(messages.usingProjectLabel, { projectTitle: projectData?.title }) }}
|
||||
</div>
|
||||
|
||||
<ServerInfoLabels
|
||||
:server-data="
|
||||
isConfiguring
|
||||
? { net }
|
||||
: {
|
||||
game,
|
||||
mc_version,
|
||||
loader,
|
||||
loader_version,
|
||||
net,
|
||||
online,
|
||||
players: playerCount
|
||||
? { current: playerCount.current, max: playerCount.max }
|
||||
: undefined,
|
||||
}
|
||||
"
|
||||
:server-id="server_id"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:show-player-count="showPlayerCount"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
:linked="false"
|
||||
class="flex w-full flex-row flex-wrap items-center gap-2 text-primary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
<div
|
||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
||||
class="relative flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<LoaderCircleIcon class="size-5 animate-spin" />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been cancelled. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
|
||||
<div v-if="noticeType" class="server-listing-notice">
|
||||
<div v-if="noticeType === 'provisioning'" class="flex gap-2">
|
||||
{{ formatMessage(messages.provisioningNotice) }}
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended:
|
||||
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
|
||||
for more information.
|
||||
<div v-else-if="noticeType === 'upgrading'" class="flex gap-2">
|
||||
{{ formatMessage(messages.upgradingNotice) }}
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
<div v-else-if="noticeType === 'cancelled' || noticeType === 'paymentfailed'">
|
||||
<IntlFormatted
|
||||
v-if="noticeType === 'paymentfailed' && cancellationDate"
|
||||
:message-id="messages.subscriptionCancelledPaymentFailedOnDate"
|
||||
:values="{ formattedDate: formatDate(cancellationDate) }"
|
||||
>
|
||||
<template #date="{ children }">
|
||||
<span class="font-medium text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
|
||||
<span v-else-if="noticeType === 'paymentfailed'">
|
||||
{{ formatMessage(messages.subscriptionCancelledPaymentFailed) }}
|
||||
</span>
|
||||
|
||||
<IntlFormatted
|
||||
v-else-if="cancellationDate"
|
||||
:message-id="messages.subscriptionCancelledOnDate"
|
||||
:values="{ formattedDate: formatDate(cancellationDate) }"
|
||||
>
|
||||
<template #date="{ children }">
|
||||
<span class="font-medium text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
|
||||
<span v-else>
|
||||
{{ formatMessage(messages.subscriptionCancelled) }}
|
||||
</span>
|
||||
|
||||
{{ ' ' }}
|
||||
<IntlFormatted
|
||||
v-if="!isFilesExpired"
|
||||
:message-id="messages.filesKeptForDownload"
|
||||
:values="{ daysRemaining: filesRemainingDays }"
|
||||
>
|
||||
<template #days-remaining="{ children }">
|
||||
<span class="font-medium text-red">
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
|
||||
<div v-else-if="noticeType === 'setToCancel'">
|
||||
<IntlFormatted
|
||||
v-if="cancellationDate"
|
||||
:message-id="messages.subscriptionSetToCancelOnDate"
|
||||
:values="{ formattedDate: formatDate(cancellationDate) }"
|
||||
>
|
||||
<template #date="{ children }">
|
||||
<span class="font-medium text-contrast">
|
||||
<component :is="() => children" />
|
||||
</span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
|
||||
<span v-else>{{ formatMessage(messages.subscriptionSetToCancel) }}</span>
|
||||
|
||||
<template v-if="!isFilesExpired">
|
||||
{{ ' ' }}
|
||||
{{ formatMessage(messages.filesPreservedAfterCancellation) }}
|
||||
</template>
|
||||
</div>
|
||||
<div v-else-if="noticeType === 'moderated'">
|
||||
{{ formatMessage(messages.moderatedNotice) }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ formatMessage(messages.suspendedNotice) }}
|
||||
</div>
|
||||
|
||||
<div v-if="noticeButtons" class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-if="noticeButtons.downloadBackup && onDownloadBackup && isBackupDownloadEnabled"
|
||||
type="outlined"
|
||||
circular
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.downloadLatestBackupTooltip)"
|
||||
class="!border-surface-4"
|
||||
data-server-listing-button
|
||||
@click="onDownloadBackup"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="noticeButtons.copyId" type="outlined">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.copyCodeToClipboardTooltip)"
|
||||
class="!border-surface-4"
|
||||
data-server-listing-button
|
||||
@click="copyToClipboard(server_id)"
|
||||
>
|
||||
<template v-if="copied">
|
||||
{{ formatMessage(messages.copiedLabel) }} <CheckIcon class="text-green" />
|
||||
</template>
|
||||
<template v-else> {{ formatMessage(messages.copyIdLabel) }} <CopyIcon /> </template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="noticeButtons.support">
|
||||
<a href="https://support.modrinth.com/en/" target="_blank" data-server-listing-button
|
||||
><MessagesSquareIcon /> {{ formatMessage(messages.supportLabel) }}
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="noticeButtons.manageBilling" color="brand">
|
||||
<AutoLink :to="`/settings/billing#server-${server_id}`" data-server-listing-button>
|
||||
<CardIcon /> {{ formatMessage(messages.manageBillingLabel) }}
|
||||
</AutoLink>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="noticeButtons.resubscribe && onResubscribe" color="brand">
|
||||
<button data-server-listing-button @click="onResubscribe">
|
||||
<RotateCounterClockwiseIcon /> {{ formatMessage(messages.resubscribeLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-if="pendingChange && status !== 'suspended'"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-orange bg-bg-orange p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
|
||||
<div v-if="pendingChange && status !== 'suspended'" class="server-listing-notice">
|
||||
<div>
|
||||
Your server will {{ pendingChange.verb.toLowerCase() }} to the "{{
|
||||
pendingChange.planSize
|
||||
}}" plan on {{ formatDate(pendingChange.date) }}.
|
||||
<IntlFormatted
|
||||
:message-id="messages.pendingChangeNotice"
|
||||
:values="{
|
||||
verb: pendingChange.verb.toLowerCase(),
|
||||
planSize: pendingChange.planSize,
|
||||
formattedDate: formatDate(pendingChange.date),
|
||||
}"
|
||||
>
|
||||
<template #date="{ children }">
|
||||
<span class="font-medium text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<ServersSpecs
|
||||
class="!font-normal !text-contrast"
|
||||
class="!font-normal !text-primary"
|
||||
:ram="Math.round((pendingChange.ramGb ?? 0) * 1024)"
|
||||
:storage="Math.round((pendingChange.storageGb ?? 0) * 1024)"
|
||||
:cpus="pendingChange.cpuBurst"
|
||||
@@ -115,24 +241,134 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LoaderCircleIcon,
|
||||
DownloadIcon,
|
||||
LockIcon,
|
||||
MessagesSquareIcon,
|
||||
SparklesIcon,
|
||||
TriangleAlertIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { AutoLink, ButtonStyled } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
CardIcon,
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
} from '../../../../assets/generated-icons'
|
||||
import { useFormatDateTime } from '../../composables'
|
||||
import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { injectModrinthClient } from '../../providers/api-client'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import CopyCode from '../base/CopyCode.vue'
|
||||
import IntlFormatted from '../base/IntlFormatted.vue'
|
||||
import ServersSpecs from '../billing/ServersSpecs.vue'
|
||||
import ServerIcon from './icons/ServerIcon.vue'
|
||||
import ServerInfoLabels from './labels/ServerInfoLabels.vue'
|
||||
|
||||
const formatDate = useFormatDateTime({ dateStyle: 'long' })
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
newLabel: {
|
||||
id: 'servers.listing.new-label',
|
||||
defaultMessage: 'New',
|
||||
},
|
||||
serverIconAlt: {
|
||||
id: 'servers.listing.server-icon-alt',
|
||||
defaultMessage: 'Server icon',
|
||||
},
|
||||
usingProjectLabel: {
|
||||
id: 'servers.listing.using-project-label',
|
||||
defaultMessage: 'Using {projectTitle}',
|
||||
},
|
||||
provisioningNotice: {
|
||||
id: 'servers.listing.notice.provisioning',
|
||||
defaultMessage: 'Please wait while we set up your server. This can take up to 10 minutes.',
|
||||
},
|
||||
upgradingNotice: {
|
||||
id: 'servers.listing.notice.upgrading',
|
||||
defaultMessage:
|
||||
"Your server's hardware is currently being upgraded and will be back online shortly.",
|
||||
},
|
||||
subscriptionCancelled: {
|
||||
id: 'servers.listing.notice.subscription-cancelled',
|
||||
defaultMessage: 'Your subscription was cancelled.',
|
||||
},
|
||||
subscriptionCancelledOnDate: {
|
||||
id: 'servers.listing.notice.subscription-cancelled-on-date',
|
||||
defaultMessage: 'Your subscription was cancelled on <date>{formattedDate}</date>. ',
|
||||
},
|
||||
subscriptionCancelledPaymentFailed: {
|
||||
id: 'servers.listing.notice.subscription-cancelled-payment-failed',
|
||||
defaultMessage: 'Your subscription was cancelled due to payment failure.',
|
||||
},
|
||||
subscriptionCancelledPaymentFailedOnDate: {
|
||||
id: 'servers.listing.notice.subscription-cancelled-payment-failed-on-date',
|
||||
defaultMessage:
|
||||
'Your subscription was cancelled on <date>{formattedDate}</date> due to payment failure. ',
|
||||
},
|
||||
filesKeptForDownload: {
|
||||
id: 'servers.listing.notice.files-kept-for-download',
|
||||
defaultMessage:
|
||||
'Your files will be kept for <days-remaining>{daysRemaining} more {daysRemaining, plural, one {day} other {days} }</days-remaining>. Contact support to download the files before they are deleted. ',
|
||||
},
|
||||
subscriptionSetToCancel: {
|
||||
id: 'servers.listing.notice.subscription-set-to-cancel',
|
||||
defaultMessage: 'Your subscription is set to cancel.',
|
||||
},
|
||||
subscriptionSetToCancelOnDate: {
|
||||
id: 'servers.listing.notice.subscription-set-to-cancel-on-date',
|
||||
defaultMessage: 'Your subscription is set to cancel on <date>{formattedDate}</date>. ',
|
||||
},
|
||||
filesPreservedAfterCancellation: {
|
||||
id: 'servers.listing.notice.files-preserved-after-cancellation',
|
||||
defaultMessage: 'Your files will be preserved for 30 days after cancellation.',
|
||||
},
|
||||
moderatedNotice: {
|
||||
id: 'servers.listing.notice.moderated',
|
||||
defaultMessage: 'Your server has been suspended by moderation action. ',
|
||||
},
|
||||
suspendedNotice: {
|
||||
id: 'servers.listing.notice.suspended',
|
||||
defaultMessage:
|
||||
'Your server has been suspended. Please contact Modrinth Support for more information.',
|
||||
},
|
||||
downloadLatestBackupTooltip: {
|
||||
id: 'servers.listing.download-latest-backup-tooltip',
|
||||
defaultMessage: 'Download latest backup',
|
||||
},
|
||||
copyCodeToClipboardTooltip: {
|
||||
id: 'servers.listing.copy-code-tooltip',
|
||||
defaultMessage: 'Copy code to clipboard',
|
||||
},
|
||||
copiedLabel: {
|
||||
id: 'servers.listing.copied-label',
|
||||
defaultMessage: 'Copied',
|
||||
},
|
||||
copyIdLabel: {
|
||||
id: 'servers.listing.copy-id-label',
|
||||
defaultMessage: 'Copy ID',
|
||||
},
|
||||
supportLabel: {
|
||||
id: 'servers.listing.support-label',
|
||||
defaultMessage: 'Support',
|
||||
},
|
||||
manageBillingLabel: {
|
||||
id: 'servers.listing.manage-billing-label',
|
||||
defaultMessage: 'Manage billing',
|
||||
},
|
||||
resubscribeLabel: {
|
||||
id: 'servers.listing.resubscribe-label',
|
||||
defaultMessage: 'Resubscribe',
|
||||
},
|
||||
pendingChangeNotice: {
|
||||
id: 'servers.listing.notice.pending-change',
|
||||
defaultMessage:
|
||||
'Your server will {verb} to the {planSize} Plan on <date>{formattedDate}</date>. ',
|
||||
},
|
||||
})
|
||||
|
||||
export type PendingChange = {
|
||||
planSize: string
|
||||
@@ -159,14 +395,99 @@ type ServerListingProps = {
|
||||
upstream?: Archon.Servers.v0.Upstream | null
|
||||
flows?: Archon.Servers.v0.Flows
|
||||
pendingChange?: PendingChange
|
||||
online?: boolean
|
||||
playerCount?: {
|
||||
current?: number
|
||||
max?: number
|
||||
}
|
||||
isProvisioning?: boolean
|
||||
cancellationDate?: string | Date | null
|
||||
onResubscribe?: (() => void) | null
|
||||
onDownloadBackup?: (() => void) | null
|
||||
}
|
||||
|
||||
const props = defineProps<ServerListingProps>()
|
||||
const router = useRouter()
|
||||
|
||||
const { archon, kyros, labrinth } = injectModrinthClient()
|
||||
|
||||
const showGameLabel = computed(() => !!props.game)
|
||||
const showLoaderLabel = computed(() => !!props.loader)
|
||||
const isBackupDownloadEnabled = false
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
const isUpgrading = computed(
|
||||
() => props.status === 'suspended' && props.suspension_reason === 'upgrading',
|
||||
)
|
||||
const isDisabled = computed(() => props.status === 'suspended' || props.isProvisioning)
|
||||
const isSetToCancel = computed(() => !!props.cancellationDate && props.status !== 'suspended')
|
||||
const filesRemainingDays = computed(() => {
|
||||
if (!props.cancellationDate) return 0
|
||||
const cancellation = new Date(props.cancellationDate)
|
||||
const expiresAt = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000) // expires 30 days after cancellation
|
||||
const remaining = Math.ceil((expiresAt.getTime() - Date.now()) / (24 * 60 * 60 * 1000))
|
||||
return Math.max(0, remaining)
|
||||
})
|
||||
const isFilesExpired = computed(() => filesRemainingDays.value <= 0)
|
||||
|
||||
const hasIconOverlay = computed(
|
||||
() => props.isProvisioning || isUpgrading.value || props.status === 'suspended',
|
||||
)
|
||||
|
||||
type NoticeType =
|
||||
| 'provisioning'
|
||||
| 'upgrading'
|
||||
| 'cancelled'
|
||||
| 'paymentfailed'
|
||||
| 'moderated'
|
||||
| 'suspended'
|
||||
| 'setToCancel'
|
||||
|
||||
const noticeType = computed<NoticeType | null>(() => {
|
||||
if (props.isProvisioning) return 'provisioning'
|
||||
if (props.status === 'suspended') {
|
||||
switch (props.suspension_reason) {
|
||||
case 'upgrading':
|
||||
return 'upgrading'
|
||||
case 'cancelled':
|
||||
return 'cancelled'
|
||||
case 'paymentfailed':
|
||||
return 'paymentfailed'
|
||||
case 'moderated':
|
||||
return 'moderated'
|
||||
default:
|
||||
return 'suspended'
|
||||
}
|
||||
}
|
||||
if (isSetToCancel.value) return 'setToCancel'
|
||||
return null
|
||||
})
|
||||
|
||||
type NoticeButtons = {
|
||||
downloadBackup?: boolean
|
||||
copyId?: boolean
|
||||
support?: boolean
|
||||
manageBilling?: boolean
|
||||
resubscribe?: boolean
|
||||
}
|
||||
|
||||
const noticeButtons = computed<NoticeButtons | null>(() => {
|
||||
switch (noticeType.value) {
|
||||
case 'cancelled':
|
||||
case 'setToCancel':
|
||||
return { downloadBackup: true, copyId: true, support: true, resubscribe: true }
|
||||
case 'paymentfailed':
|
||||
return { downloadBackup: true, copyId: true, support: true, manageBilling: true }
|
||||
case 'moderated':
|
||||
case 'suspended':
|
||||
return { downloadBackup: true, copyId: true, support: true }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})
|
||||
|
||||
const hasNotice = computed(() => !!noticeType.value || !!props.pendingChange)
|
||||
|
||||
const showGameLabel = computed(() => !!props.game && !isConfiguring.value)
|
||||
const showLoaderLabel = computed(() => !!props.loader && !isConfiguring.value)
|
||||
const showPlayerCount = computed(() => !!props.playerCount && !isConfiguring.value)
|
||||
|
||||
const { data: projectData } = useQuery({
|
||||
queryKey: ['project', props.upstream?.project_id] as const,
|
||||
@@ -207,17 +528,30 @@ const { data: image } = useQuery({
|
||||
if (!props.server_id || props.status !== 'available') return null
|
||||
|
||||
try {
|
||||
const auth = await archon.servers_v0.getFilesystemAuth(props.server_id)
|
||||
const fsAuth = await archon.servers_v0.getFilesystemAuth(props.server_id)
|
||||
|
||||
try {
|
||||
const blob = await kyros.files_v0.downloadFile(
|
||||
auth.url,
|
||||
auth.token,
|
||||
'/server-icon-original.png',
|
||||
)
|
||||
const blob = await kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
|
||||
return await processImageBlob(blob, 64)
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number })?.statusCode
|
||||
if (statusCode != null && statusCode !== 404) {
|
||||
throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const originalBlob = await kyros.files_v0.downloadFileWithAuth(
|
||||
fsAuth,
|
||||
'/server-icon-original.png',
|
||||
)
|
||||
return await processImageBlob(originalBlob, 64)
|
||||
} catch (originalError) {
|
||||
const originalStatusCode = (originalError as { statusCode?: number })?.statusCode
|
||||
if (originalStatusCode != null && originalStatusCode !== 404) {
|
||||
throw originalError
|
||||
}
|
||||
}
|
||||
|
||||
return await processImageBlob(blob, 512)
|
||||
} catch {
|
||||
const projectIcon = iconUrl.value
|
||||
if (projectIcon) {
|
||||
const response = await fetch(projectIcon)
|
||||
@@ -227,30 +561,62 @@ const { data: image } = useQuery({
|
||||
const scaledBlob = await dataURLToBlob(scaledDataUrl)
|
||||
const scaledFile = new File([scaledBlob], 'server-icon.png', { type: 'image/png' })
|
||||
|
||||
await kyros.files_v0.uploadFile(auth.url, auth.token, '/server-icon.png', scaledFile)
|
||||
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
|
||||
|
||||
const originalFile = new File([blob], 'server-icon-original.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
await kyros.files_v0.uploadFile(
|
||||
auth.url,
|
||||
auth.token,
|
||||
'/server-icon-original.png',
|
||||
originalFile,
|
||||
)
|
||||
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', originalFile)
|
||||
.promise
|
||||
|
||||
return scaledDataUrl
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.debug('Icon processing failed:', error)
|
||||
return null
|
||||
}
|
||||
|
||||
return null
|
||||
},
|
||||
enabled: computed(() => !!props.server_id && props.status === 'available'),
|
||||
})
|
||||
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
const copied = ref(false)
|
||||
|
||||
function navigateToServer(event: MouseEvent | KeyboardEvent) {
|
||||
if (isDisabled.value) return
|
||||
|
||||
const target = event.target
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.closest('[data-subdomain-label], [data-server-listing-button]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/hosting/manage/${props.server_id}`)
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-listing-notice {
|
||||
@apply relative flex w-full rounded-b-2xl border-[1px] border-solid p-4 flex-col gap-4 border-surface-4 bg-bg-raised text-primary;
|
||||
}
|
||||
|
||||
.hoverable:hover:not(:has([data-subdomain-label]:hover, [data-server-listing-button]:hover)) {
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.pressable:active:not(:has([data-subdomain-label]:active, [data-server-listing-button]:active)) {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
</style>
|
||||
|
||||
232
packages/ui/src/components/servers/ServerSettingsModal.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { TabbedModalTab } from '#ui/components'
|
||||
import { TabbedModal } from '#ui/components'
|
||||
import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
ServerSettingsAdvancedPage,
|
||||
ServerSettingsGeneralPage,
|
||||
ServerSettingsInstallationPage,
|
||||
ServerSettingsNetworkPage,
|
||||
ServerSettingsPropertiesPage,
|
||||
serverSettingsTabDefinitions,
|
||||
type ServerSettingsTabId,
|
||||
} from '#ui/layouts/shared/server-settings'
|
||||
import { provideServerSettings } from '#ui/layouts/shared/server-settings/providers/server-settings'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
type ShowOptions = {
|
||||
serverId: string
|
||||
tabIndex?: number
|
||||
tabId?: ServerSettingsTabId
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
resolveViewer: () => Promise<{ userId: string | null; userRole: string | null }>
|
||||
browseModpacks?: (args: {
|
||||
serverId: string
|
||||
worldId: string | null
|
||||
from: 'reset-server'
|
||||
}) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
failedToLoadServer: {
|
||||
id: 'app.server-settings.failed-to-load-server',
|
||||
defaultMessage: 'Failed to load server settings',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
const { serverId: currentServerId, worldId, server } = injectModrinthServerContext()
|
||||
|
||||
const currentUserId = ref<string | null>(null)
|
||||
const currentUserRole = ref<string | null>(null)
|
||||
|
||||
const isApp = ref(true)
|
||||
|
||||
const serverSettingsTabComponentMap = {
|
||||
general: ServerSettingsGeneralPage,
|
||||
installation: ServerSettingsInstallationPage,
|
||||
network: ServerSettingsNetworkPage,
|
||||
properties: ServerSettingsPropertiesPage,
|
||||
advanced: ServerSettingsAdvancedPage,
|
||||
} as const
|
||||
|
||||
provideServerSettings({
|
||||
isApp,
|
||||
currentUserId,
|
||||
currentUserRole,
|
||||
browseModpacks: props.browseModpacks ?? (() => {}),
|
||||
closeModal: () => hide(),
|
||||
})
|
||||
|
||||
const ownerId = computed(() => server.value?.owner_id ?? 'Ghost')
|
||||
const isOwner = computed(() => currentUserId.value != null && currentUserId.value === ownerId.value)
|
||||
const isAdmin = computed(() => currentUserRole.value === 'admin')
|
||||
|
||||
const tabs = computed<TabbedModalTab[]>(() =>
|
||||
serverSettingsTabDefinitions.map((tab) => {
|
||||
const ctx = {
|
||||
serverId: currentServerId,
|
||||
ownerId: ownerId.value,
|
||||
serverStatus: server.value?.status,
|
||||
isOwner: isOwner.value,
|
||||
isAdmin: isAdmin.value,
|
||||
}
|
||||
const name = defineMessage({
|
||||
id: `server.settings.tabs.${tab.id}`,
|
||||
defaultMessage: tab.label,
|
||||
})
|
||||
const shown = tab.shown ? tab.shown(ctx) : true
|
||||
|
||||
if (tab.external) {
|
||||
return {
|
||||
name,
|
||||
icon: tab.icon,
|
||||
href: tab.href ? `https://modrinth.com${tab.href(ctx)}` : undefined,
|
||||
shown,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
icon: tab.icon,
|
||||
content: serverSettingsTabComponentMap[tab.id as keyof typeof serverSettingsTabComponentMap],
|
||||
shown,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
async function fetchViewer() {
|
||||
currentUserId.value = null
|
||||
currentUserRole.value = null
|
||||
|
||||
const result = await props.resolveViewer()
|
||||
currentUserId.value = result.userId
|
||||
currentUserRole.value = result.userRole
|
||||
}
|
||||
|
||||
async function show({ serverId, tabIndex, tabId }: ShowOptions) {
|
||||
try {
|
||||
const targetServerId = currentServerId
|
||||
if (serverId !== targetServerId) {
|
||||
console.warn(
|
||||
`[ServerSettingsModal] Ignoring mismatched serverId "${serverId}" in favor of context "${targetServerId}"`,
|
||||
)
|
||||
}
|
||||
|
||||
const cachedServer = queryClient.getQueryData<Archon.Servers.v0.Server>([
|
||||
'servers',
|
||||
'detail',
|
||||
targetServerId,
|
||||
])
|
||||
const cachedFull = queryClient.getQueryData<Archon.Servers.v1.ServerFull>([
|
||||
'servers',
|
||||
'v1',
|
||||
'detail',
|
||||
targetServerId,
|
||||
])
|
||||
|
||||
modal.value?.show()
|
||||
const visibleTabs = tabs.value.filter((tab) => tab.shown !== false)
|
||||
let requestedTab = tabIndex ?? 0
|
||||
if (tabId) {
|
||||
const defIndex = serverSettingsTabDefinitions.findIndex((d) => d.id === tabId)
|
||||
if (defIndex >= 0) {
|
||||
const visibleIndex = visibleTabs.findIndex(
|
||||
(_, i) => tabs.value.indexOf(visibleTabs[i]) === defIndex,
|
||||
)
|
||||
if (visibleIndex >= 0) requestedTab = visibleIndex
|
||||
}
|
||||
}
|
||||
const clampedTab = Math.min(Math.max(requestedTab, 0), Math.max(visibleTabs.length - 1, 0))
|
||||
nextTick(() => modal.value?.setTab(clampedTab))
|
||||
|
||||
const fetchPromises: Promise<unknown>[] = [fetchViewer()]
|
||||
|
||||
if (!cachedServer) {
|
||||
fetchPromises.push(
|
||||
queryClient.fetchQuery({
|
||||
queryKey: ['servers', 'detail', targetServerId],
|
||||
queryFn: () => client.archon.servers_v0.get(targetServerId),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (!cachedFull) {
|
||||
fetchPromises.push(
|
||||
queryClient.fetchQuery({
|
||||
queryKey: ['servers', 'v1', 'detail', targetServerId],
|
||||
queryFn: () => client.archon.servers_v1.get(targetServerId),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(fetchPromises)
|
||||
|
||||
if (worldId.value) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['servers', 'properties', 'v1', targetServerId, worldId.value],
|
||||
queryFn: () => client.archon.properties_v1.getProperties(targetServerId, worldId.value!),
|
||||
})
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['content', 'list', 'v1', targetServerId],
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(targetServerId, worldId.value!, {
|
||||
from_modpack: false,
|
||||
}),
|
||||
})
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['servers', 'startup', 'v1', targetServerId, worldId.value],
|
||||
queryFn: () => client.archon.options_v1.getStartup(targetServerId, worldId.value!),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToLoadServer),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabbedModal
|
||||
ref="modal"
|
||||
:tabs="tabs"
|
||||
:max-width="'min(980px, calc(95vw - 2rem))'"
|
||||
:width="'min(980px, calc(95vw - 2rem))'"
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
{{ server.name || 'Server' }} <ChevronRightIcon />
|
||||
<span class="font-extrabold text-contrast">{{
|
||||
formatMessage(commonMessages.settingsLabel)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</TabbedModal>
|
||||
</template>
|
||||
@@ -16,21 +16,12 @@
|
||||
@browse-modpacks="$emit('browse-modpacks')"
|
||||
/>
|
||||
|
||||
<NewModal
|
||||
ref="uploadModal"
|
||||
:header="formatMessage(messages.uploadingModpackHeader)"
|
||||
:closable="false"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" />
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.uploadWarningText) }}</p>
|
||||
</div>
|
||||
</NewModal>
|
||||
<UploadProgressModal ref="uploadProgressModal" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, ModrinthApiError } from '@modrinth/api-client'
|
||||
import { computed, nextTick, ref, useTemplateRef } from 'vue'
|
||||
import { computed, useTemplateRef } from 'vue'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
|
||||
@@ -38,22 +29,13 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
|
||||
import { injectModrinthClient } from '../../providers/api-client'
|
||||
import { injectModrinthServerContext } from '../../providers/server-context'
|
||||
import { injectNotificationManager } from '../../providers/web-notifications'
|
||||
import { AppearingProgressBar } from '../base'
|
||||
import type { CreationFlowContextValue } from '../flows/creation-flow-modal/creation-flow-context'
|
||||
import CreationFlowModal from '../flows/creation-flow-modal/index.vue'
|
||||
import { NewModal } from '../modal'
|
||||
import { UploadProgressModal } from '../modal'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadingModpackHeader: {
|
||||
id: 'servers.setup.uploading-modpack.header',
|
||||
defaultMessage: 'Uploading modpack',
|
||||
},
|
||||
uploadWarningText: {
|
||||
id: 'servers.setup.upload-warning',
|
||||
defaultMessage: "Please don't close this page while uploading.",
|
||||
},
|
||||
rateLimitTitle: {
|
||||
id: 'servers.setup.rate-limit.title',
|
||||
defaultMessage: 'Cannot reinstall server',
|
||||
@@ -117,10 +99,8 @@ const initialLoader = computed(() => {
|
||||
const initialGameVersion = computed(() => serverContext.server.value.mc_version ?? undefined)
|
||||
|
||||
const creationFlowRef = useTemplateRef<InstanceType<typeof CreationFlowModal>>('creationFlowRef')
|
||||
const uploadModal = useTemplateRef<InstanceType<typeof NewModal>>('uploadModal')
|
||||
|
||||
const uploadedBytes = ref(0)
|
||||
const totalBytes = ref(0)
|
||||
const uploadProgressModal =
|
||||
useTemplateRef<InstanceType<typeof UploadProgressModal>>('uploadProgressModal')
|
||||
|
||||
async function onFlowComplete(ctx: CreationFlowContextValue) {
|
||||
debug('onFlowComplete:', {
|
||||
@@ -210,32 +190,15 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
|
||||
}
|
||||
|
||||
async function handleMrpackUpload(file: File, properties: Archon.Content.v1.PropertiesFields) {
|
||||
uploadedBytes.value = 0
|
||||
totalBytes.value = file.size
|
||||
|
||||
creationFlowRef.value?.hide()
|
||||
await nextTick()
|
||||
uploadModal.value?.show()
|
||||
|
||||
try {
|
||||
const handle = client.kyros.content_v1.uploadModpackFile(
|
||||
serverContext.worldId.value!,
|
||||
file,
|
||||
properties,
|
||||
{
|
||||
softOverride: false,
|
||||
onProgress: ({ loaded, total }) => {
|
||||
uploadedBytes.value = loaded
|
||||
totalBytes.value = total
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
await handle.promise
|
||||
emitReinstall()
|
||||
} finally {
|
||||
uploadModal.value?.hide()
|
||||
}
|
||||
const handle = client.kyros.content_v1.uploadModpackFile(
|
||||
serverContext.worldId.value!,
|
||||
file,
|
||||
properties,
|
||||
{ softOverride: false },
|
||||
)
|
||||
await uploadProgressModal.value!.track(handle)
|
||||
emitReinstall()
|
||||
}
|
||||
|
||||
function emitReinstall(args?: { loader: string; lVersion: string; mVersion: string | null }) {
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">Icon</span>
|
||||
<div class="group relative w-fit">
|
||||
<OverflowMenu
|
||||
v-tooltip="'Edit icon'"
|
||||
class="m-0 cursor-pointer appearance-none border-none bg-transparent p-0 transition-transform group-active:scale-95"
|
||||
:disabled="isIconActionLoading"
|
||||
:options="[
|
||||
{
|
||||
id: 'upload',
|
||||
action: () => triggerFileInput(),
|
||||
},
|
||||
{
|
||||
id: 'sync',
|
||||
action: () => resetIcon(),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<ServerIcon
|
||||
class="size-28 transition-[filter] group-hover:brightness-[0.50]"
|
||||
:class="isIconActionLoading ? 'brightness-[0.50]' : ''"
|
||||
:image="displayIcon"
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 h-full w-full flex items-center justify-center"
|
||||
:class="isIconActionLoading ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="isIconActionLoading"
|
||||
aria-hidden="true"
|
||||
class="h-10 w-10 animate-spin text-primary"
|
||||
/>
|
||||
<EditIcon v-else aria-hidden="true" class="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<template #upload> <UploadIcon /> Upload icon </template>
|
||||
<template #sync> <TransferIcon /> Sync icon </template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, SpinnerIcon, TransferIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { OverflowMenu, ServerIcon } from '#ui/components'
|
||||
import { useServerImage } from '#ui/composables'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId, server } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
const isUploadingIcon = ref(false)
|
||||
const isSyncingIcon = ref(false)
|
||||
const isIconActionLoading = computed(() => isUploadingIcon.value || isSyncingIcon.value)
|
||||
|
||||
const {
|
||||
image: displayIcon,
|
||||
refetch: refetchRemoteIcon,
|
||||
setImage,
|
||||
clearImage,
|
||||
} = useServerImage(
|
||||
serverId,
|
||||
computed(() => server.value?.upstream ?? null),
|
||||
{
|
||||
includeProjectFallback: false,
|
||||
},
|
||||
)
|
||||
|
||||
function getStatusCode(error: unknown): number | undefined {
|
||||
const err = error as { statusCode?: number; response?: { status?: number } }
|
||||
return err.statusCode ?? err.response?.status
|
||||
}
|
||||
|
||||
function isNotFound(error: unknown): boolean {
|
||||
return getStatusCode(error) === 404
|
||||
}
|
||||
|
||||
const uploadFile = async (e: Event) => {
|
||||
if (isIconActionLoading.value) return
|
||||
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'No file selected',
|
||||
text: 'Please select a file to upload.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
isUploadingIcon.value = true
|
||||
|
||||
try {
|
||||
const scaledFile = await new Promise<File>((resolve, reject) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = 64
|
||||
canvas.height = 64
|
||||
ctx?.drawImage(img, 0, 0, 64, 64)
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) {
|
||||
resolve(new File([blob], 'server-icon.png', { type: 'image/png' }))
|
||||
} else {
|
||||
reject(new Error('Canvas toBlob failed'))
|
||||
}
|
||||
}, 'image/png')
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.onerror = reject
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
|
||||
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
|
||||
} catch (scaledUploadError) {
|
||||
// Node FS may reject create when file already exists. Delete and retry once.
|
||||
try {
|
||||
await client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false)
|
||||
} catch (deleteError) {
|
||||
if (!isNotFound(deleteError)) {
|
||||
throw scaledUploadError
|
||||
}
|
||||
}
|
||||
|
||||
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
|
||||
}
|
||||
|
||||
// Keep original file in sync when possible, but don't block icon updates on failures here.
|
||||
try {
|
||||
await client.kyros.files_v0.deleteFileOrFolderWithAuth(
|
||||
fsAuth,
|
||||
'/server-icon-original.png',
|
||||
false,
|
||||
)
|
||||
} catch (deleteOriginalError) {
|
||||
if (!isNotFound(deleteOriginalError)) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', file)
|
||||
.promise
|
||||
} catch (originalUploadError) {
|
||||
if (!isNotFound(originalUploadError)) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
const img = new Image()
|
||||
await new Promise<void>((resolve) => {
|
||||
img.onload = () => {
|
||||
canvas.width = 512
|
||||
canvas.height = 512
|
||||
ctx?.drawImage(img, 0, 0, 512, 512)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
setImage(dataURL)
|
||||
queryClient.setQueriesData({ queryKey: ['servers', 'detail', serverId, 'icon'] }, dataURL)
|
||||
resolve()
|
||||
URL.revokeObjectURL(img.src)
|
||||
}
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
await refetchRemoteIcon()
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server icon updated',
|
||||
text: 'Your server icon was successfully changed.',
|
||||
})
|
||||
} catch {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Upload failed',
|
||||
text: 'Failed to upload server icon.',
|
||||
})
|
||||
} finally {
|
||||
isUploadingIcon.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetIcon = async () => {
|
||||
if (isIconActionLoading.value) return
|
||||
isSyncingIcon.value = true
|
||||
|
||||
try {
|
||||
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
||||
const deleteResults = await Promise.allSettled([
|
||||
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false),
|
||||
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon-original.png', false),
|
||||
])
|
||||
|
||||
for (const result of deleteResults) {
|
||||
if (result.status === 'rejected' && !isNotFound(result.reason)) {
|
||||
throw result.reason
|
||||
}
|
||||
}
|
||||
|
||||
// Force default icon state across all useServerImage instances via the shared query cache.
|
||||
// Use `null` (not `undefined`) because TanStack Query v5 treats setQueriesData(undefined)
|
||||
// as a no-op. The `null` sentinel is handled by useServerImage's image computed.
|
||||
clearImage()
|
||||
await queryClient.cancelQueries({ queryKey: ['servers', 'detail', serverId, 'icon'] })
|
||||
queryClient.setQueriesData({ queryKey: ['servers', 'detail', serverId, 'icon'] }, null)
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server icon reset',
|
||||
text: 'Your server icon was successfully reset.',
|
||||
})
|
||||
} catch {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Reset failed',
|
||||
text: 'Failed to reset server icon.',
|
||||
})
|
||||
} finally {
|
||||
isSyncingIcon.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (isIconActionLoading.value) return
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.id = 'server-icon-field'
|
||||
input.accept = 'image/png,image/jpeg,image/gif,image/webp'
|
||||
const cleanup = () => {
|
||||
input.remove()
|
||||
window.removeEventListener('focus', handleWindowFocus)
|
||||
}
|
||||
const handleWindowFocus = () => {
|
||||
// If picker was cancelled there is no change event; clean up on focus return.
|
||||
setTimeout(() => {
|
||||
if (!input.value) cleanup()
|
||||
}, 0)
|
||||
}
|
||||
input.onchange = async (event) => {
|
||||
try {
|
||||
await uploadFile(event)
|
||||
} finally {
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
document.body.appendChild(input)
|
||||
window.addEventListener('focus', handleWindowFocus, { once: true })
|
||||
input.click()
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div
|
||||
class="experimental-styles-within flex size-16 shrink-0 overflow-hidden rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
class="experimental-styles-within relative flex size-16 shrink-0 overflow-hidden rounded-2xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<client-only>
|
||||
<template v-if="hasMounted">
|
||||
<img
|
||||
v-if="image"
|
||||
class="h-full w-full select-none object-fill"
|
||||
@@ -15,14 +15,29 @@
|
||||
alt="Server Icon"
|
||||
:src="MinecraftServerIcon"
|
||||
/>
|
||||
</client-only>
|
||||
</template>
|
||||
<img
|
||||
v-else
|
||||
class="h-full w-full select-none object-fill"
|
||||
alt="Server Icon"
|
||||
:src="MinecraftServerIcon"
|
||||
/>
|
||||
<div v-if="disabled" class="absolute inset-0 bg-surface-1 opacity-50" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { MinecraftServerIcon } from '@modrinth/assets'
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
const hasMounted = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
hasMounted.value = true
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
image: string | undefined
|
||||
disabled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
@@ -4,7 +4,10 @@ export * from './icons'
|
||||
export { default as InstallingBanner } from './InstallingBanner.vue'
|
||||
export * from './labels'
|
||||
export * from './marketing'
|
||||
export type { PendingChange } from './ServerListing.vue'
|
||||
export { default as ServerListing } from './ServerListing.vue'
|
||||
export { default as SaveBanner } from './SaveBanner.vue'
|
||||
export * from './server-header'
|
||||
export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue'
|
||||
export { type PendingChange, default as ServerListing } from './ServerListing.vue'
|
||||
export { default as ServerSettingsModal } from './ServerSettingsModal.vue'
|
||||
export { default as ServerSetupModal } from './ServerSetupModal.vue'
|
||||
export { default as ServersPromo } from './ServersPromo.vue'
|
||||
|
||||
3
packages/ui/src/components/servers/labels/Separator.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<div class="experimental-styles-within h-1.5 w-1.5 bg-button-border rounded-full"></div>
|
||||
</template>
|
||||
@@ -1,15 +1,13 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="game"
|
||||
v-tooltip="'Change server version'"
|
||||
class="min-w-0 flex-none flex-row items-center gap-2 first:!flex"
|
||||
>
|
||||
<GameIcon aria-hidden="true" class="size-5 shrink-0" />
|
||||
<div v-if="game" class="min-w-0 flex-none flex-row items-center gap-1.5 first:!flex">
|
||||
<Separator v-if="!noSeparator" />
|
||||
|
||||
<GameIcon aria-hidden="true" />
|
||||
<AutoLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/hosting/manage/${serverId}/options/loader` : ''"
|
||||
class="flex min-w-0 items-center truncate text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
:to="settingsLinkTarget"
|
||||
class="flex min-w-0 items-center truncate text-sm font-medium"
|
||||
:class="settingsLinkTarget ? 'hover:underline' : ''"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
{{ game[0].toUpperCase() + game.slice(1) }}
|
||||
@@ -17,7 +15,11 @@
|
||||
<span v-else class="inline-block h-3 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
</div>
|
||||
</AutoLink>
|
||||
<div v-else class="flex min-w-0 flex-row items-center gap-1 truncate text-sm font-semibold">
|
||||
<div
|
||||
v-else
|
||||
v-tooltip="'Change server version'"
|
||||
class="pointer-events-none flex min-w-0 flex-row items-center gap-1 truncate text-sm font-medium"
|
||||
>
|
||||
{{ game[0].toUpperCase() + game.slice(1) }}
|
||||
<span v-if="mcVersion">{{ mcVersion }}</span>
|
||||
<span v-else class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"></span>
|
||||
@@ -27,16 +29,25 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { GameIcon } from '@modrinth/assets'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { injectServerSettingsModal } from '#ui/providers/server-settings-modal'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Separator from './Separator.vue'
|
||||
|
||||
defineProps<{
|
||||
game: string
|
||||
mcVersion: string
|
||||
isLink?: boolean
|
||||
noSeparator?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
const settingsModal = injectServerSettingsModal(null)
|
||||
const settingsLinkTarget = computed(() => {
|
||||
if (settingsModal) {
|
||||
return () => settingsModal.openServerSettings({ tabId: 'installation' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
<template>
|
||||
<div>
|
||||
<ServerPlayerCount
|
||||
v-if="showPlayerCount"
|
||||
:current-players="serverData.players.current"
|
||||
:max-players="serverData.players.max"
|
||||
:online="serverData.online"
|
||||
/>
|
||||
<ServerGameLabel
|
||||
v-if="showGameLabel"
|
||||
:game="serverData.game"
|
||||
:mc-version="serverData.mc_version ?? ''"
|
||||
:no-separator="column || !showPlayerCount"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<ServerLoaderLabel
|
||||
v-if="showLoaderLabel"
|
||||
:loader="serverData.loader"
|
||||
:loader-version="serverData.loader_version ?? ''"
|
||||
:no-separator="column"
|
||||
:no-separator="column || !showGameLabel"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<ServerSubdomainLabel
|
||||
v-if="serverData.net?.domain"
|
||||
:subdomain="serverData.net.domain"
|
||||
:no-separator="column"
|
||||
:server-id="serverId"
|
||||
:no-separator="column || (!showLoaderLabel && !showGameLabel)"
|
||||
:is-link="linked"
|
||||
/>
|
||||
<ServerUptimeLabel
|
||||
@@ -29,14 +38,17 @@
|
||||
<script setup lang="ts">
|
||||
import ServerGameLabel from './ServerGameLabel.vue'
|
||||
import ServerLoaderLabel from './ServerLoaderLabel.vue'
|
||||
import ServerPlayerCount from './ServerPlayerCount.vue'
|
||||
import ServerSubdomainLabel from './ServerSubdomainLabel.vue'
|
||||
import ServerUptimeLabel from './ServerUptimeLabel.vue'
|
||||
|
||||
interface ServerInfoLabelsProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
serverData: Record<string, any>
|
||||
serverId?: string
|
||||
showGameLabel: boolean
|
||||
showLoaderLabel: boolean
|
||||
showPlayerCount?: boolean
|
||||
uptimeSeconds?: number
|
||||
column?: boolean
|
||||
linked?: boolean
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<template>
|
||||
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-2 truncate">
|
||||
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<LoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
|
||||
<div class="flex min-w-0 flex-row items-center gap-2 truncate">
|
||||
<Separator v-if="!noSeparator" />
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<LoaderIcon v-if="loader" :loader="loader" />
|
||||
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
|
||||
<AutoLink
|
||||
v-if="isLink"
|
||||
:to="serverId ? `/hosting/manage/${serverId}/options/loader` : ''"
|
||||
class="flex min-w-0 items-center text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
v-tooltip="'Change server loader'"
|
||||
:to="settingsLinkTarget"
|
||||
class="flex min-w-0 items-center font-medium text-sm"
|
||||
:class="settingsLinkTarget ? 'hover:underline' : ''"
|
||||
>
|
||||
<span v-if="loader">
|
||||
{{ loader }}
|
||||
@@ -19,7 +20,7 @@
|
||||
<span class="inline-block h-4 w-12 animate-pulse rounded bg-button-border"></span>
|
||||
</span>
|
||||
</AutoLink>
|
||||
<div v-else class="min-w-0 text-sm font-semibold">
|
||||
<div v-else class="pointer-events-none min-w-0 font-medium text-sm">
|
||||
<span v-if="loader">
|
||||
{{ loader }}
|
||||
<span v-if="loaderVersion">{{ loaderVersion }}</span>
|
||||
@@ -34,10 +35,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { injectServerSettingsModal } from '#ui/providers/server-settings-modal'
|
||||
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import LoaderIcon from '../icons/LoaderIcon.vue'
|
||||
import Separator from './Separator.vue'
|
||||
|
||||
defineProps<{
|
||||
noSeparator?: boolean
|
||||
@@ -46,6 +50,11 @@ defineProps<{
|
||||
isLink?: boolean
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const serverId = route.params.id as string
|
||||
const settingsModal = injectServerSettingsModal(null)
|
||||
const settingsLinkTarget = computed(() => {
|
||||
if (settingsModal) {
|
||||
return () => settingsModal.openServerSettings({ tabId: 'installation' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div class="flex gap-1 text-sm font-medium">
|
||||
<!-- indicator icon -->
|
||||
|
||||
{{ currentPlayers }} / {{ maxPlayers }} Players
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface ServerPlayerCountProps {
|
||||
currentPlayers: number
|
||||
maxPlayers: number
|
||||
online: boolean
|
||||
}
|
||||
|
||||
defineProps<ServerPlayerCountProps>()
|
||||
</script>
|
||||
@@ -2,14 +2,17 @@
|
||||
<div
|
||||
v-if="subdomain && !isHidden"
|
||||
v-tooltip="'Copy custom URL'"
|
||||
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer"
|
||||
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer hover:underline"
|
||||
data-subdomain-label
|
||||
@click.stop.prevent
|
||||
>
|
||||
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<LinkIcon class="flex size-5 shrink-0" />
|
||||
<Separator v-if="!noSeparator" />
|
||||
|
||||
<div class="flex flex-row items-center gap-1.5">
|
||||
<LinkIcon />
|
||||
<div
|
||||
class="flex min-w-0 text-sm font-semibold"
|
||||
:class="serverId ? 'hover:underline' : ''"
|
||||
class="flex min-w-0 font-medium text-sm text-nowrap"
|
||||
:class="props.subdomain ? 'hover:underline' : ''"
|
||||
@click="copySubdomain"
|
||||
>
|
||||
{{ subdomain }}.modrinth.gg
|
||||
@@ -25,10 +28,13 @@ import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Separator from './Separator.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const props = defineProps<{
|
||||
subdomain: string
|
||||
serverId?: string
|
||||
noSeparator?: boolean
|
||||
}>()
|
||||
|
||||
@@ -42,9 +48,9 @@ const copySubdomain = () => {
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const serverId = computed(() => route.params.id as string)
|
||||
const serverId = props.serverId || (route.params.id as string)
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId.value}-preferences`, {
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
hideSubdomainLabel: false,
|
||||
})
|
||||
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-2"
|
||||
data-pyro-uptime
|
||||
>
|
||||
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
<Separator v-if="!noSeparator" />
|
||||
|
||||
<div class="flex gap-2">
|
||||
<TimerIcon class="flex size-5 shrink-0" />
|
||||
<time class="truncate text-sm font-semibold" :aria-label="verboseUptime">
|
||||
<div class="flex gap-1.5">
|
||||
<time class="truncate text-sm font-medium" :aria-label="verboseUptime">
|
||||
{{ formattedUptime }}
|
||||
</time>
|
||||
</div>
|
||||
@@ -17,9 +16,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TimerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Separator from './Separator.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
uptimeSeconds: number
|
||||
noSeparator?: boolean
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div
|
||||
class="medal-promotion relative flex w-full flex-row items-center justify-between rounded-2xl p-4 shadow-xl"
|
||||
>
|
||||
<MedalBackgroundImage />
|
||||
|
||||
<div class="z-10 mr-2 flex flex-col gap-1">
|
||||
<Transition
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-active-class="transition-all duration-300"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="expiryDate"
|
||||
class="flex items-center gap-2 whitespace-nowrap font-semibold text-contrast"
|
||||
>
|
||||
<ClockIcon class="clock-glow text-medal-orange size-5 shrink-0" />
|
||||
<span class="w-full text-wrap text-lg">
|
||||
Your <span class="text-medal-orange">Medal</span>-powered Modrinth Server will expire in
|
||||
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.days }}</span> days
|
||||
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.hours }}</span> hours
|
||||
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.minutes }}</span> minutes
|
||||
<span class="text-medal-orange font-bold">{{ timeLeftCountdown.seconds }}</span>
|
||||
seconds.
|
||||
</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<ButtonStyled color="medal-promo" type="outlined" size="large">
|
||||
<button class="z-10 my-auto" @click="openUpgradeModal"><RocketIcon /> Upgrade</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ServersUpgradeModalWrapper
|
||||
ref="upgradeModal"
|
||||
:stripe-publishable-key="props.stripePublishableKey ?? ''"
|
||||
:site-url="props.siteUrl ?? ''"
|
||||
:products="props.products ?? []"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { ClockIcon, RocketIcon } from '@modrinth/assets'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjsDuration from 'dayjs/plugin/duration'
|
||||
import { type ComponentPublicInstance, computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
|
||||
import MedalBackgroundImage from './MedalBackgroundImage.vue'
|
||||
|
||||
dayjs.extend(dayjsDuration)
|
||||
|
||||
type UpgradeWrapperRef = ComponentPublicInstance<{ open: (id?: string) => void | Promise<void> }>
|
||||
const upgradeModal = ref<UpgradeWrapperRef | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
serverId?: string
|
||||
stripePublishableKey?: string
|
||||
siteUrl?: string
|
||||
products?: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { data: subscriptions } = useQuery({
|
||||
queryKey: ['billing', 'subscriptions'],
|
||||
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
|
||||
})
|
||||
|
||||
const expiryDate = computed(() => {
|
||||
for (const subscription of subscriptions.value || []) {
|
||||
if (subscription.metadata?.id === props.serverId) {
|
||||
return dayjs(subscription.created).add(5, 'days')
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
})
|
||||
|
||||
function openUpgradeModal() {
|
||||
upgradeModal.value?.open(props.serverId)
|
||||
}
|
||||
|
||||
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 })
|
||||
|
||||
function updateCountdown() {
|
||||
if (!expiryDate.value) {
|
||||
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
const now = dayjs()
|
||||
const diff = expiryDate.value.diff(now)
|
||||
|
||||
if (diff <= 0) {
|
||||
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
const duration = dayjs.duration(diff)
|
||||
timeLeftCountdown.value = {
|
||||
days: duration.days(),
|
||||
hours: duration.hours(),
|
||||
minutes: duration.minutes(),
|
||||
seconds: duration.seconds(),
|
||||
}
|
||||
}
|
||||
|
||||
updateCountdown()
|
||||
|
||||
const intervalId = ref<NodeJS.Timeout | null>(null)
|
||||
onMounted(() => {
|
||||
intervalId.value = setInterval(updateCountdown, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId.value) clearInterval(intervalId.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.medal-promotion {
|
||||
position: relative;
|
||||
border: 1px solid var(--medal-promotion-bg-orange);
|
||||
background: inherit; // allows overlay + pattern to take over
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--medal-promotion-bg-gradient);
|
||||
z-index: 1;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.background-pattern {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 0;
|
||||
background-color: var(--medal-promotion-bg);
|
||||
border-radius: inherit;
|
||||
color: var(--medal-promotion-text-orange);
|
||||
}
|
||||
|
||||
.clock-glow {
|
||||
filter: drop-shadow(0 0 72px var(--color-orange)) drop-shadow(0 0 36px var(--color-orange))
|
||||
drop-shadow(0 0 18px var(--color-orange));
|
||||
}
|
||||
|
||||
.text-medal-orange {
|
||||
color: var(--medal-promotion-text-orange);
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
@@ -1,127 +1,143 @@
|
||||
<template>
|
||||
<div class="rounded-2xl shadow-xl">
|
||||
<div
|
||||
class="transition-all"
|
||||
:class="{
|
||||
pressable: !isDisabled,
|
||||
hoverable: !isDisabled,
|
||||
'cursor-pointer': !isDisabled,
|
||||
}"
|
||||
:role="!isDisabled ? 'link' : undefined"
|
||||
:tabindex="!isDisabled ? 0 : undefined"
|
||||
@click="navigateToServer"
|
||||
@keydown.enter.self="navigateToServer"
|
||||
@keydown.space.prevent.self="navigateToServer"
|
||||
>
|
||||
<div
|
||||
class="medal-promotion flex flex-row items-center overflow-x-hidden rounded-t-2xl p-4 transition-transform duration-100"
|
||||
:class="status === 'suspended' ? 'rounded-b-none border-b-0 opacity-75' : 'rounded-b-2xl'"
|
||||
class="medal-promotion flex flex-row items-center overflow-x-hidden rounded-2xl p-4 transition-all duration-150"
|
||||
:class="{
|
||||
'!rounded-b-none border-b-0': hasNotice,
|
||||
'!bg-surface-2': isDisabled,
|
||||
}"
|
||||
data-pyro-server-listing
|
||||
:data-pyro-server-listing-id="server_id"
|
||||
>
|
||||
<MedalBackgroundImage />
|
||||
<AutoLink
|
||||
:to="status === 'suspended' ? '' : `/hosting/manage/${props.server_id}`"
|
||||
class="z-10 flex flex-grow flex-row items-center overflow-x-hidden"
|
||||
:class="status !== 'suspended' && 'active:scale-95'"
|
||||
<div
|
||||
v-if="isDisabled"
|
||||
class="relative z-10 flex size-16 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<Avatar
|
||||
v-if="status !== 'suspended'"
|
||||
src="https://cdn-raw.modrinth.com/medal_icon.webp"
|
||||
size="64px"
|
||||
class="z-10"
|
||||
<Avatar src="https://cdn-raw.modrinth.com/medal_icon.webp" size="64px" class="opacity-50" />
|
||||
<SpinnerIcon
|
||||
v-if="isUpgrading"
|
||||
class="size-8 animate-spin absolute text-contrast"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
/>
|
||||
<LockIcon v-else class="size-8 absolute" :class="{ 'opacity-50': isDisabled }" />
|
||||
</div>
|
||||
<Avatar v-else src="https://cdn-raw.modrinth.com/medal_icon.webp" size="64px" class="z-10" />
|
||||
<div class="z-10 ml-4 flex min-w-0 flex-col gap-1.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2
|
||||
class="m-0 truncate text-xl font-bold text-contrast"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
>
|
||||
{{ name }}
|
||||
</h2>
|
||||
|
||||
<span class="truncate" :class="{ 'opacity-50': isDisabled }">
|
||||
<IntlFormatted
|
||||
:message-id="messages.countdownRemaining"
|
||||
:values="{
|
||||
days: timeLeftCountdown.days,
|
||||
hours: timeLeftCountdown.hours,
|
||||
minutes: timeLeftCountdown.minutes,
|
||||
seconds: timeLeftCountdown.seconds,
|
||||
}"
|
||||
>
|
||||
<template #days-count="{ children }">
|
||||
<span class="text-medal-orange"><component :is="() => children" /></span>
|
||||
</template>
|
||||
<template #hours-count="{ children }">
|
||||
<span class="text-medal-orange"><component :is="() => children" /></span>
|
||||
</template>
|
||||
<template #minutes-count="{ children }">
|
||||
<span class="text-medal-orange"><component :is="() => children" /></span>
|
||||
</template>
|
||||
<template #seconds-count="{ children }">
|
||||
<span class="text-medal-orange"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="bg-bg-secondary z-10 flex size-16 shrink-0 items-center justify-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
>
|
||||
<LockIcon class="size-12 text-secondary" />
|
||||
</div>
|
||||
|
||||
<div class="z-10 ml-4 flex min-w-0 flex-col gap-2.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 truncate text-xl font-bold text-contrast">{{ name }}</h2>
|
||||
<ChevronRightIcon />
|
||||
|
||||
<span class="truncate">
|
||||
<span class="text-medal-orange">
|
||||
{{ timeLeftCountdown.days }}
|
||||
</span>
|
||||
days
|
||||
<span class="text-medal-orange">
|
||||
{{ timeLeftCountdown.hours }}
|
||||
</span>
|
||||
hours
|
||||
<span class="text-medal-orange">
|
||||
{{ timeLeftCountdown.minutes }}
|
||||
</span>
|
||||
minutes
|
||||
<span class="text-medal-orange">
|
||||
{{ timeLeftCountdown.seconds }}
|
||||
</span>
|
||||
seconds remaining...
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="projectData?.title"
|
||||
class="m-0 flex flex-row items-center gap-2 text-sm font-medium text-secondary"
|
||||
>
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
alt="Server Icon"
|
||||
/>
|
||||
Using {{ projectData?.title || 'Unknown' }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isConfiguring"
|
||||
class="text-medal-orange flex min-w-0 items-center gap-2 truncate text-sm font-semibold"
|
||||
>
|
||||
<SparklesIcon class="size-5 shrink-0" /> New server
|
||||
</div>
|
||||
<ServerInfoLabels
|
||||
v-else
|
||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:linked="false"
|
||||
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
|
||||
<Avatar
|
||||
:src="iconUrl"
|
||||
no-shadow
|
||||
style="min-height: 20px; min-width: 20px; height: 20px; width: 20px"
|
||||
:alt="formatMessage(messages.serverIconAlt)"
|
||||
/>
|
||||
{{ formatMessage(messages.usingProjectLabel, { projectTitle: projectData?.title }) }}
|
||||
</div>
|
||||
</AutoLink>
|
||||
|
||||
<div v-if="isNuxt" class="z-10 ml-auto">
|
||||
<div
|
||||
v-if="isConfiguring"
|
||||
class="flex min-w-0 items-center gap-2 truncate text-sm font-medium text-blue h-[28px] w-max"
|
||||
>
|
||||
<SparklesIcon class="size-5 shrink-0 font-semibold" />
|
||||
{{ formatMessage(messages.newServerLabel) }}
|
||||
</div>
|
||||
<ServerInfoLabels
|
||||
v-else
|
||||
:server-data="{ game, mc_version, loader, loader_version, net }"
|
||||
:server-id="server_id"
|
||||
:show-game-label="showGameLabel"
|
||||
:show-loader-label="showLoaderLabel"
|
||||
:linked="false"
|
||||
:class="{ 'opacity-50': isDisabled }"
|
||||
class="flex w-full flex-row flex-wrap items-center gap-2 text-primary *:hidden sm:flex-row sm:*:flex"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="z-10 ml-auto">
|
||||
<ButtonStyled color="medal-promo" type="outlined" size="large">
|
||||
<button class="my-auto" @click="handleUpgrade"><RocketIcon /> Upgrade</button>
|
||||
<button class="my-auto" data-server-listing-button @click="handleUpgrade">
|
||||
<RocketIcon /> {{ formatMessage(messages.upgradeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="status === 'suspended' && suspension_reason === 'upgrading'"
|
||||
class="relative flex w-full flex-row items-center gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-blue bg-bg-blue p-4 text-sm font-bold text-contrast"
|
||||
class="server-listing-notice"
|
||||
>
|
||||
<LoaderCircleIcon class="size-5 animate-spin" />
|
||||
Your server's hardware is currently being upgraded and will be back online shortly.
|
||||
<div class="flex gap-2">
|
||||
{{ formatMessage(messages.upgradingNotice) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason === 'cancelled'"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
class="server-listing-notice"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your Medal server trial has ended and your server has
|
||||
been suspended. Please upgrade to continue to use your server.
|
||||
</div>
|
||||
<div>{{ formatMessage(messages.medalTrialEndedNotice) }}</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended' && suspension_reason"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended:
|
||||
{{ suspension_reason }}. Please update your billing information or contact Modrinth Support
|
||||
for more information.
|
||||
<div v-else-if="status === 'suspended' && suspension_reason" class="server-listing-notice">
|
||||
<div>
|
||||
{{
|
||||
formatMessage(messages.suspendedWithReasonNotice, {
|
||||
reason: suspension_reason,
|
||||
})
|
||||
}}
|
||||
</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="status === 'suspended'"
|
||||
class="relative flex w-full flex-col gap-2 rounded-b-2xl border-[1px] border-t-0 border-solid border-bg-red bg-bg-red p-4 text-sm font-bold text-contrast"
|
||||
>
|
||||
<div class="flex flex-row gap-2">
|
||||
<TriangleAlertIcon class="!size-5" /> Your server has been suspended. Please update your
|
||||
billing information or contact Modrinth Support for more information.
|
||||
</div>
|
||||
<div v-else-if="status === 'suspended'" class="server-listing-notice">
|
||||
<div>{{ formatMessage(messages.suspendedNotice) }}</div>
|
||||
<CopyCode :text="`${props.server_id}`" class="ml-auto" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,25 +145,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
LoaderCircleIcon,
|
||||
LockIcon,
|
||||
RocketIcon,
|
||||
SparklesIcon,
|
||||
TriangleAlertIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { LockIcon, RocketIcon, SparklesIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjsDuration from 'dayjs/plugin/duration'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import { injectModrinthClient } from '../../../providers/api-client'
|
||||
import AutoLink from '../../base/AutoLink.vue'
|
||||
import Avatar from '../../base/Avatar.vue'
|
||||
import ButtonStyled from '../../base/ButtonStyled.vue'
|
||||
import CopyCode from '../../base/CopyCode.vue'
|
||||
import IntlFormatted from '../../base/IntlFormatted.vue'
|
||||
import ServerInfoLabels from '../labels/ServerInfoLabels.vue'
|
||||
import MedalBackgroundImage from './MedalBackgroundImage.vue'
|
||||
|
||||
@@ -171,13 +181,21 @@ type MedalServerListingProps = {
|
||||
const props = defineProps<MedalServerListingProps>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'upgrade'): void }>()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const router = useRouter()
|
||||
|
||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||
// const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||
|
||||
const showGameLabel = computed(() => !!props.game)
|
||||
const showLoaderLabel = computed(() => !!props.loader)
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
const isUpgrading = computed(
|
||||
() => props.status === 'suspended' && props.suspension_reason === 'upgrading',
|
||||
)
|
||||
const isDisabled = computed(() => props.status === 'suspended')
|
||||
const hasNotice = computed(() => props.status === 'suspended')
|
||||
|
||||
const { data: projectData } = useQuery({
|
||||
queryKey: ['server-project', props.server_id, props.upstream?.project_id],
|
||||
@@ -189,7 +207,50 @@ const { data: projectData } = useQuery({
|
||||
})
|
||||
|
||||
const iconUrl = computed(() => projectData.value?.icon_url || undefined)
|
||||
const isConfiguring = computed(() => props.flows?.intro)
|
||||
|
||||
const messages = defineMessages({
|
||||
countdownRemaining: {
|
||||
id: 'servers.medal-listing.countdown.remaining',
|
||||
defaultMessage:
|
||||
'<days-count>{days}</days-count> {days, plural, one {day} other {days}} <hours-count>{hours}</hours-count> {hours, plural, one {hour} other {hours}} <minutes-count>{minutes}</minutes-count> {minutes, plural, one {minute} other {minutes}} <seconds-count>{seconds}</seconds-count> {seconds, plural, one {second} other {seconds}} remaining...',
|
||||
},
|
||||
serverIconAlt: {
|
||||
id: 'servers.medal-listing.server-icon-alt',
|
||||
defaultMessage: 'Server icon',
|
||||
},
|
||||
usingProjectLabel: {
|
||||
id: 'servers.medal-listing.using-project-label',
|
||||
defaultMessage: 'Using {projectTitle}',
|
||||
},
|
||||
newServerLabel: {
|
||||
id: 'servers.medal-listing.new-server-label',
|
||||
defaultMessage: 'New server',
|
||||
},
|
||||
upgradeButton: {
|
||||
id: 'servers.medal-listing.upgrade-button',
|
||||
defaultMessage: 'Upgrade',
|
||||
},
|
||||
upgradingNotice: {
|
||||
id: 'servers.medal-listing.notice.upgrading',
|
||||
defaultMessage:
|
||||
"Your server's hardware is currently being upgraded and will be back online shortly.",
|
||||
},
|
||||
medalTrialEndedNotice: {
|
||||
id: 'servers.medal-listing.notice.medal-trial-ended',
|
||||
defaultMessage:
|
||||
'Your Medal server trial has ended and your server has been suspended. Please upgrade to continue using your server.',
|
||||
},
|
||||
suspendedWithReasonNotice: {
|
||||
id: 'servers.medal-listing.notice.suspended-with-reason',
|
||||
defaultMessage:
|
||||
'Your server has been suspended: {reason}. Please update your billing information or contact Modrinth Support for more information.',
|
||||
},
|
||||
suspendedNotice: {
|
||||
id: 'servers.medal-listing.notice.suspended',
|
||||
defaultMessage:
|
||||
'Your server has been suspended. Please update your billing information or contact Modrinth Support for more information.',
|
||||
},
|
||||
})
|
||||
|
||||
const timeLeftCountdown = ref({ days: 0, hours: 0, minutes: 0, seconds: 0 })
|
||||
const expiryDate = computed(() => (props.medal_expires ? dayjs(props.medal_expires) : null))
|
||||
@@ -199,6 +260,20 @@ function handleUpgrade(event: Event) {
|
||||
emit('upgrade')
|
||||
}
|
||||
|
||||
function navigateToServer(event: MouseEvent | KeyboardEvent) {
|
||||
if (isDisabled.value) return
|
||||
|
||||
const target = event.target
|
||||
if (
|
||||
target instanceof HTMLElement &&
|
||||
target.closest('[data-subdomain-label], [data-server-listing-button]')
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/hosting/manage/${props.server_id}`)
|
||||
}
|
||||
|
||||
function updateCountdown() {
|
||||
if (!expiryDate.value) {
|
||||
timeLeftCountdown.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
|
||||
@@ -250,4 +325,17 @@ onUnmounted(() => {
|
||||
.border-medal-orange {
|
||||
border-color: var(--medal-promotion-bg-orange);
|
||||
}
|
||||
|
||||
.server-listing-notice {
|
||||
@apply relative flex w-full rounded-b-2xl border-[1px] border-t-0 border-solid p-4 flex-col gap-4 border-surface-4 bg-bg-raised text-primary;
|
||||
}
|
||||
|
||||
.hoverable:hover:not(:has([data-subdomain-label]:hover, [data-server-listing-button]:hover))
|
||||
.medal-promotion {
|
||||
filter: brightness(1.05) saturate(1.1);
|
||||
}
|
||||
|
||||
.pressable:active:not(:has([data-subdomain-label]:active, [data-server-listing-button]:active)) {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { default as MedalBackgroundImage } from './MedalBackgroundImage.vue'
|
||||
export { default as MedalServerCountdown } from './MedalServerCountdown.vue'
|
||||
export { default as MedalServerListing } from './MedalServerListing.vue'
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<div class="flex flex-row items-center gap-2 rounded-lg">
|
||||
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
|
||||
<button disabled class="flex-shrink-0">
|
||||
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template v-else>
|
||||
<ButtonStyled v-if="showStopButton" type="transparent" size="large">
|
||||
<button
|
||||
v-tooltip="busyTooltip"
|
||||
:disabled="!canTakeAction"
|
||||
@click="initiateAction('Stop')"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<StopCircleIcon class="h-5 w-5" />
|
||||
<span>Stop</span>
|
||||
</div>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="standard" color="brand" size="large">
|
||||
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||
<component :is="isRunning || showStopButton ? UpdatedIcon : PlayIcon" />
|
||||
<span>{{ primaryActionText }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LoaderCircleIcon, PlayIcon, StopCircleIcon, UpdatedIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { ButtonStyled } from '#ui/components'
|
||||
|
||||
import { useServerPowerAction } from './use-server-power-action'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
},
|
||||
)
|
||||
|
||||
const {
|
||||
isInstalling,
|
||||
isRunning,
|
||||
showStopButton,
|
||||
busyTooltip,
|
||||
canTakeAction,
|
||||
primaryActionText,
|
||||
initiateAction,
|
||||
handlePrimaryAction,
|
||||
} = useServerPowerAction({
|
||||
disabled: computed(() => props.disabled),
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<ButtonStyled circular type="transparent" size="large">
|
||||
<TeleportOverflowMenu :options="menuOptions">
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #kill>
|
||||
<SlashIcon class="h-5 w-5" />
|
||||
<span>Kill server</span>
|
||||
</template>
|
||||
<template #allServers>
|
||||
<ServerIcon class="h-5 w-5" />
|
||||
<span>All servers</span>
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
<span>Copy ID</span>
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, MoreVerticalIcon, ServerIcon, SlashIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { ButtonStyled } from '#ui/components'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
import { useServerPowerAction } from './use-server-power-action'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean
|
||||
showCopyIdAction?: boolean
|
||||
showDebugInfo?: boolean
|
||||
uptimeSeconds?: number
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
showCopyIdAction: false,
|
||||
showDebugInfo: false,
|
||||
uptimeSeconds: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const router = useRouter()
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
|
||||
const { isInstalling, initiateAction } = useServerPowerAction({
|
||||
disabled: computed(() => props.disabled),
|
||||
})
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
...(isInstalling.value
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 'kill',
|
||||
label: 'Kill server',
|
||||
icon: SlashIcon,
|
||||
action: () => initiateAction('Kill'),
|
||||
},
|
||||
]),
|
||||
{
|
||||
id: 'allServers',
|
||||
label: 'All servers',
|
||||
icon: ServerIcon,
|
||||
action: () => router.push('/hosting/manage'),
|
||||
},
|
||||
{
|
||||
id: 'copy-id',
|
||||
label: 'Copy ID',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => copyId(),
|
||||
shown: props.showCopyIdAction,
|
||||
},
|
||||
])
|
||||
|
||||
async function copyId() {
|
||||
await navigator.clipboard.writeText(serverId)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
|
||||
<ContentPageHeader :class="props.headerClass">
|
||||
<template #icon>
|
||||
<ServerIcon
|
||||
:image="headerImage"
|
||||
:class="isNuxt ? 'size-20 !rounded-2xl' : 'size-16 !rounded-xl'"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
{{ props.server?.name || 'Server' }}
|
||||
</template>
|
||||
<template #stats>
|
||||
<div
|
||||
v-if="props.server?.flows?.intro"
|
||||
class="flex items-center gap-2 font-semibold text-secondary"
|
||||
>
|
||||
<SettingsIcon />
|
||||
Configuring server...
|
||||
</div>
|
||||
<div v-else class="flex flex-wrap items-center gap-2">
|
||||
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium capitalize">
|
||||
<LoaderIcon :loader="props.server.loader" class="flex shrink-0 [&&]:size-5" />
|
||||
{{ props.server.loader }} {{ props.server.mc_version }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
props.server?.loader &&
|
||||
props.server?.net?.domain &&
|
||||
!userPreferences.hideSubdomainLabel
|
||||
"
|
||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="props.server?.net?.domain && !userPreferences.hideSubdomainLabel"
|
||||
v-tooltip="'Copy server address'"
|
||||
class="flex cursor-pointer items-center gap-2 font-medium hover:underline text-nowrap"
|
||||
@click="copyServerAddress"
|
||||
>
|
||||
<LinkIcon class="flex size-5 shrink-0" />
|
||||
{{ props.server.net.domain }}.modrinth.gg
|
||||
</div>
|
||||
|
||||
<div v-if="showUptime" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
|
||||
|
||||
<div v-if="showUptime" class="flex items-center gap-2 font-medium">
|
||||
<TimerIcon class="flex size-5 shrink-0" />
|
||||
{{ formattedUptime }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showProject && (props.server?.loader || props.server?.net?.domain || showUptime)"
|
||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="showProject"
|
||||
class="flex items-center gap-1.5 font-medium text-primary text-nowrap"
|
||||
>
|
||||
Linked to
|
||||
<Avatar
|
||||
:src="props.serverProject?.icon_url ?? undefined"
|
||||
:alt="props.serverProject?.title ?? ''"
|
||||
size="24px"
|
||||
/>
|
||||
<AutoLink :to="serverProjectLink" class="truncate text-primary hover:underline">
|
||||
{{ props.serverProject?.title }}
|
||||
</AutoLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import { LinkIcon, LoaderIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { AutoLink, Avatar, ContentPageHeader, ServerIcon } from '#ui/components'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
|
||||
type ServerProjectSummary = {
|
||||
id: string
|
||||
slug?: string | null
|
||||
title: string
|
||||
icon_url?: string | null
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
server: Archon.Servers.v0.Server | null | undefined
|
||||
serverImage?: string | null
|
||||
serverProject?: ServerProjectSummary | null
|
||||
serverProjectLink?: string
|
||||
uptimeSeconds?: number
|
||||
showUptime?: boolean
|
||||
backHref?: string
|
||||
breadcrumbClass?: string
|
||||
headerClass?: string
|
||||
}>(),
|
||||
{
|
||||
serverImage: null,
|
||||
serverProject: null,
|
||||
serverProjectLink: '',
|
||||
uptimeSeconds: 0,
|
||||
showUptime: true,
|
||||
backHref: '/hosting/manage',
|
||||
breadcrumbClass: 'breadcrumb goto-link flex w-fit items-center',
|
||||
headerClass: '',
|
||||
},
|
||||
)
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
hideSubdomainLabel: false,
|
||||
})
|
||||
|
||||
const headerImage = computed(() => {
|
||||
if (props.server?.is_medal) {
|
||||
return 'https://cdn-raw.modrinth.com/medal_icon.webp'
|
||||
}
|
||||
return props.serverImage ?? undefined
|
||||
})
|
||||
|
||||
const showUptime = computed(() => props.showUptime && (props.uptimeSeconds ?? 0) > 0)
|
||||
|
||||
const formattedUptime = computed(() => {
|
||||
const uptime = props.uptimeSeconds ?? 0
|
||||
const days = Math.floor(uptime / (24 * 3600))
|
||||
const hours = Math.floor((uptime % (24 * 3600)) / 3600)
|
||||
const minutes = Math.floor((uptime % 3600) / 60)
|
||||
const seconds = uptime % 60
|
||||
|
||||
let formatted = ''
|
||||
if (days > 0) formatted += `${days}d `
|
||||
if (hours > 0 || days > 0) formatted += `${hours}h `
|
||||
formatted += `${minutes}m ${seconds}s`
|
||||
return formatted.trim()
|
||||
})
|
||||
|
||||
const showProject = computed(() => !!props.serverProject)
|
||||
|
||||
const serverProjectLink = computed(() => {
|
||||
if (props.serverProjectLink) {
|
||||
return props.serverProjectLink
|
||||
}
|
||||
if (!props.serverProject) {
|
||||
return ''
|
||||
}
|
||||
return `/project/${props.serverProject.slug ?? props.serverProject.id}`
|
||||
})
|
||||
|
||||
function copyServerAddress() {
|
||||
if (!props.server?.net?.domain) return
|
||||
navigator.clipboard.writeText(`${props.server.net.domain}.modrinth.gg`)
|
||||
addNotification({
|
||||
title: 'Server address copied',
|
||||
text: "Your server's address has been copied to your clipboard.",
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as PanelServerActionButton } from './PanelServerActionButton.vue'
|
||||
export { default as PanelServerOverflowMenu } from './PanelServerOverflowMenu.vue'
|
||||
export { default as ServerManageHeader } from './ServerManageHeader.vue'
|
||||
@@ -0,0 +1,80 @@
|
||||
import { computed, type Ref } from 'vue'
|
||||
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
|
||||
export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
|
||||
|
||||
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const isInstalling = computed(() => server.value.status === 'installing')
|
||||
const isRunning = computed(() => powerState.value === 'running')
|
||||
const isStopping = computed(() => powerState.value === 'stopping')
|
||||
const isStarting = computed(() => powerState.value === 'starting')
|
||||
const isTransitioning = computed(() => isStarting.value || isStopping.value)
|
||||
const showStopButton = computed(() => isRunning.value || isStarting.value)
|
||||
|
||||
const busyTooltip = computed(() => {
|
||||
if (isStopping.value) return 'Server is currently stopping'
|
||||
if (isStarting.value) return 'Your server is starting'
|
||||
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined
|
||||
})
|
||||
|
||||
const canTakeAction = computed(
|
||||
() => !isTransitioning.value && !options?.disabled?.value && busyReasons.value.length === 0,
|
||||
)
|
||||
|
||||
const primaryActionText = computed(() => {
|
||||
switch (powerState.value) {
|
||||
case 'running':
|
||||
case 'starting':
|
||||
return 'Restart'
|
||||
default:
|
||||
return 'Start'
|
||||
}
|
||||
})
|
||||
|
||||
async function sendPowerAction(action: PowerAction) {
|
||||
try {
|
||||
await client.archon.servers_v0.power(serverId, action)
|
||||
} catch (error) {
|
||||
console.error(`Error performing ${action} on server:`, error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: `Failed to ${action.toLowerCase()} server`,
|
||||
text: 'An error occurred while performing this action.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function initiateAction(action: PowerAction) {
|
||||
if (!canTakeAction.value) return
|
||||
void sendPowerAction(action)
|
||||
}
|
||||
|
||||
function handlePrimaryAction() {
|
||||
initiateAction(isRunning.value ? 'Restart' : 'Start')
|
||||
}
|
||||
|
||||
return {
|
||||
isInstalling,
|
||||
isRunning,
|
||||
isStopping,
|
||||
isTransitioning,
|
||||
showStopButton,
|
||||
busyTooltip,
|
||||
canTakeAction,
|
||||
primaryActionText,
|
||||
sendPowerAction,
|
||||
initiateAction,
|
||||
handlePrimaryAction,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-8 items-center justify-center py-10 max-w-[760px]">
|
||||
<!-- Left column -->
|
||||
<div class="flex flex-col gap-8 items-start pr-8 shrink-0">
|
||||
<!-- Heading -->
|
||||
<div class="flex flex-col gap-2 items-start w-[300px]">
|
||||
<p class="text-3xl leading-9 font-semibold text-contrast">
|
||||
{{ formatMessage(messages.modrinthHostingLabel) }}
|
||||
</p>
|
||||
<p class="text-base font-normal text-primary">
|
||||
{{ formatMessage(messages.noServersDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature list -->
|
||||
<div class="flex flex-col gap-4 items-start w-full">
|
||||
<div class="flex gap-3 items-start">
|
||||
<div
|
||||
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center"
|
||||
>
|
||||
<PackageOpenIcon class="size-5 text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.oneClickModInstallsTitle) }}
|
||||
</p>
|
||||
<p class="text-base font-normal text-primary">
|
||||
{{ formatMessage(messages.oneClickModInstallsDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 items-start">
|
||||
<div
|
||||
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<GlobeIcon class="size-5 text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.simpleSetupTitle) }}
|
||||
</p>
|
||||
<p class="text-base font-normal text-primary">
|
||||
{{ formatMessage(messages.simpleSetupDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 items-start">
|
||||
<div
|
||||
class="bg-surface-4 border border-solid border-surface-5 rounded-full shrink-0 size-8 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<UsersIcon class="size-5 text-secondary" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="text-base font-semibold text-contrast">
|
||||
{{ formatMessage(messages.playWithFriendsTitle) }}
|
||||
</p>
|
||||
<p class="text-base font-normal text-primary">
|
||||
{{ formatMessage(messages.playWithFriendsDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- CTA section -->
|
||||
<div class="flex flex-col gap-6 items-start">
|
||||
<div class="flex flex-col gap-3 items-start">
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="onClickNewServer?.()">
|
||||
<PlusIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.newServerButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<AutoLink
|
||||
to="https://modrinth.com/hosting"
|
||||
target="_blank"
|
||||
class="flex items-center gap-1 hover:brightness-125"
|
||||
>
|
||||
{{ formatMessage(messages.learnMoreLink) }}
|
||||
<RightArrowIcon class="size-5 shrink-0" aria-hidden="true" />
|
||||
</AutoLink>
|
||||
</div>
|
||||
|
||||
<template v-if="!loggedIn">
|
||||
<div class="h-px w-full bg-surface-5" />
|
||||
|
||||
<div class="flex gap-3 items-center flex-wrap">
|
||||
<p class="text-base font-normal text-primary">
|
||||
{{ formatMessage(messages.alreadyHaveServerLabel) }}
|
||||
</p>
|
||||
<ButtonStyled>
|
||||
<button @click="onClickSignIn?.()">
|
||||
<LogInIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.signInButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right column - mod icon grid -->
|
||||
<div
|
||||
class="relative flex h-[617px] shrink-0 items-center justify-center overflow-hidden rounded-[40px] pointer-events-none select-none [mask-image:linear-gradient(to_bottom,black_0%,black_35%,transparent_100%)] [-webkit-mask-image:linear-gradient(to_bottom,black_0%,black_35%,transparent_100%)]"
|
||||
>
|
||||
<div class="rotate-[15deg]">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="row in GRID_ROWS"
|
||||
:key="row"
|
||||
class="flex gap-4 items-center shrink-0"
|
||||
:class="animated ? (row % 2 === 1 ? 'drift-left' : 'drift-right relative left-14') : ''"
|
||||
>
|
||||
<div class="hidden drift-right drift-left"></div>
|
||||
<div
|
||||
v-for="col in GRID_COLS"
|
||||
:key="col"
|
||||
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
|
||||
</div>
|
||||
<div
|
||||
v-for="col in GRID_COLS"
|
||||
:key="col"
|
||||
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
|
||||
</div>
|
||||
<div
|
||||
v-for="col in GRID_COLS"
|
||||
:key="col"
|
||||
class="border border-surface-5 rounded-[20px] shrink-0 size-[112px] bg-surface-4 overflow-hidden"
|
||||
>
|
||||
<img :src="getGridImage(row - 1, col - 1)" alt="" class="size-full object-cover" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
GlobeIcon,
|
||||
LogInIcon,
|
||||
PackageOpenIcon,
|
||||
PlusIcon,
|
||||
RightArrowIcon,
|
||||
UsersIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { AutoLink } from '@modrinth/ui'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '../../../composables/i18n'
|
||||
import imgAircraft from './grid-images/aircraft.png'
|
||||
import imgAlexs from "./grid-images/alex's.png"
|
||||
import imgArtifacts from './grid-images/artifacts.png'
|
||||
import imgBiomes from './grid-images/biomes.png'
|
||||
import imgCatac from './grid-images/catac.png'
|
||||
import imgCobble from './grid-images/cobble.png'
|
||||
import imgComforts from './grid-images/comforts.png'
|
||||
import imgCreate from './grid-images/create.png'
|
||||
import imgCreate1 from './grid-images/create1.png'
|
||||
import imgCreate2 from './grid-images/create2.png'
|
||||
import imgCreate3 from './grid-images/create3.png'
|
||||
import imgCreeper from './grid-images/creeper.png'
|
||||
import imgFriends from './grid-images/friends.png'
|
||||
import imgGeo from './grid-images/geo.png'
|
||||
import imgNaturalist from './grid-images/naturalist.png'
|
||||
import imgSeasons from './grid-images/seasons.png'
|
||||
import imgTravellers from './grid-images/travellers.png'
|
||||
import imgTree from './grid-images/tree.png'
|
||||
import imgYum1 from './grid-images/yum1.png'
|
||||
import imgYum2 from './grid-images/yum2.png'
|
||||
import imgYum3 from './grid-images/yum3.png'
|
||||
import imgYung from './grid-images/yung.png'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
animated?: boolean
|
||||
onClickNewServer?: () => void
|
||||
onClickSignIn?: () => void
|
||||
loggedIn?: boolean
|
||||
}>(),
|
||||
{ animated: false },
|
||||
)
|
||||
|
||||
const GRID_ROWS = 6
|
||||
const GRID_COLS = 5
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
modrinthHostingLabel: {
|
||||
id: 'servers.list-empty.modrinth-hosting-label',
|
||||
defaultMessage: 'Modrinth Hosting',
|
||||
},
|
||||
noServersTitle: {
|
||||
id: 'servers.list-empty.no-servers-title',
|
||||
defaultMessage: 'No servers yet',
|
||||
},
|
||||
noServersDescription: {
|
||||
id: 'servers.list-empty.no-servers-description',
|
||||
defaultMessage: 'Install mods, invite friends, and play together all from the Modrinth App.',
|
||||
},
|
||||
oneClickModInstallsTitle: {
|
||||
id: 'servers.list-empty.one-click-mod-installs-title',
|
||||
defaultMessage: 'One-click mod installs',
|
||||
},
|
||||
oneClickModInstallsDescription: {
|
||||
id: 'servers.list-empty.one-click-mod-installs-description',
|
||||
defaultMessage: 'Pick your favourite mods and we handle the rest.',
|
||||
},
|
||||
simpleSetupTitle: {
|
||||
id: 'servers.list-empty.simple-setup-title',
|
||||
defaultMessage: 'Simple setup',
|
||||
},
|
||||
simpleSetupDescription: {
|
||||
id: 'servers.list-empty.simple-setup-description',
|
||||
defaultMessage: 'Set up your server just like a single player world.',
|
||||
},
|
||||
playWithFriendsTitle: {
|
||||
id: 'servers.list-empty.play-with-friends-title',
|
||||
defaultMessage: 'Play with friends',
|
||||
},
|
||||
playWithFriendsDescription: {
|
||||
id: 'servers.list-empty.play-with-friends-description',
|
||||
defaultMessage: 'Invite friends and get them set up right in the Modrinth App.',
|
||||
},
|
||||
newServerButton: {
|
||||
id: 'servers.list-empty.new-server-button',
|
||||
defaultMessage: 'New server',
|
||||
},
|
||||
learnMoreLink: {
|
||||
id: 'servers.list-empty.learn-more-link',
|
||||
defaultMessage: 'Learn more about Modrinth Hosting',
|
||||
},
|
||||
alreadyHaveServerLabel: {
|
||||
id: 'servers.list-empty.already-have-server-label',
|
||||
defaultMessage: 'Already have a server?',
|
||||
},
|
||||
signInButton: {
|
||||
id: 'servers.list-empty.sign-in-button',
|
||||
defaultMessage: 'Sign in',
|
||||
},
|
||||
})
|
||||
|
||||
const GRID_IMAGES = [
|
||||
imgYum1,
|
||||
imgYum2,
|
||||
imgYum3,
|
||||
imgYung,
|
||||
imgCreeper,
|
||||
imgFriends,
|
||||
imgNaturalist,
|
||||
imgBiomes,
|
||||
imgCatac,
|
||||
imgCobble,
|
||||
imgGeo,
|
||||
imgCreate,
|
||||
imgCreate1,
|
||||
imgCreate2,
|
||||
imgCreate3,
|
||||
imgAircraft,
|
||||
imgArtifacts,
|
||||
imgComforts,
|
||||
imgTravellers,
|
||||
imgAlexs,
|
||||
imgSeasons,
|
||||
imgTree,
|
||||
]
|
||||
|
||||
function getGridImage(row: number, col: number): string {
|
||||
return GRID_IMAGES[(row * GRID_COLS + col) % GRID_IMAGES.length]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes drift-right {
|
||||
from {
|
||||
transform: translateX(-33%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(33%);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drift-left {
|
||||
from {
|
||||
transform: translateX(33%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-33%);
|
||||
}
|
||||
}
|
||||
|
||||
.drift-left {
|
||||
animation: drift-left linear infinite alternate;
|
||||
animation-duration: 400s;
|
||||
}
|
||||
|
||||
.drift-right {
|
||||
animation: drift-right linear infinite alternate;
|
||||
animation-duration: 400s;
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 704 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 990 B |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -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>
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ export * from './i18n'
|
||||
export * from './i18n-debug'
|
||||
export * from './page-leave-safety'
|
||||
export * from './scroll-indicator'
|
||||
export * from './server-backup'
|
||||
export * from './server-console'
|
||||
export * from './server-manage-core-runtime'
|
||||
export * from './sticky-observer'
|
||||
export * from './terminal'
|
||||
export * from './use-server-image'
|
||||
export * from './use-server-project'
|
||||
export * from './virtual-scroll'
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const isClient = typeof window !== 'undefined'
|
||||
const stack: symbol[] = []
|
||||
const stackSizeRef = ref(0)
|
||||
|
||||
export function useModalStack() {
|
||||
const id = Symbol()
|
||||
|
||||
function push() {
|
||||
if (isClient && !stack.includes(id)) stack.push(id)
|
||||
if (isClient && !stack.includes(id)) {
|
||||
stack.push(id)
|
||||
stackSizeRef.value = stack.length
|
||||
}
|
||||
}
|
||||
|
||||
function pop() {
|
||||
if (!isClient) return
|
||||
const idx = stack.indexOf(id)
|
||||
if (idx !== -1) stack.splice(idx, 1)
|
||||
if (idx !== -1) {
|
||||
stack.splice(idx, 1)
|
||||
stackSizeRef.value = stack.length
|
||||
}
|
||||
}
|
||||
|
||||
function isTopmost() {
|
||||
@@ -23,5 +32,7 @@ export function useModalStack() {
|
||||
return isClient ? stack.length : 0
|
||||
}
|
||||
|
||||
return { push, pop, isTopmost, stackSize }
|
||||
const hasModal = computed(() => stackSizeRef.value > 0)
|
||||
|
||||
return { push, pop, isTopmost, stackSize, hasModal }
|
||||
}
|
||||
|
||||
54
packages/ui/src/composables/server-backup.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
|
||||
import { injectModrinthClient } from '../providers/api-client'
|
||||
import { injectNotificationManager } from '../providers/web-notifications'
|
||||
|
||||
export function useServerBackupDownload() {
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
function getLatestBackupDownload(
|
||||
serverId: string,
|
||||
serverFullList: Archon.Servers.v1.ServerFull[] | null | undefined,
|
||||
): (() => Promise<void>) | null {
|
||||
const serverFull = serverFullList?.find((s) => s.id === serverId)
|
||||
if (!serverFull) return null
|
||||
|
||||
const activeWorld = serverFull.worlds.find((w) => w.is_active) ?? serverFull.worlds[0]
|
||||
if (!activeWorld?.backups?.length) return null
|
||||
|
||||
const latestBackup = activeWorld.backups
|
||||
.filter((b) => b.status === 'done')
|
||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]
|
||||
if (!latestBackup) return null
|
||||
|
||||
return async () => {
|
||||
try {
|
||||
const server = await client.archon.servers_v0.get(serverId)
|
||||
const kyrosUrl = server.node?.instance
|
||||
const jwt = server.node?.token
|
||||
if (!kyrosUrl || !jwt) {
|
||||
addNotification({
|
||||
title: 'Download unavailable',
|
||||
text: 'Server connection info is not available. Please contact support.',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
window.open(
|
||||
`https://${kyrosUrl}/modrinth/v0/backups/${latestBackup.id}/download?auth=${jwt}`,
|
||||
'_blank',
|
||||
)
|
||||
} catch {
|
||||
addNotification({
|
||||
title: 'Download failed',
|
||||
text: 'An error occurred while trying to download the backup.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { getLatestBackupDownload }
|
||||
}
|
||||
351
packages/ui/src/composables/server-console.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { createGlobalState } from '@vueuse/core'
|
||||
import { type Ref, shallowRef, triggerRef } from 'vue'
|
||||
|
||||
import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
|
||||
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
|
||||
|
||||
// Flip to true during development to enable console perf logging.
|
||||
// Uses a plain constant to avoid turbo env-var declarations.
|
||||
const DEBUG_PERF = false
|
||||
|
||||
// TODO: for true unbounded history, consider IndexedDB or similar
|
||||
const ARCHIVE_CAPACITY = 500_000
|
||||
|
||||
const ENTRY_START_RE = /^\[\d{2}:\d{2}:\d{2}\]/
|
||||
|
||||
/**
|
||||
* Reorders a batch of log lines so that continuation lines (lines without a
|
||||
* timestamp prefix) stay grouped with their parent error/warn entry, even when
|
||||
* unrelated timestamped lines arrive between them from the server.
|
||||
*/
|
||||
function groupContinuations(lines: LogLine[]): LogLine[] {
|
||||
if (lines.length <= 1) return lines
|
||||
|
||||
const groups: LogLine[][] = []
|
||||
|
||||
for (const line of lines) {
|
||||
if (ENTRY_START_RE.test(line.text)) {
|
||||
groups.push([line])
|
||||
} else if (groups.length > 0) {
|
||||
let target = groups.length - 1
|
||||
const lastEntry = groups[target][0]
|
||||
|
||||
if (lastEntry.level !== 'error' && lastEntry.level !== 'warn') {
|
||||
if (line.level === 'error' || line.level === null) {
|
||||
for (let i = groups.length - 2; i >= 0; i--) {
|
||||
if (groups[i][0].level === 'error' || groups[i][0].level === 'warn') {
|
||||
target = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
groups[target].push(line)
|
||||
} else {
|
||||
groups.push([line])
|
||||
}
|
||||
}
|
||||
|
||||
return groups.flat()
|
||||
}
|
||||
|
||||
const batchTimeout = 300
|
||||
const initialBatchSize = 256
|
||||
|
||||
const LogLevelCode = {
|
||||
None: 0,
|
||||
Trace: 1,
|
||||
Debug: 2,
|
||||
Info: 3,
|
||||
Warn: 4,
|
||||
Error: 5,
|
||||
} as const
|
||||
type LogLevelCode = (typeof LogLevelCode)[keyof typeof LogLevelCode]
|
||||
|
||||
function encodeLevel(level: LogLevel | null): LogLevelCode {
|
||||
if (!level) return LogLevelCode.None
|
||||
switch (level) {
|
||||
case 'trace':
|
||||
return LogLevelCode.Trace
|
||||
case 'debug':
|
||||
return LogLevelCode.Debug
|
||||
case 'info':
|
||||
return LogLevelCode.Info
|
||||
case 'warn':
|
||||
return LogLevelCode.Warn
|
||||
case 'error':
|
||||
return LogLevelCode.Error
|
||||
}
|
||||
}
|
||||
|
||||
function decodeLevel(code: LogLevelCode): LogLevel | null {
|
||||
switch (code) {
|
||||
case LogLevelCode.Trace:
|
||||
return 'trace'
|
||||
case LogLevelCode.Debug:
|
||||
return 'debug'
|
||||
case LogLevelCode.Info:
|
||||
return 'info'
|
||||
case LogLevelCode.Warn:
|
||||
return 'warn'
|
||||
case LogLevelCode.Error:
|
||||
return 'error'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Columnar ring buffer: stores text and level in parallel arrays instead of
|
||||
// LogLine objects, eliminating ~40 bytes of object header per line (~20MB
|
||||
// saved at 500k lines). Lines are stored by value — get(i) returns a fresh
|
||||
// LogLine each call, so consumers must not rely on reference identity.
|
||||
class ColumnarRingBuffer {
|
||||
texts: (string | undefined)[]
|
||||
levels: Uint8Array
|
||||
private head = 0
|
||||
private _size = 0
|
||||
|
||||
constructor(readonly capacity: number) {
|
||||
this.texts = new Array(capacity)
|
||||
this.levels = new Uint8Array(capacity)
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this._size
|
||||
}
|
||||
|
||||
push(text: string, level: LogLevel | null): boolean {
|
||||
const wrapped = this._size === this.capacity
|
||||
this.texts[this.head] = text
|
||||
this.levels[this.head] = encodeLevel(level)
|
||||
this.head = (this.head + 1) % this.capacity
|
||||
if (!wrapped) this._size++
|
||||
return wrapped
|
||||
}
|
||||
|
||||
get(index: number): LogLine {
|
||||
if (index < 0 || index >= this._size) {
|
||||
throw new RangeError(`Index ${index} out of bounds [0, ${this._size})`)
|
||||
}
|
||||
const start = this._size === this.capacity ? this.head : 0
|
||||
const physical = (start + index) % this.capacity
|
||||
return {
|
||||
text: this.texts[physical] as string,
|
||||
level: decodeLevel(this.levels[physical] as LogLevelCode),
|
||||
}
|
||||
}
|
||||
|
||||
toArray(): LogLine[] {
|
||||
if (this._size === 0) return []
|
||||
const start = this._size === this.capacity ? this.head : 0
|
||||
const result = new Array<LogLine>(this._size)
|
||||
for (let i = 0; i < this._size; i++) {
|
||||
const physical = (start + i) % this.capacity
|
||||
result[i] = {
|
||||
text: this.texts[physical] as string,
|
||||
level: decodeLevel(this.levels[physical] as LogLevelCode),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.texts = new Array(this.capacity)
|
||||
this.levels = new Uint8Array(this.capacity)
|
||||
this.head = 0
|
||||
this._size = 0
|
||||
}
|
||||
}
|
||||
|
||||
function mapLog4jLevel(level?: string): LogLevel | null {
|
||||
if (!level) return null
|
||||
switch (level.toUpperCase()) {
|
||||
case 'FATAL':
|
||||
case 'ERROR':
|
||||
return 'error'
|
||||
case 'WARN':
|
||||
return 'warn'
|
||||
case 'INFO':
|
||||
return 'info'
|
||||
case 'DEBUG':
|
||||
return 'debug'
|
||||
case 'TRACE':
|
||||
return 'trace'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(millis?: number): string {
|
||||
if (!millis) return ''
|
||||
const date = new Date(millis)
|
||||
const h = String(date.getHours()).padStart(2, '0')
|
||||
const m = String(date.getMinutes()).padStart(2, '0')
|
||||
const s = String(date.getSeconds()).padStart(2, '0')
|
||||
return `[${h}:${m}:${s}]`
|
||||
}
|
||||
|
||||
function formatLog4jLines(event: Log4jEvent): LogLine[] {
|
||||
const level = mapLog4jLevel(event.level)
|
||||
const time = formatTimestamp(event.timestamp_millis)
|
||||
const thread = event.thread_name ?? ''
|
||||
const levelStr = event.level ?? ''
|
||||
const message = event.message?.trim() ?? ''
|
||||
|
||||
const prefix = time ? `${time} [${thread}/${levelStr}]: ` : `[${thread}/${levelStr}]: `
|
||||
const messageLines = message.split(/\r?\n/)
|
||||
const lines: LogLine[] = [{ text: prefix + messageLines[0], level }]
|
||||
for (let i = 1; i < messageLines.length; i++) {
|
||||
if (!messageLines[i].trim()) continue
|
||||
lines.push({ text: messageLines[i], level })
|
||||
}
|
||||
|
||||
if (event.throwable) {
|
||||
for (const line of event.throwable.split(/\r?\n/)) {
|
||||
if (!line.trim()) continue
|
||||
lines.push({ text: line, level: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function textToLogLine(text: string): LogLine {
|
||||
return { text, level: detectLogLevel(text) }
|
||||
}
|
||||
|
||||
export function createConsoleState() {
|
||||
const archive = new ColumnarRingBuffer(ARCHIVE_CAPACITY)
|
||||
const output: Ref<LogLine[]> = shallowRef<LogLine[]>([])
|
||||
|
||||
let lineBuffer: LogLine[] = []
|
||||
let batchTimer: NodeJS.Timeout | null = null
|
||||
|
||||
let wrapCount = 0
|
||||
let lastFlushMs = 0
|
||||
|
||||
const flushBuffer = (): void => {
|
||||
if (lineBuffer.length === 0) return
|
||||
|
||||
const t0 = DEBUG_PERF ? performance.now() : 0
|
||||
const arr = output.value
|
||||
const lines = groupContinuations(lineBuffer)
|
||||
const flushedCount = lines.length
|
||||
let didWrap = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (archive.push(line.text, line.level)) didWrap = true
|
||||
arr.push(line)
|
||||
}
|
||||
|
||||
if (didWrap) {
|
||||
const evictedCount = Math.max(0, arr.length - archive.size)
|
||||
if (evictedCount > 0) {
|
||||
arr.splice(0, evictedCount)
|
||||
}
|
||||
wrapCount++
|
||||
}
|
||||
|
||||
lineBuffer = []
|
||||
batchTimer = null
|
||||
triggerRef(output)
|
||||
|
||||
if (DEBUG_PERF) {
|
||||
lastFlushMs = performance.now() - t0
|
||||
if (arr.length !== archive.size) {
|
||||
console.error(
|
||||
`[mr-console] drift: output.length=${arr.length} !== archive.size=${archive.size}`,
|
||||
)
|
||||
}
|
||||
console.debug(
|
||||
`[mr-console] flush: ${flushedCount} lines in ${lastFlushMs.toFixed(2)}ms` +
|
||||
` | buffer: ${archive.size} | wrap: ${didWrap}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const addLines = (lines: LogLine[]): void => {
|
||||
if (output.value.length === 0 && lines.length >= initialBatchSize) {
|
||||
lineBuffer = lines
|
||||
flushBuffer()
|
||||
return
|
||||
}
|
||||
|
||||
lineBuffer.push(...lines)
|
||||
|
||||
if (!batchTimer) {
|
||||
batchTimer = setTimeout(flushBuffer, batchTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
const addLog4jEvent = (event: Log4jEvent): void => {
|
||||
addLines(formatLog4jLines(event))
|
||||
}
|
||||
|
||||
const addLegacyLog = (message: string): void => {
|
||||
const logLines = message
|
||||
.split('\n')
|
||||
.filter((l) => l.trim())
|
||||
.map(textToLogLine)
|
||||
|
||||
let parentLevel: LogLevel | null = null
|
||||
for (const line of logLines) {
|
||||
if (ENTRY_START_RE.test(line.text)) {
|
||||
parentLevel = line.level
|
||||
} else if (line.level === null && parentLevel !== null) {
|
||||
line.level = parentLevel
|
||||
}
|
||||
}
|
||||
|
||||
addLines(logLines)
|
||||
}
|
||||
|
||||
const clear = (): void => {
|
||||
const t0 = DEBUG_PERF ? performance.now() : 0
|
||||
archive.clear()
|
||||
output.value = []
|
||||
lineBuffer = []
|
||||
wrapCount = 0
|
||||
if (batchTimer) {
|
||||
clearTimeout(batchTimer)
|
||||
batchTimer = null
|
||||
}
|
||||
if (DEBUG_PERF) {
|
||||
console.debug(`[mr-console] clear in ${(performance.now() - t0).toFixed(2)}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
const __debugStats = ():
|
||||
| { enabled: false }
|
||||
| {
|
||||
enabled: true
|
||||
bufferSize: number
|
||||
heapEstimate: number
|
||||
recentFlushMs: number
|
||||
wrapCount: number
|
||||
} => {
|
||||
if (!DEBUG_PERF) return { enabled: false }
|
||||
const heapEstimate =
|
||||
archive.texts.reduce<number>((a, s) => a + (s?.length ?? 0) * 2, 0) +
|
||||
archive.levels.byteLength
|
||||
return {
|
||||
enabled: true,
|
||||
bufferSize: archive.size,
|
||||
heapEstimate,
|
||||
recentFlushMs: lastFlushMs,
|
||||
wrapCount,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
output,
|
||||
addLines,
|
||||
addLog4jEvent,
|
||||
addLegacyLog,
|
||||
clear,
|
||||
__debugStats,
|
||||
}
|
||||
}
|
||||
|
||||
export const useModrinthServersConsole = createGlobalState(createConsoleState)
|
||||
443
packages/ui/src/composables/server-manage-core-runtime.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import {
|
||||
type Archon,
|
||||
clearNodeAuthState,
|
||||
setNodeAuthState,
|
||||
type UploadState,
|
||||
} from '@modrinth/api-client'
|
||||
import type { Stats } from '@modrinth/utils'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
|
||||
import type { FileOperation } from '../layouts/shared/files-tab/types'
|
||||
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
|
||||
import type { BusyReason } from '../providers/server-context'
|
||||
import { defineMessage } from './i18n'
|
||||
import { useModrinthServersConsole } from './server-console'
|
||||
|
||||
type ReadableRef<T> = Ref<T> | ComputedRef<T>
|
||||
type SocketUnsubscriber = () => void
|
||||
|
||||
type ConnectSocketOptions = {
|
||||
force?: boolean
|
||||
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
|
||||
}
|
||||
|
||||
type UseServerManageCoreRuntimeOptions = {
|
||||
serverId: ReadableRef<string>
|
||||
worldId: ReadableRef<string | null>
|
||||
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
|
||||
isSyncingContent: ReadableRef<boolean>
|
||||
markBackupCancelled?: (backupId: string) => void
|
||||
includeBackupBusyReasons?: boolean
|
||||
setDisconnectedOnAuthIncorrect?: boolean
|
||||
syncUptimeFromState?: boolean
|
||||
incrementUptimeLocally?: boolean
|
||||
eventGuard?: () => boolean
|
||||
onStateEvent?: (data: Archon.Websocket.v0.WSStateEvent) => void
|
||||
}
|
||||
|
||||
const createInitialStats = (): Stats => ({
|
||||
current: {
|
||||
cpu_percent: 0,
|
||||
ram_usage_bytes: 0,
|
||||
ram_total_bytes: 1,
|
||||
storage_usage_bytes: 0,
|
||||
storage_total_bytes: 0,
|
||||
},
|
||||
past: {
|
||||
cpu_percent: 0,
|
||||
ram_usage_bytes: 0,
|
||||
ram_total_bytes: 1,
|
||||
storage_usage_bytes: 0,
|
||||
storage_total_bytes: 0,
|
||||
},
|
||||
graph: {
|
||||
cpu: [],
|
||||
ram: [],
|
||||
},
|
||||
})
|
||||
|
||||
const appendGraphData = (dataArray: number[], newValue: number): number[] => {
|
||||
const updated = [...dataArray, newValue]
|
||||
if (updated.length > 10) updated.shift()
|
||||
return updated
|
||||
}
|
||||
|
||||
const mapPowerStateFromStateEvent = (
|
||||
data: Archon.Websocket.v0.WSStateEvent,
|
||||
): Archon.Websocket.v0.PowerState => {
|
||||
const powerMap: Record<Archon.Websocket.v0.FlattenedPowerState, Archon.Websocket.v0.PowerState> =
|
||||
{
|
||||
not_ready: 'stopped',
|
||||
starting: 'starting',
|
||||
running: 'running',
|
||||
stopping: 'stopping',
|
||||
idle:
|
||||
data.was_oom || (data.exit_code != null && data.exit_code !== 0) ? 'crashed' : 'stopped',
|
||||
}
|
||||
return powerMap[data.power_variant]
|
||||
}
|
||||
|
||||
export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOptions) {
|
||||
const client = injectModrinthClient()
|
||||
const modrinthServersConsole = useModrinthServersConsole()
|
||||
|
||||
const shouldProcessEvent = () => (options.eventGuard ? options.eventGuard() : true)
|
||||
|
||||
const isConnected = ref(false)
|
||||
const isWsAuthIncorrect = ref(false)
|
||||
const serverPowerState = ref<Archon.Websocket.v0.PowerState>('stopped')
|
||||
const powerStateDetails = ref<{ oom_killed?: boolean; exit_code?: number }>()
|
||||
const isServerRunning = computed(() => serverPowerState.value === 'running')
|
||||
const stats = ref<Stats>(createInitialStats())
|
||||
const uptimeSeconds = ref(0)
|
||||
const backupsState = reactive(new Map())
|
||||
const fsAuth = ref<{ url: string; token: string } | null>(null)
|
||||
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
|
||||
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
|
||||
const connectedSocketServerId = ref<string | null>(null)
|
||||
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
|
||||
const cpuData = ref<number[]>([])
|
||||
const ramData = ref<number[]>([])
|
||||
|
||||
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const markBackupCancelled =
|
||||
options.markBackupCancelled ??
|
||||
((backupId: string) => {
|
||||
backupsState.delete(backupId)
|
||||
})
|
||||
|
||||
const busyReasons = computed<BusyReason[]>(() => {
|
||||
const reasons: BusyReason[] = []
|
||||
if (options.server.value?.status === 'installing') {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.installing',
|
||||
defaultMessage: 'Server is installing',
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (options.isSyncingContent.value) {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.syncing-content',
|
||||
defaultMessage: 'Content sync in progress',
|
||||
}),
|
||||
})
|
||||
}
|
||||
if (options.includeBackupBusyReasons) {
|
||||
for (const entry of backupsState.values()) {
|
||||
if (entry.create?.state === 'ongoing') {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.backup-creating',
|
||||
defaultMessage: 'Backup creation in progress',
|
||||
}),
|
||||
})
|
||||
break
|
||||
}
|
||||
if (entry.restore?.state === 'ongoing') {
|
||||
reasons.push({
|
||||
reason: defineMessage({
|
||||
id: 'servers.busy.backup-restoring',
|
||||
defaultMessage: 'Backup restore in progress',
|
||||
}),
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return reasons
|
||||
})
|
||||
|
||||
const stopUptimeTicker = () => {
|
||||
if (uptimeIntervalId) {
|
||||
clearInterval(uptimeIntervalId)
|
||||
uptimeIntervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
const startUptimeTicker = () => {
|
||||
if (!options.incrementUptimeLocally || uptimeIntervalId) return
|
||||
uptimeIntervalId = setInterval(() => {
|
||||
uptimeSeconds.value += 1
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const updateStats = (currentStats: Stats['current']) => {
|
||||
if (!shouldProcessEvent()) return
|
||||
if (!isConnected.value) isConnected.value = true
|
||||
cpuData.value = appendGraphData(cpuData.value, currentStats.cpu_percent)
|
||||
ramData.value = appendGraphData(
|
||||
ramData.value,
|
||||
Math.floor((currentStats.ram_usage_bytes / currentStats.ram_total_bytes) * 100),
|
||||
)
|
||||
stats.value = {
|
||||
current: currentStats,
|
||||
past: { ...stats.value.current },
|
||||
graph: {
|
||||
cpu: cpuData.value,
|
||||
ram: ramData.value,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const updatePowerState = (
|
||||
state: Archon.Websocket.v0.PowerState,
|
||||
details?: { oom_killed?: boolean; exit_code?: number },
|
||||
) => {
|
||||
if (!shouldProcessEvent()) return
|
||||
serverPowerState.value = state
|
||||
powerStateDetails.value = state === 'crashed' ? details : undefined
|
||||
if (state === 'stopped' || state === 'crashed') {
|
||||
stopUptimeTicker()
|
||||
uptimeSeconds.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const handleLog = (data: Archon.Websocket.v0.WSLogEvent) => {
|
||||
if (!shouldProcessEvent()) return
|
||||
modrinthServersConsole.addLegacyLog(data.message)
|
||||
}
|
||||
|
||||
const handleLog4j = (data: Archon.Websocket.v0.WSLog4jEvent) => {
|
||||
if (!shouldProcessEvent()) return
|
||||
modrinthServersConsole.addLog4jEvent(data)
|
||||
}
|
||||
|
||||
const handleStats = (data: Archon.Websocket.v0.WSStatsEvent) => {
|
||||
updateStats({
|
||||
cpu_percent: data.cpu_percent,
|
||||
ram_usage_bytes: data.ram_usage_bytes,
|
||||
ram_total_bytes: data.ram_total_bytes,
|
||||
storage_usage_bytes: data.storage_usage_bytes,
|
||||
storage_total_bytes: data.storage_total_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
const handlePowerState = (data: Archon.Websocket.v0.WSPowerStateEvent) => {
|
||||
if (data.state === 'crashed') {
|
||||
updatePowerState(data.state, {
|
||||
oom_killed: data.oom_killed,
|
||||
exit_code: data.exit_code,
|
||||
})
|
||||
} else {
|
||||
updatePowerState(data.state)
|
||||
}
|
||||
}
|
||||
|
||||
const handleState = (data: Archon.Websocket.v0.WSStateEvent) => {
|
||||
if (!shouldProcessEvent()) return
|
||||
options.onStateEvent?.(data)
|
||||
updatePowerState(mapPowerStateFromStateEvent(data), {
|
||||
exit_code: data.exit_code ?? undefined,
|
||||
oom_killed: data.was_oom,
|
||||
})
|
||||
|
||||
if (options.syncUptimeFromState && data.uptime > 0) {
|
||||
stopUptimeTicker()
|
||||
uptimeSeconds.value = data.uptime
|
||||
startUptimeTicker()
|
||||
}
|
||||
}
|
||||
|
||||
const handleUptime = (data: Archon.Websocket.v0.WSUptimeEvent) => {
|
||||
if (!shouldProcessEvent()) return
|
||||
stopUptimeTicker()
|
||||
uptimeSeconds.value = data.uptime
|
||||
startUptimeTicker()
|
||||
}
|
||||
|
||||
const handleAuthIncorrect = () => {
|
||||
if (!shouldProcessEvent()) return
|
||||
isWsAuthIncorrect.value = true
|
||||
if (options.setDisconnectedOnAuthIncorrect) {
|
||||
isConnected.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleAuthOk = () => {
|
||||
if (!shouldProcessEvent()) return
|
||||
isWsAuthIncorrect.value = false
|
||||
isConnected.value = true
|
||||
}
|
||||
|
||||
const clearSocketListeners = () => {
|
||||
for (const unsub of socketUnsubscribers.value) unsub()
|
||||
socketUnsubscribers.value = []
|
||||
}
|
||||
|
||||
const disconnectSocket = (targetServerId?: string) => {
|
||||
if (!targetServerId && !connectedSocketServerId.value) return
|
||||
|
||||
clearSocketListeners()
|
||||
|
||||
if (targetServerId) {
|
||||
client.archon.sockets.disconnect(targetServerId)
|
||||
}
|
||||
|
||||
stopUptimeTicker()
|
||||
connectedSocketServerId.value = null
|
||||
isConnected.value = false
|
||||
isWsAuthIncorrect.value = false
|
||||
serverPowerState.value = 'stopped'
|
||||
powerStateDetails.value = undefined
|
||||
uptimeSeconds.value = 0
|
||||
}
|
||||
|
||||
const connectSocket = async (
|
||||
targetServerId: string,
|
||||
connectOptions: ConnectSocketOptions = {},
|
||||
): Promise<boolean> => {
|
||||
if (
|
||||
connectedSocketServerId.value === targetServerId &&
|
||||
(isConnected.value || isWsAuthIncorrect.value)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
disconnectSocket(connectedSocketServerId.value ?? undefined)
|
||||
|
||||
try {
|
||||
const safeConnectOptions = connectOptions.force ? { force: true } : undefined
|
||||
await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions)
|
||||
connectedSocketServerId.value = targetServerId
|
||||
isConnected.value = true
|
||||
isWsAuthIncorrect.value = false
|
||||
|
||||
modrinthServersConsole.clear()
|
||||
|
||||
const baseSubscriptions: SocketUnsubscriber[] = [
|
||||
client.archon.sockets.on(targetServerId, 'log', handleLog),
|
||||
client.archon.sockets.on(targetServerId, 'log4j', handleLog4j),
|
||||
client.archon.sockets.on(targetServerId, 'stats', handleStats),
|
||||
client.archon.sockets.on(targetServerId, 'state', handleState),
|
||||
client.archon.sockets.on(targetServerId, 'power-state', handlePowerState),
|
||||
client.archon.sockets.on(targetServerId, 'uptime', handleUptime),
|
||||
client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect),
|
||||
client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk),
|
||||
]
|
||||
const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? []
|
||||
socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions]
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[hosting/manage] Failed to connect server socket:', error)
|
||||
isConnected.value = false
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const uploadState = ref<UploadState>({
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
})
|
||||
const cancelUpload = ref<(() => void) | null>(null)
|
||||
|
||||
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
|
||||
const dismissedOpIds = ref<Set<string>>(new Set())
|
||||
|
||||
const activeOperations = computed<FileOperation[]>(() => [
|
||||
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
||||
...(fsOps.value.filter((op) => !op.id || !dismissedOpIds.value.has(op.id)) as FileOperation[]),
|
||||
])
|
||||
|
||||
async function dismissOperation(opId: string, action: 'dismiss' | 'cancel') {
|
||||
if (action === 'dismiss') {
|
||||
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
|
||||
}
|
||||
try {
|
||||
await client.kyros.files_v0.modifyOperation(opId, action)
|
||||
} catch (error) {
|
||||
if (action === 'dismiss') return
|
||||
console.error(`Failed to ${action} operation:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => fsOps.value,
|
||||
(newOps) => {
|
||||
for (const op of newOps) {
|
||||
if (op.state === 'done' && op.id && !dismissedOpIds.value.has(op.id)) {
|
||||
setTimeout(() => {
|
||||
dismissOperation(op.id!, 'dismiss')
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const refreshFsAuth = async () => {
|
||||
if (!options.serverId.value) {
|
||||
fsAuth.value = null
|
||||
return
|
||||
}
|
||||
fsAuth.value = await client.archon.servers_v0.getFilesystemAuth(options.serverId.value)
|
||||
}
|
||||
|
||||
provideModrinthServerContext({
|
||||
get serverId() {
|
||||
return options.serverId.value
|
||||
},
|
||||
worldId: options.worldId as Ref<string | null>,
|
||||
server: options.server as Ref<Archon.Servers.v0.Server>,
|
||||
isConnected,
|
||||
isWsAuthIncorrect,
|
||||
powerState: serverPowerState,
|
||||
powerStateDetails,
|
||||
isServerRunning,
|
||||
stats,
|
||||
uptimeSeconds,
|
||||
backupsState,
|
||||
markBackupCancelled,
|
||||
isSyncingContent: options.isSyncingContent as Ref<boolean>,
|
||||
busyReasons,
|
||||
fsAuth,
|
||||
fsOps,
|
||||
fsQueuedOps,
|
||||
refreshFsAuth,
|
||||
uploadState,
|
||||
cancelUpload,
|
||||
activeOperations,
|
||||
dismissOperation,
|
||||
})
|
||||
|
||||
setNodeAuthState(() => fsAuth.value, refreshFsAuth)
|
||||
|
||||
const cleanupCoreRuntime = (targetServerId?: string) => {
|
||||
disconnectSocket(targetServerId ?? connectedSocketServerId.value ?? undefined)
|
||||
clearNodeAuthState()
|
||||
}
|
||||
|
||||
return {
|
||||
activeOperations,
|
||||
backupsState,
|
||||
busyReasons,
|
||||
cancelUpload,
|
||||
cleanupCoreRuntime,
|
||||
connectSocket,
|
||||
connectedSocketServerId,
|
||||
cpuData,
|
||||
disconnectSocket,
|
||||
dismissOperation,
|
||||
fsAuth,
|
||||
fsOps,
|
||||
fsQueuedOps,
|
||||
isConnected,
|
||||
isServerRunning,
|
||||
isWsAuthIncorrect,
|
||||
powerStateDetails,
|
||||
ramData,
|
||||
refreshFsAuth,
|
||||
serverPowerState,
|
||||
stats,
|
||||
uptimeSeconds,
|
||||
uploadState,
|
||||
}
|
||||
}
|
||||
@@ -242,10 +242,18 @@ export const useStripe = (
|
||||
if (confirmation) {
|
||||
confirmationToken.value = id
|
||||
if (result && 'payment_method' in result && result.payment_method) {
|
||||
// payment_method is a string ID from the API, need to find the full object
|
||||
const method = paymentMethods.find((x) => x.id === result.payment_method)
|
||||
if (method) {
|
||||
inputtedPaymentMethod.value = method
|
||||
const paymentMethod = (
|
||||
result as {
|
||||
payment_method?: string | Stripe.PaymentMethod
|
||||
}
|
||||
).payment_method
|
||||
if (typeof paymentMethod === 'string') {
|
||||
const method = paymentMethods.find((x) => x.id === paymentMethod)
|
||||
if (method) {
|
||||
inputtedPaymentMethod.value = method
|
||||
}
|
||||
} else if (paymentMethod) {
|
||||
inputtedPaymentMethod.value = paymentMethod
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,31 +338,42 @@ export const useStripe = (
|
||||
|
||||
const loadingElements = computed(() => elementsLoaded.value < 2)
|
||||
|
||||
async function submitPayment(returnUrl: string) {
|
||||
async function submitPayment(returnUrl?: string): Promise<boolean> {
|
||||
if (noPaymentRequired.value) {
|
||||
completingPurchase.value = false
|
||||
return true
|
||||
}
|
||||
completingPurchase.value = true
|
||||
const secert = clientSecret.value
|
||||
const secret = clientSecret.value
|
||||
|
||||
if (!secert) {
|
||||
return handlePaymentError('No client secret')
|
||||
if (!secret) {
|
||||
handlePaymentError('No client secret')
|
||||
return false
|
||||
}
|
||||
|
||||
if (!stripe.value) {
|
||||
return handlePaymentError('No stripe')
|
||||
handlePaymentError('No stripe')
|
||||
return false
|
||||
}
|
||||
|
||||
submittingPayment.value = true
|
||||
const productPrice = product.value?.prices.find((x) => x.currency_code === currency)
|
||||
const { error } = await stripe.value.confirmPayment({
|
||||
clientSecret: secert,
|
||||
confirmParams: {
|
||||
confirmation_token: confirmationToken.value,
|
||||
return_url: `${returnUrl}?priceId=${productPrice?.id}&plan=${interval.value}`,
|
||||
},
|
||||
})
|
||||
|
||||
const { error } = returnUrl
|
||||
? await stripe.value.confirmPayment({
|
||||
clientSecret: secret,
|
||||
confirmParams: {
|
||||
confirmation_token: confirmationToken.value,
|
||||
return_url: `${returnUrl}?priceId=${productPrice?.id}&plan=${interval.value}`,
|
||||
},
|
||||
})
|
||||
: await stripe.value.confirmPayment({
|
||||
clientSecret: secret,
|
||||
redirect: 'if_required',
|
||||
confirmParams: {
|
||||
confirmation_token: confirmationToken.value,
|
||||
},
|
||||
})
|
||||
|
||||
if (error) {
|
||||
handlePaymentError(error.message ?? 'Unknown error submitting payment')
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
shallowRef,
|
||||
} from 'vue'
|
||||
|
||||
function getCssVar(name: string, fallback: string): string {
|
||||
export function getCssVar(name: string, fallback: string): string {
|
||||
if (typeof document === 'undefined') return fallback
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
|
||||
return value || fallback
|
||||
@@ -54,6 +54,7 @@ function buildTerminalTheme() {
|
||||
scrollbarSliderBackground: surface5,
|
||||
scrollbarSliderHoverBackground: surface5,
|
||||
scrollbarSliderActiveBackground: surface5,
|
||||
overviewRulerBorder: 'transparent',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +63,7 @@ export interface UseTerminalOptions {
|
||||
options?: ITerminalOptions
|
||||
scrollback?: number
|
||||
onReady?: (terminal: Terminal) => void
|
||||
onResize?: () => void
|
||||
}
|
||||
|
||||
export interface UseTerminalReturn {
|
||||
@@ -85,6 +87,7 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
let wheelHandler: ((e: WheelEvent) => void) | null = null
|
||||
let hasWritten = false
|
||||
const pendingWrites: Array<{ data: string; newline: boolean }> = []
|
||||
|
||||
@@ -126,7 +129,7 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
if (!fa || !term) return
|
||||
const dims = fa.proposeDimensions()
|
||||
if (dims) {
|
||||
term.resize(dims.cols, dims.rows + 1)
|
||||
term.resize(dims.cols, dims.rows)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,12 +167,13 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
|
||||
const term = new Terminal({
|
||||
disableStdin: true,
|
||||
scrollback: options.scrollback ?? 10000,
|
||||
scrollback: options.scrollback ?? Infinity,
|
||||
convertEol: true,
|
||||
smoothScrollDuration: 125,
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 14,
|
||||
lineHeight: 1.5,
|
||||
allowProposedApi: true,
|
||||
theme: buildTerminalTheme(),
|
||||
...options.options,
|
||||
})
|
||||
@@ -183,12 +187,17 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
await nextTick()
|
||||
const dims = fit.proposeDimensions()
|
||||
if (dims) {
|
||||
term.resize(dims.cols, dims.rows + 1)
|
||||
term.resize(dims.cols, dims.rows)
|
||||
}
|
||||
|
||||
term.options.disableStdin = true
|
||||
term.write('\x1b[?25l')
|
||||
|
||||
wheelHandler = (e: WheelEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
container.addEventListener('wheel', wheelHandler, { passive: false })
|
||||
|
||||
term.onScroll(() => checkIfAtBottom())
|
||||
term.onWriteParsed(() => {
|
||||
if (isAtBottom.value) {
|
||||
@@ -212,8 +221,9 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
const d = fit.proposeDimensions()
|
||||
if (d) {
|
||||
term.resize(d.cols, d.rows + 1)
|
||||
term.resize(d.cols, d.rows)
|
||||
}
|
||||
options.onResize?.()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
|
||||
@@ -229,6 +239,10 @@ export function useTerminal(options: UseTerminalOptions): UseTerminalReturn {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (wheelHandler && options.container.value) {
|
||||
options.container.value.removeEventListener('wheel', wheelHandler)
|
||||
wheelHandler = null
|
||||
}
|
||||
resizeObserver?.disconnect()
|
||||
resizeObserver = null
|
||||
themeObserver?.disconnect()
|
||||
|
||||
134
packages/ui/src/composables/use-server-image.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef, ref } from 'vue'
|
||||
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
|
||||
type UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
|
||||
|
||||
type UseServerImageOptions = {
|
||||
enabled?: ComputedRef<boolean> | boolean
|
||||
size?: number
|
||||
includeProjectFallback?: boolean
|
||||
}
|
||||
|
||||
export async function processImageBlob(blob: Blob, size: number): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const canvas = document.createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')!
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
canvas.width = size
|
||||
canvas.height = size
|
||||
ctx.drawImage(img, 0, 0, size, size)
|
||||
const dataURL = canvas.toDataURL('image/png')
|
||||
URL.revokeObjectURL(img.src)
|
||||
resolve(dataURL)
|
||||
}
|
||||
img.src = URL.createObjectURL(blob)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
export function useServerImage(
|
||||
serverId: string,
|
||||
upstream: UpstreamRef,
|
||||
options: UseServerImageOptions = {},
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
const localImage = ref<string | null | undefined>(undefined)
|
||||
const iconSize = options.size ?? 512
|
||||
const includeProjectFallback = options.includeProjectFallback ?? false
|
||||
|
||||
const queryKey = computed(
|
||||
() => ['servers', 'detail', serverId, 'icon', upstream.value?.project_id ?? null] as const,
|
||||
)
|
||||
|
||||
const isEnabled = computed(() => {
|
||||
const explicitEnabled =
|
||||
typeof options.enabled === 'boolean' ? options.enabled : options.enabled?.value
|
||||
return !!serverId && (explicitEnabled ?? true)
|
||||
})
|
||||
|
||||
const { data: remoteImage, refetch } = useQuery({
|
||||
queryKey,
|
||||
queryFn: async (): Promise<string | null | undefined> => {
|
||||
if (!serverId) return undefined
|
||||
|
||||
try {
|
||||
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
||||
|
||||
try {
|
||||
const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
|
||||
return await processImageBlob(blob, iconSize)
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error
|
||||
}
|
||||
|
||||
try {
|
||||
const blob = await client.kyros.files_v0.downloadFileWithAuth(
|
||||
fsAuth,
|
||||
'/server-icon-original.png',
|
||||
)
|
||||
return await processImageBlob(blob, iconSize)
|
||||
} catch (error) {
|
||||
if (!isNotFound(error)) throw error
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Server image fetch failed:', error)
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (!includeProjectFallback || !upstream.value?.project_id) return undefined
|
||||
|
||||
try {
|
||||
const project = await client.labrinth.projects_v2.get(upstream.value.project_id)
|
||||
if (!project.icon_url) return undefined
|
||||
const response = await fetch(project.icon_url)
|
||||
if (!response.ok) return undefined
|
||||
const blob = await response.blob()
|
||||
return await processImageBlob(blob, iconSize)
|
||||
} catch (error) {
|
||||
console.debug('Project icon fallback failed:', error)
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
enabled: isEnabled,
|
||||
})
|
||||
|
||||
const image = computed(() => {
|
||||
if (localImage.value === null) return undefined
|
||||
const remote = remoteImage.value
|
||||
if (remote === null) return undefined
|
||||
return localImage.value ?? remote
|
||||
})
|
||||
|
||||
function setImage(nextImage: string | null | undefined) {
|
||||
localImage.value = nextImage
|
||||
}
|
||||
|
||||
function clearImage() {
|
||||
localImage.value = null
|
||||
}
|
||||
|
||||
function resetLocalOverride() {
|
||||
localImage.value = undefined
|
||||
}
|
||||
|
||||
return {
|
||||
image,
|
||||
queryKey,
|
||||
refetch,
|
||||
setImage,
|
||||
clearImage,
|
||||
resetLocalOverride,
|
||||
}
|
||||
}
|
||||
18
packages/ui/src/composables/use-server-project.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, type ComputedRef } from 'vue'
|
||||
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
|
||||
// TODO: Remove and use v1
|
||||
export function useServerProject(
|
||||
upstream: ComputedRef<Archon.Servers.v0.Server['upstream'] | null>,
|
||||
) {
|
||||
const client = injectModrinthClient()
|
||||
|
||||
return useQuery({
|
||||
queryKey: computed(() => ['servers', 'project', upstream.value?.project_id ?? null]),
|
||||
queryFn: () => client.labrinth.projects_v2.get(upstream.value!.project_id!),
|
||||
enabled: computed(() => !!upstream.value?.project_id),
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
export * from './shared/browse-tab'
|
||||
export * from './shared/console'
|
||||
export * from './shared/content-tab'
|
||||
export * from './shared/files-tab'
|
||||
export * from './shared/installation-settings'
|
||||
export * from './shared/server-settings'
|
||||
export * from './wrapped'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './use-browse-search'
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
|
||||
import { useSearch } from '#ui/utils/search'
|
||||
import { useServerSearch } from '#ui/utils/server-search'
|
||||
|
||||
import type { BrowseSearchResponse } from '../types'
|
||||
|
||||
export interface UseBrowseSearchOptions {
|
||||
projectType: Ref<string>
|
||||
tags: Ref<{
|
||||
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||
loaders: Labrinth.Tags.v2.Loader[]
|
||||
categories: Labrinth.Tags.v2.Category[]
|
||||
}>
|
||||
providedFilters?: ComputedRef<FilterValue[]>
|
||||
search: (params: string) => Promise<BrowseSearchResponse>
|
||||
persistentQueryParams: string[]
|
||||
getExtraQueryParams?: () => Record<string, string | undefined>
|
||||
maxResultsOptions?: ComputedRef<number[]>
|
||||
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
|
||||
}
|
||||
|
||||
export interface BrowseSearchState {
|
||||
query: Ref<string>
|
||||
|
||||
filters: ComputedRef<FilterType[]>
|
||||
currentFilters: Ref<FilterValue[]>
|
||||
toggledGroups: Ref<string[]>
|
||||
overriddenProvidedFilterTypes: Ref<string[]>
|
||||
|
||||
serverFilterTypes: ComputedRef<FilterType[]>
|
||||
serverCurrentFilters: Ref<FilterValue[]>
|
||||
serverToggledGroups: Ref<string[]>
|
||||
|
||||
effectiveSortTypes: ComputedRef<readonly SortType[]>
|
||||
effectiveCurrentSortType: Ref<SortType>
|
||||
|
||||
loading: Ref<boolean>
|
||||
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
|
||||
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
|
||||
totalHits: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
|
||||
maxResults: Ref<number>
|
||||
currentPage: Ref<number>
|
||||
|
||||
isServerType: ComputedRef<boolean>
|
||||
effectiveLayout: ComputedRef<'list' | 'grid'>
|
||||
deprioritizedTags: ComputedRef<string[]>
|
||||
excludeLoaders: ComputedRef<boolean>
|
||||
|
||||
refreshSearch: () => Promise<void>
|
||||
setPage: (page: number) => Promise<void>
|
||||
clearSearch: () => void
|
||||
onFilterChange: () => void
|
||||
}
|
||||
|
||||
const LOADER_FILTER_TYPES = [
|
||||
'mod_loader',
|
||||
'plugin_loader',
|
||||
'modpack_loader',
|
||||
'shader_loader',
|
||||
'plugin_platform',
|
||||
] as const
|
||||
|
||||
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
|
||||
const debug = useDebugLogger('BrowseSearch')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
debug('init, projectType:', options.projectType.value)
|
||||
|
||||
const projectTypes = computed(() => [options.projectType.value] as ProjectType[])
|
||||
const isServerType = computed(() => options.projectType.value === 'server')
|
||||
|
||||
const {
|
||||
query,
|
||||
currentSortType,
|
||||
currentFilters,
|
||||
toggledGroups,
|
||||
maxResults,
|
||||
currentPage,
|
||||
overriddenProvidedFilterTypes,
|
||||
filters,
|
||||
sortTypes,
|
||||
requestParams,
|
||||
createPageParams,
|
||||
} = useSearch(projectTypes, options.tags, options.providedFilters ?? computed(() => []))
|
||||
|
||||
const {
|
||||
serverCurrentSortType,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
serverSortTypes,
|
||||
serverFilterTypes,
|
||||
serverRequestParams,
|
||||
createServerPageParams,
|
||||
} = useServerSearch({ tags: options.tags, query, maxResults, currentPage })
|
||||
|
||||
const effectiveRequestParams = computed(() =>
|
||||
isServerType.value ? serverRequestParams.value : requestParams.value,
|
||||
)
|
||||
const effectiveSortTypes = computed(() =>
|
||||
isServerType.value ? (serverSortTypes as readonly SortType[]) : sortTypes,
|
||||
)
|
||||
const effectiveCurrentSortType = computed({
|
||||
get: () => (isServerType.value ? serverCurrentSortType.value : currentSortType.value),
|
||||
set: (v: SortType) => {
|
||||
if (isServerType.value) serverCurrentSortType.value = v
|
||||
else currentSortType.value = v
|
||||
},
|
||||
})
|
||||
|
||||
const effectiveMaxResultsOptions = computed(
|
||||
() => options.maxResultsOptions?.value ?? [5, 10, 15, 20, 50, 100],
|
||||
)
|
||||
|
||||
watch(effectiveMaxResultsOptions, (opts) => {
|
||||
if (!opts.includes(maxResults.value)) {
|
||||
maxResults.value = opts.reduce((prev, curr) =>
|
||||
Math.abs(curr - maxResults.value) <= Math.abs(prev - maxResults.value) ? curr : prev,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const effectiveDisplayMode = computed(() => options.displayMode?.value ?? 'list')
|
||||
const effectiveLayout = computed<'list' | 'grid'>(() =>
|
||||
effectiveDisplayMode.value === 'grid' || effectiveDisplayMode.value === 'gallery'
|
||||
? 'grid'
|
||||
: 'list',
|
||||
)
|
||||
|
||||
const selectedFilterTags = computed(() =>
|
||||
currentFilters.value
|
||||
.filter(
|
||||
(f) =>
|
||||
f.type.startsWith('category_') ||
|
||||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
|
||||
)
|
||||
.map((f) => f.option),
|
||||
)
|
||||
const excludeLoaders = computed(
|
||||
() =>
|
||||
currentFilters.value.some((f) =>
|
||||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
|
||||
) || ['resourcepack', 'datapack'].includes(options.projectType.value),
|
||||
)
|
||||
const loadersNotForThisType = computed(
|
||||
() =>
|
||||
options.tags.value?.loaders
|
||||
?.filter((loader) => !loader.supported_project_types.includes(options.projectType.value))
|
||||
?.map((loader) => loader.name) ?? [],
|
||||
)
|
||||
const deprioritizedTags = computed(() => [
|
||||
...selectedFilterTags.value,
|
||||
...loadersNotForThisType.value,
|
||||
])
|
||||
|
||||
const loading = ref(true)
|
||||
const projectHits = shallowRef<BrowseSearchResponse['projectHits']>([])
|
||||
const serverHits = shallowRef<BrowseSearchResponse['serverHits']>([])
|
||||
const totalHits = ref(0)
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (totalHits.value === 0) return 1
|
||||
return Math.ceil(totalHits.value / maxResults.value)
|
||||
})
|
||||
|
||||
let searchVersion = 0
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(effectiveRequestParams, (newVal, oldVal) => {
|
||||
debug('effectiveRequestParams changed', {
|
||||
from: oldVal?.substring(0, 80),
|
||||
to: newVal?.substring(0, 80),
|
||||
})
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
refreshSearch()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
async function refreshSearch() {
|
||||
const version = ++searchVersion
|
||||
debug('refreshSearch start', {
|
||||
version,
|
||||
projectType: options.projectType.value,
|
||||
params: effectiveRequestParams.value.substring(0, 100),
|
||||
})
|
||||
|
||||
const currentHitsEmpty = isServerType.value
|
||||
? serverHits.value.length === 0
|
||||
: projectHits.value.length === 0
|
||||
if (currentHitsEmpty) {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await options.search(effectiveRequestParams.value)
|
||||
|
||||
if (version !== searchVersion) {
|
||||
debug('refreshSearch stale, discarding', { version, current: searchVersion })
|
||||
return
|
||||
}
|
||||
|
||||
if (isServerType.value) {
|
||||
serverHits.value = response.serverHits
|
||||
} else {
|
||||
projectHits.value = response.projectHits
|
||||
}
|
||||
totalHits.value = response.total_hits
|
||||
debug('refreshSearch complete', {
|
||||
version,
|
||||
hits: response.total_hits,
|
||||
projectHits: response.projectHits.length,
|
||||
serverHits: response.serverHits.length,
|
||||
})
|
||||
|
||||
updateUrlParams()
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
debug('refreshSearch error', err)
|
||||
console.error('Browse search error:', err)
|
||||
if (version === searchVersion) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrlParams() {
|
||||
debug('updateUrlParams', { path: route.path })
|
||||
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(route.query)) {
|
||||
if (options.persistentQueryParams.includes(key)) {
|
||||
persistentParams[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const extraParams = options.getExtraQueryParams?.() ?? {}
|
||||
for (const [key, value] of Object.entries(extraParams)) {
|
||||
if (value !== undefined) {
|
||||
persistentParams[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const params = {
|
||||
...persistentParams,
|
||||
...(isServerType.value ? createServerPageParams() : createPageParams()),
|
||||
}
|
||||
|
||||
router.replace({ path: route.path, query: params })
|
||||
}
|
||||
|
||||
async function setPage(newPageNumber: number) {
|
||||
currentPage.value = newPageNumber
|
||||
await nextTick()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
query.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
nextTick(() => window.scrollTo({ top: 0, behavior: 'smooth' }))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => options.projectType.value,
|
||||
(newType, oldType) => {
|
||||
debug('projectType changed', { from: oldType, to: newType })
|
||||
currentSortType.value = { display: 'Relevance', name: 'relevance' }
|
||||
query.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
query,
|
||||
filters,
|
||||
currentFilters,
|
||||
toggledGroups,
|
||||
overriddenProvidedFilterTypes,
|
||||
serverFilterTypes,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
effectiveSortTypes,
|
||||
effectiveCurrentSortType,
|
||||
loading,
|
||||
projectHits,
|
||||
serverHits,
|
||||
totalHits,
|
||||
pageCount,
|
||||
maxResults,
|
||||
currentPage,
|
||||
isServerType,
|
||||
effectiveLayout,
|
||||
deprioritizedTags,
|
||||
excludeLoaders,
|
||||
refreshSearch,
|
||||
setPage,
|
||||
clearSearch,
|
||||
onFilterChange,
|
||||
}
|
||||
}
|
||||
63
packages/ui/src/layouts/shared/browse-tab/header.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { GameIcon, LeftArrowIcon, MinecraftServerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import ContentPageHeader from '#ui/components/base/ContentPageHeader.vue'
|
||||
import { useServerImage } from '#ui/composables/use-server-image'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const MEDAL_ICON_URL = 'https://cdn-raw.modrinth.com/medal_icon.webp'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const router = useRouter()
|
||||
const installContext = computed(() => ctx.installContext?.value ?? null)
|
||||
|
||||
const serverId = computed(() => installContext.value?.serverId ?? '')
|
||||
const upstream = computed(() => installContext.value?.upstream ?? null)
|
||||
|
||||
const { image: fetchedIcon } = useServerImage(serverId, upstream, {
|
||||
enabled: computed(() => !!installContext.value?.serverId),
|
||||
})
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
if (installContext.value?.isMedal) return MEDAL_ICON_URL
|
||||
return fetchedIcon.value ?? installContext.value?.iconSrc ?? MinecraftServerIcon
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="installContext">
|
||||
<ContentPageHeader class="mb-2">
|
||||
<template #icon>
|
||||
<Avatar :src="iconSrc" size="64px" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ installContext.name }}
|
||||
</template>
|
||||
<template #summary>
|
||||
<span class="flex items-center gap-2 text-sm font-semibold text-secondary">
|
||||
<GameIcon class="h-5 w-5 text-secondary" />
|
||||
{{ formatLoaderLabel(installContext.loader) }} {{ installContext.gameVersion }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled>
|
||||
<button @click="router.push(installContext.backUrl)">
|
||||
<LeftArrowIcon />
|
||||
{{ installContext.backLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
<h1 class="m-0 mb-1 text-xl font-extrabold">{{ installContext.heading }}</h1>
|
||||
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
|
||||
{{ installContext.warning }}
|
||||
</Admonition>
|
||||
</template>
|
||||
</template>
|
||||
6
packages/ui/src/layouts/shared/browse-tab/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './composables'
|
||||
export { default as BrowseInstallHeader } from './header.vue'
|
||||
export { default as BrowsePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export { default as BrowseSidebar } from './sidebar.vue'
|
||||
export * from './types'
|
||||
262
packages/ui/src/layouts/shared/browse-tab/layout.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
||||
import NavTabs from '#ui/components/base/NavTabs.vue'
|
||||
import Pagination from '#ui/components/base/Pagination.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
|
||||
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
|
||||
import SearchFilterControl from '#ui/components/search/SearchFilterControl.vue'
|
||||
import type { SortType } from '#ui/utils/search'
|
||||
|
||||
import BrowseInstallHeader from './header.vue'
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
|
||||
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
|
||||
ctx.effectiveSortTypes.value.map((st) => ({
|
||||
value: st,
|
||||
label: st.display,
|
||||
})),
|
||||
)
|
||||
|
||||
const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
(ctx.maxResultsOptions?.value ?? [5, 10, 15, 20, 50, 100]).map((n) => ({
|
||||
value: n,
|
||||
label: String(n),
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
|
||||
<BrowseInstallHeader />
|
||||
</template>
|
||||
|
||||
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
|
||||
|
||||
<StyledInput
|
||||
v-model="ctx.query.value"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="`Search ${ctx.projectType.value}s...`"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
|
||||
@clear="ctx.clearSearch()"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Combobox
|
||||
:model-value="ctx.effectiveCurrentSortType.value"
|
||||
:options="sortOptions"
|
||||
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[16rem]'"
|
||||
placeholder="Sort by"
|
||||
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="font-semibold text-primary">Sort by:</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<Combobox
|
||||
:model-value="ctx.maxResults.value"
|
||||
:options="maxResultsOptions"
|
||||
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[9rem]'"
|
||||
placeholder="View"
|
||||
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="font-semibold text-primary">View:</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
|
||||
<ButtonStyled>
|
||||
<button @click="ctx.filtersMenuOpen.value = true">Filter results...</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="ctx.cycleDisplayMode" circular>
|
||||
<button @click="ctx.cycleDisplayMode!()">
|
||||
<slot name="display-mode-icon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<Pagination
|
||||
:page="ctx.currentPage.value"
|
||||
:count="ctx.pageCount.value"
|
||||
:class="ctx.variant === 'web' ? 'mx-auto sm:ml-auto sm:mr-0' : 'ml-auto'"
|
||||
@switch-page="ctx.setPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchFilterControl
|
||||
v-if="ctx.isServerType.value"
|
||||
v-model:selected-filters="ctx.serverCurrentFilters.value"
|
||||
:filters="ctx.serverFilterTypes.value"
|
||||
:provided-filters="[]"
|
||||
:overridden-provided-filter-types="[]"
|
||||
/>
|
||||
<SearchFilterControl
|
||||
v-else
|
||||
v-model:selected-filters="ctx.currentFilters.value"
|
||||
:filters="ctx.filters.value.filter((f) => f.display !== 'none')"
|
||||
:provided-filters="ctx.providedFilters?.value ?? []"
|
||||
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
|
||||
:provided-message="lockedMessages?.providedBy"
|
||||
/>
|
||||
|
||||
<div class="search">
|
||||
<section v-if="ctx.loading.value" class="offline">
|
||||
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
|
||||
</section>
|
||||
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
|
||||
You are currently offline. Connect to the internet to browse Modrinth!
|
||||
</section>
|
||||
<section
|
||||
v-else-if="
|
||||
ctx.isServerType.value
|
||||
? ctx.serverHits.value.length === 0
|
||||
: ctx.projectHits.value.length === 0
|
||||
"
|
||||
class="offline"
|
||||
>
|
||||
<p>No results found for your query!</p>
|
||||
</section>
|
||||
|
||||
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">
|
||||
<template v-if="ctx.isServerType.value">
|
||||
<ProjectCard
|
||||
v-for="result in ctx.serverHits.value"
|
||||
:key="`server-card-${result.project_id}`"
|
||||
:title="result.name"
|
||||
:icon-url="result.icon_url || undefined"
|
||||
:summary="result.summary"
|
||||
:tags="result.categories"
|
||||
:link="ctx.getServerProjectLink(result)"
|
||||
:server-online-players="result.minecraft_java_server?.ping?.data?.players_online ?? 0"
|
||||
:server-region="result.minecraft_server?.region"
|
||||
:server-recent-plays="result.minecraft_java_server?.verified_plays_2w ?? 0"
|
||||
:server-modpack-content="ctx.getServerModpackContent?.(result)"
|
||||
:server-ping="ctx.serverPings?.value?.[result.project_id]"
|
||||
:server-status-online="!!result.minecraft_java_server?.ping?.data"
|
||||
:hide-online-players-label="ctx.variant === 'app'"
|
||||
:hide-recent-plays-label="ctx.variant === 'app'"
|
||||
:layout="ctx.effectiveLayout.value"
|
||||
:max-tags="2"
|
||||
is-server-project
|
||||
exclude-loaders
|
||||
:color="result.color ?? undefined"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
|
||||
@mouseenter="ctx.onServerProjectHover?.(result)"
|
||||
@mouseleave="ctx.onProjectHoverEnd?.()"
|
||||
>
|
||||
<template v-if="ctx.getCardActions" #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
|
||||
:key="action.key"
|
||||
:color="action.color"
|
||||
:type="action.type"
|
||||
:circular="action.circular"
|
||||
>
|
||||
<button
|
||||
v-tooltip="action.tooltip"
|
||||
:disabled="action.disabled"
|
||||
@click.stop="action.onClick"
|
||||
>
|
||||
<component :is="action.icon" />
|
||||
<template v-if="!action.circular">{{ action.label }}</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ProjectCard
|
||||
v-for="result in ctx.projectHits.value"
|
||||
:key="result.project_id"
|
||||
:link="ctx.getProjectLink(result)"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
:author="{
|
||||
name: result.author,
|
||||
link:
|
||||
ctx.variant === 'web'
|
||||
? `/user/${result.author}`
|
||||
: `https://modrinth.com/user/${result.author}`,
|
||||
}"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="
|
||||
ctx.effectiveCurrentSortType.value.name === 'newest' ? 'published' : 'updated'
|
||||
"
|
||||
:downloads="result.downloads"
|
||||
:summary="result.description"
|
||||
:tags="result.display_categories"
|
||||
:all-tags="result.categories"
|
||||
:deprioritized-tags="ctx.deprioritizedTags.value"
|
||||
:exclude-loaders="ctx.excludeLoaders.value"
|
||||
:followers="result.follows"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
:color="result.color ?? undefined"
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(ctx.projectType.value)
|
||||
? {
|
||||
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
|
||||
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:layout="ctx.effectiveLayout.value"
|
||||
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
|
||||
@mouseenter="ctx.onProjectHover?.(result)"
|
||||
@mouseleave="ctx.onProjectHoverEnd?.()"
|
||||
>
|
||||
<template v-if="ctx.getCardActions" #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
|
||||
:key="action.key"
|
||||
:color="action.color"
|
||||
:type="action.type"
|
||||
:circular="action.circular"
|
||||
>
|
||||
<button
|
||||
v-tooltip="action.tooltip"
|
||||
:disabled="action.disabled"
|
||||
@click.stop="action.onClick"
|
||||
>
|
||||
<component :is="action.icon" />
|
||||
<template v-if="!action.circular">{{ action.label }}</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
</ProjectCardList>
|
||||
|
||||
<div :class="ctx.variant === 'web' ? 'pagination-after' : 'flex justify-end'">
|
||||
<Pagination
|
||||
:page="ctx.currentPage.value"
|
||||
:count="ctx.pageCount.value"
|
||||
:class="ctx.variant === 'web' ? 'justify-end' : 'pagination-after'"
|
||||
@switch-page="ctx.setPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="after" />
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Component, ComputedRef, MaybeRef, Ref, ShallowRef } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
import type { FilterType, FilterValue, SortType } from '#ui/utils/search'
|
||||
|
||||
import type {
|
||||
BrowseInstallContext,
|
||||
BrowseSearchResponse,
|
||||
CardAction,
|
||||
ServerModpackContent,
|
||||
} from '../types'
|
||||
|
||||
export interface BrowseManagerContext {
|
||||
tags: Ref<{
|
||||
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||
loaders: Labrinth.Tags.v2.Loader[]
|
||||
categories: Labrinth.Tags.v2.Category[]
|
||||
}>
|
||||
projectType: Ref<string>
|
||||
|
||||
query: Ref<string>
|
||||
filters: ComputedRef<FilterType[]>
|
||||
currentFilters: Ref<FilterValue[]>
|
||||
toggledGroups: Ref<string[]>
|
||||
overriddenProvidedFilterTypes: Ref<string[]>
|
||||
serverFilterTypes: ComputedRef<FilterType[]>
|
||||
serverCurrentFilters: Ref<FilterValue[]>
|
||||
serverToggledGroups: Ref<string[]>
|
||||
effectiveSortTypes: ComputedRef<readonly SortType[]>
|
||||
effectiveCurrentSortType: Ref<SortType>
|
||||
loading: Ref<boolean>
|
||||
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
|
||||
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
|
||||
totalHits: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
maxResults: Ref<number>
|
||||
currentPage: Ref<number>
|
||||
isServerType: ComputedRef<boolean>
|
||||
effectiveLayout: ComputedRef<'list' | 'grid'>
|
||||
deprioritizedTags: ComputedRef<string[]>
|
||||
excludeLoaders: ComputedRef<boolean>
|
||||
refreshSearch: () => Promise<void>
|
||||
setPage: (page: number) => Promise<void>
|
||||
clearSearch: () => void
|
||||
onFilterChange: () => void
|
||||
|
||||
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => string | RouteLocationRaw
|
||||
getServerProjectLink: (
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
) => string | RouteLocationRaw
|
||||
|
||||
selectableProjectTypes: ComputedRef<{ label: string; href: string; shown?: boolean }[]>
|
||||
showProjectTypeTabs: ComputedRef<boolean>
|
||||
|
||||
variant: 'app' | 'web'
|
||||
|
||||
getCardActions?: (
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
projectType: string,
|
||||
) => CardAction[]
|
||||
|
||||
installContext?: ComputedRef<BrowseInstallContext | null>
|
||||
providedFilters?: ComputedRef<FilterValue[]>
|
||||
hideInstalled?: Ref<boolean>
|
||||
showHideInstalled?: ComputedRef<boolean>
|
||||
hideInstalledLabel?: ComputedRef<string>
|
||||
onInstalled?: (projectId: string) => void
|
||||
|
||||
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
|
||||
cycleDisplayMode?: () => void
|
||||
maxResultsOptions?: ComputedRef<number[]>
|
||||
|
||||
serverPings?: Ref<Record<string, number | undefined>>
|
||||
getServerModpackContent?: (
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
) => ServerModpackContent | undefined
|
||||
|
||||
onProjectHover?: (result: Labrinth.Search.v2.ResultSearchProject) => void
|
||||
onServerProjectHover?: (result: Labrinth.Search.v3.ResultSearchProject) => void
|
||||
onProjectHoverEnd?: () => void
|
||||
onContextMenu?: (
|
||||
event: MouseEvent,
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
) => void
|
||||
offline?: Ref<boolean>
|
||||
|
||||
filtersMenuOpen?: Ref<boolean>
|
||||
|
||||
lockedFilterMessages?: MaybeRef<{
|
||||
gameVersion?: string
|
||||
modLoader?: string
|
||||
environment?: string
|
||||
syncButton?: string
|
||||
providedBy?: string
|
||||
gameVersionShaderMessage?: string
|
||||
}>
|
||||
|
||||
loadingComponent?: Component
|
||||
}
|
||||
|
||||
export const [injectBrowseManager, provideBrowseManager] = createContext<BrowseManagerContext>(
|
||||
'BrowsePageLayout',
|
||||
'browseManagerContext',
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export * from './browse-manager'
|
||||
177
packages/ui/src/layouts/shared/browse-tab/sidebar.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
|
||||
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
|
||||
const isApp = computed(() => ctx.variant === 'app')
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
|
||||
function closeFiltersMenu() {
|
||||
if (ctx.filtersMenuOpen) {
|
||||
ctx.filtersMenuOpen.value = false
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior })
|
||||
}
|
||||
|
||||
const filterClass = computed(() => {
|
||||
if (isApp.value) {
|
||||
return 'border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
|
||||
}
|
||||
if (ctx.filtersMenuOpen?.value) {
|
||||
return 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
}
|
||||
return 'card-shadow rounded-2xl bg-bg-raised'
|
||||
})
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
if (isApp.value) {
|
||||
return 'button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg'
|
||||
}
|
||||
return 'button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none'
|
||||
})
|
||||
|
||||
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
|
||||
const innerPanelClass = computed(() => (isApp.value ? 'ml-2 mr-3' : 'p-1'))
|
||||
|
||||
function getFilterOpenByDefault(filterId: string): boolean {
|
||||
if (ctx.isServerType.value) {
|
||||
return ![
|
||||
'server_category_minecraft_server_meta',
|
||||
'server_category_minecraft_server_community',
|
||||
'server_game_version',
|
||||
'server_status',
|
||||
].includes(filterId)
|
||||
}
|
||||
if (isApp.value) {
|
||||
return filterId.startsWith('category') || filterId === 'environment' || filterId === 'license'
|
||||
}
|
||||
if (
|
||||
lockedMessages.value?.gameVersionShaderMessage &&
|
||||
ctx.projectType.value === 'shader' &&
|
||||
filterId === 'game_version'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot name="prepend" />
|
||||
|
||||
<div v-if="ctx.filtersMenuOpen?.value" class="fixed inset-0 z-40 bg-bg" />
|
||||
|
||||
<div
|
||||
class="flex flex-col"
|
||||
:class="{
|
||||
'gap-3': !isApp,
|
||||
'fixed inset-0 z-50 m-4 mb-0 overflow-auto rounded-t-3xl bg-bg-raised':
|
||||
ctx.filtersMenuOpen?.value,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="ctx.filtersMenuOpen?.value"
|
||||
class="sticky top-0 z-10 mx-1 flex items-center justify-between gap-3 border-0 border-b-[1px] border-solid border-divider bg-bg-raised px-6 py-4"
|
||||
>
|
||||
<h3 class="m-0 text-lg text-contrast">Filters</h3>
|
||||
<ButtonStyled circular>
|
||||
<button @click="closeFiltersMenu">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ctx.showHideInstalled?.value"
|
||||
:class="
|
||||
isApp
|
||||
? 'border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised p-4'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="ctx.hideInstalled!.value"
|
||||
:label="ctx.hideInstalledLabel?.value ?? 'Hide installed content'"
|
||||
class="filter-checkbox"
|
||||
@update:model-value="ctx.onFilterChange()"
|
||||
@click.prevent.stop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="ctx.isServerType.value">
|
||||
<SearchSidebarFilter
|
||||
v-for="filterType in ctx.serverFilterTypes.value.filter((f) => f.options.length > 0)"
|
||||
:key="`server-filter-${filterType.id}`"
|
||||
v-model:selected-filters="ctx.serverCurrentFilters.value"
|
||||
v-model:toggled-groups="ctx.serverToggledGroups.value"
|
||||
:provided-filters="[]"
|
||||
:filter-type="filterType"
|
||||
:class="filterClass"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:open-by-default="getFilterOpenByDefault(filterType.id)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg'">
|
||||
{{ filterType.formatted_name }}
|
||||
</h3>
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in ctx.filters.value.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="ctx.currentFilters.value"
|
||||
v-model:toggled-groups="ctx.toggledGroups.value"
|
||||
v-model:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
|
||||
:provided-filters="ctx.providedFilters?.value ?? []"
|
||||
:filter-type="filter"
|
||||
:class="filterClass"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:open-by-default="getFilterOpenByDefault(filter.id)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg'">
|
||||
{{ filter.formatted_name }}
|
||||
</h3>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
lockedMessages?.gameVersionShaderMessage &&
|
||||
ctx.projectType.value === 'shader' &&
|
||||
filter.id === 'game_version'
|
||||
"
|
||||
#prefix
|
||||
>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span>{{ lockedMessages.gameVersionShaderMessage }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="lockedMessages?.gameVersion" #locked-game_version>
|
||||
{{ lockedMessages.gameVersion }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.modLoader" #locked-mod_loader>
|
||||
{{ lockedMessages.modLoader }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.environment" #locked-environment>
|
||||
{{ lockedMessages.environment }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.syncButton" #sync-button>
|
||||
{{ lockedMessages.syncButton }}
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
46
packages/ui/src/layouts/shared/browse-tab/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Component } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export interface BrowseSearchResponse {
|
||||
projectHits: (Labrinth.Search.v2.ResultSearchProject & {
|
||||
installed?: boolean
|
||||
installing?: boolean
|
||||
})[]
|
||||
serverHits: Labrinth.Search.v3.ResultSearchProject[]
|
||||
total_hits: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface BrowseInstallContext {
|
||||
name: string
|
||||
loader: string
|
||||
gameVersion: string
|
||||
serverId?: string | null
|
||||
upstream?: { project_id?: string | null } | null
|
||||
iconSrc?: string | null
|
||||
isMedal?: boolean
|
||||
backUrl: string | RouteLocationRaw
|
||||
backLabel: string
|
||||
heading: string
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface CardAction {
|
||||
key: string
|
||||
label: string
|
||||
icon: Component
|
||||
disabled?: boolean
|
||||
color?: 'brand' | 'red'
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
circular?: boolean
|
||||
tooltip?: string
|
||||
onClick: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface ServerModpackContent {
|
||||
name: string
|
||||
icon?: string
|
||||
onclick?: () => void
|
||||
showCustomModpackTooltip: boolean
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<ButtonStyled v-if="showClear && hasLogs" type="transparent">
|
||||
<button @click="emit('clear')">
|
||||
<XIcon />
|
||||
Clear
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="showDelete" type="transparent" hover-color-fill="background" color="red">
|
||||
<button
|
||||
v-tooltip="deleteDisabled ? deleteDisabledTooltip : undefined"
|
||||
:disabled="deleteDisabled"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="hasLogs" type="transparent">
|
||||
<button
|
||||
v-tooltip="shareDisabled ? shareDisabledTooltip : undefined"
|
||||
:disabled="shareDisabled || sharing"
|
||||
@click="emit('share')"
|
||||
>
|
||||
<SpinnerIcon v-if="sharing" class="animate-spin" />
|
||||
<ShareIcon v-else />
|
||||
Share
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="emit('toggle-fullscreen')">
|
||||
<ContractIcon v-if="fullscreen" />
|
||||
<ExpandIcon v-else />
|
||||
{{ fullscreen ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ContractIcon,
|
||||
ExpandIcon,
|
||||
ShareIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
|
||||
defineProps<{
|
||||
showClear?: boolean
|
||||
hasLogs?: boolean
|
||||
shareDisabled?: boolean
|
||||
shareDisabledTooltip?: string
|
||||
sharing?: boolean
|
||||
fullscreen?: boolean
|
||||
showDelete?: boolean
|
||||
deleteDisabled?: boolean
|
||||
deleteDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
share: []
|
||||
'toggle-fullscreen': []
|
||||
delete: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<FilterPills v-model="selectedFilters" :options="visibleOptions">
|
||||
<template #all> All </template>
|
||||
</FilterPills>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import FilterPills from '#ui/components/base/FilterPills.vue'
|
||||
|
||||
import type { ConditionalLevel } from '../composables/console-filtering'
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
type FilterValue = LogLevel | 'all'
|
||||
|
||||
const ALWAYS_VISIBLE: Array<{ id: LogLevel; label: string }> = [
|
||||
{ id: 'error', label: 'Error' },
|
||||
{ id: 'warn', label: 'Warn' },
|
||||
{ id: 'info', label: 'Info' },
|
||||
]
|
||||
|
||||
const CONDITIONAL_OPTIONS: Array<{ id: ConditionalLevel; label: string }> = [
|
||||
{ id: 'debug', label: 'Debug' },
|
||||
{ id: 'trace', label: 'Trace' },
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
presentLevels: Set<ConditionalLevel>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<Set<FilterValue>>({ required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [value: FilterValue]
|
||||
}>()
|
||||
|
||||
const visibleOptions = computed(() => [
|
||||
...ALWAYS_VISIBLE,
|
||||
...CONDITIONAL_OPTIONS.filter((opt) => props.presentLevels.has(opt.id)),
|
||||
])
|
||||
|
||||
const selectedFilters = computed({
|
||||
get() {
|
||||
if (modelValue.value.has('all')) return []
|
||||
return [...modelValue.value] as string[]
|
||||
},
|
||||
set(ids: string[]) {
|
||||
if (ids.length === 0) {
|
||||
emit('toggle', 'all')
|
||||
} else {
|
||||
const current = selectedFilters.value
|
||||
const added = ids.find((id) => !current.includes(id))
|
||||
const removed = current.find((id) => !ids.includes(id))
|
||||
if (added) emit('toggle', added as FilterValue)
|
||||
if (removed) emit('toggle', removed as FilterValue)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { LogLevel, LogLine } from '../types'
|
||||
|
||||
export type FilterPredicate = (line: LogLine) => boolean
|
||||
|
||||
function highlightMatches(text: string, query: string): string {
|
||||
if (!query) return text
|
||||
const lower = text.toLowerCase()
|
||||
let result = ''
|
||||
let pos = 0
|
||||
while (pos < text.length) {
|
||||
const idx = lower.indexOf(query, pos)
|
||||
if (idx === -1) {
|
||||
result += text.slice(pos)
|
||||
break
|
||||
}
|
||||
result += text.slice(pos, idx)
|
||||
result += `\x1b[1;7m${text.slice(idx, idx + query.length)}\x1b[27;22m`
|
||||
pos = idx + query.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function colorize(line: LogLine, searchQuery?: string): string {
|
||||
const text = searchQuery ? highlightMatches(line.text, searchQuery) : line.text
|
||||
switch (line.level) {
|
||||
case 'error':
|
||||
return `\x1b[31;40m${text}\x1b[K\x1b[0m`
|
||||
case 'warn':
|
||||
return `\x1b[33;40m${text}\x1b[K\x1b[0m`
|
||||
case 'debug':
|
||||
case 'trace':
|
||||
return `\x1b[90m${text}\x1b[0m`
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
export type ConditionalLevel = 'debug' | 'trace'
|
||||
|
||||
export function useConsoleFilters() {
|
||||
const activeFilters = ref<Set<LogLevel | 'all'>>(new Set(['all']))
|
||||
|
||||
function toggleFilter(level: LogLevel | 'all') {
|
||||
const next = new Set(activeFilters.value)
|
||||
if (level === 'all') {
|
||||
next.clear()
|
||||
next.add('all')
|
||||
} else {
|
||||
next.delete('all')
|
||||
if (next.has(level)) {
|
||||
next.delete(level)
|
||||
} else {
|
||||
next.add(level)
|
||||
}
|
||||
if (next.size === 0) {
|
||||
next.add('all')
|
||||
}
|
||||
}
|
||||
activeFilters.value = next
|
||||
}
|
||||
|
||||
function buildFilterPredicate(): FilterPredicate | null {
|
||||
if (activeFilters.value.has('all')) return null
|
||||
const allowed = activeFilters.value
|
||||
return (line: LogLine) => {
|
||||
return allowed.has(line.level ?? 'info')
|
||||
}
|
||||
}
|
||||
|
||||
return { activeFilters, toggleFilter, buildFilterPredicate }
|
||||
}
|
||||
|
||||
export function rewriteTerminal(
|
||||
terminal: Terminal,
|
||||
allLines: LogLine[],
|
||||
predicate: FilterPredicate | null,
|
||||
searchQuery?: string,
|
||||
callback?: () => void,
|
||||
) {
|
||||
terminal.reset()
|
||||
terminal.write('\x1b[?25l')
|
||||
|
||||
const filtered = predicate ? allLines.filter(predicate) : allLines
|
||||
if (filtered.length === 0) {
|
||||
callback?.()
|
||||
return
|
||||
}
|
||||
|
||||
terminal.write('\x1b[?2026h')
|
||||
terminal.write(filtered.map((line) => colorize(line, searchQuery)).join('\r\n'), () => {
|
||||
terminal.write('\x1b[?2026l')
|
||||
callback?.()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
colorize,
|
||||
type ConditionalLevel,
|
||||
type FilterPredicate,
|
||||
rewriteTerminal,
|
||||
useConsoleFilters,
|
||||
} from './console-filtering'
|
||||
export { computeHighlightColors, LogHighlightAddon } from './log-highlight-addon'
|
||||
export { detectLogLevel } from './log-level'
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { IDecoration, IDisposable, IMarker, ITerminalAddon, Terminal } from '@xterm/xterm'
|
||||
|
||||
import { getCssVar } from '#ui/composables/terminal'
|
||||
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
export interface HighlightColors {
|
||||
errorPrimary: string
|
||||
errorWrap: string
|
||||
warnPrimary: string
|
||||
warnWrap: string
|
||||
}
|
||||
|
||||
interface TrackedLine {
|
||||
marker: IMarker
|
||||
level: 'error' | 'warn'
|
||||
isEntryStart: boolean
|
||||
primary: IDecoration | undefined
|
||||
wraps: IDecoration[]
|
||||
}
|
||||
|
||||
type HighlightClass = 'hl-error-primary' | 'hl-error-wrap' | 'hl-warn-primary' | 'hl-warn-wrap'
|
||||
|
||||
const LOG_ENTRY_START = /^\[\d{2}:\d{2}:\d{2}\]/
|
||||
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
const h = hex.startsWith('#') ? hex.slice(1) : hex
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
function blendHex(base: string, overlay: string, alpha: number): string {
|
||||
const [br, bg, bb] = parseHex(base)
|
||||
const [or, og, ob] = parseHex(overlay)
|
||||
const r = Math.round(br + (or - br) * alpha)
|
||||
const g = Math.round(bg + (og - bg) * alpha)
|
||||
const b = Math.round(bb + (ob - bb) * alpha)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function computeHighlightColors(): HighlightColors {
|
||||
const bg = getCssVar('--surface-2', '#1d1f23')
|
||||
const red = getCssVar('--color-red', '#ff496e')
|
||||
const orange = getCssVar('--color-orange', '#ffa347')
|
||||
return {
|
||||
errorPrimary: blendHex(bg, red, 0.15),
|
||||
errorWrap: blendHex(bg, red, 0.04),
|
||||
warnPrimary: blendHex(bg, orange, 0.15),
|
||||
warnWrap: blendHex(bg, orange, 0.04),
|
||||
}
|
||||
}
|
||||
|
||||
export class LogHighlightAddon implements ITerminalAddon {
|
||||
private terminal: Terminal | null = null
|
||||
private tracked: TrackedLine[] = []
|
||||
private colors: HighlightColors
|
||||
private disposables: IDisposable[] = []
|
||||
private styleElement: HTMLStyleElement | null = null
|
||||
|
||||
constructor(colors: HighlightColors) {
|
||||
this.colors = colors
|
||||
}
|
||||
|
||||
activate(terminal: Terminal): void {
|
||||
this.terminal = terminal
|
||||
this.injectStylesheet()
|
||||
this.disposables.push(terminal.onResize(() => this.rebuildAllDecorations()))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose()
|
||||
this.disposables = []
|
||||
this.clearAll()
|
||||
this.styleElement?.remove()
|
||||
this.styleElement = null
|
||||
this.terminal = null
|
||||
}
|
||||
|
||||
applyFromLine(startLine: number, levels: Array<LogLevel | null>): void {
|
||||
const term = this.terminal
|
||||
if (!term) return
|
||||
|
||||
const buffer = term.buffer.active
|
||||
let levelIdx = 0
|
||||
|
||||
for (let line = startLine; line < buffer.length && levelIdx < levels.length; line++) {
|
||||
const bufLine = buffer.getLine(line)
|
||||
if (!bufLine || bufLine.isWrapped) continue
|
||||
|
||||
const level = levels[levelIdx++]
|
||||
if (level === 'error' || level === 'warn') {
|
||||
this.decorateLogicalLine(line, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
for (const tl of this.tracked) {
|
||||
tl.primary?.dispose()
|
||||
for (const w of tl.wraps) w.dispose()
|
||||
tl.marker.dispose()
|
||||
}
|
||||
this.tracked = []
|
||||
}
|
||||
|
||||
updateColors(colors: HighlightColors): void {
|
||||
this.colors = colors
|
||||
this.updateStylesheet()
|
||||
this.rebuildAllDecorations()
|
||||
}
|
||||
|
||||
private injectStylesheet(): void {
|
||||
const el = this.terminal?.element
|
||||
if (!el) return
|
||||
this.styleElement = document.createElement('style')
|
||||
this.updateStylesheet()
|
||||
el.appendChild(this.styleElement)
|
||||
}
|
||||
|
||||
private updateStylesheet(): void {
|
||||
if (!this.styleElement) return
|
||||
this.styleElement.textContent = [
|
||||
`.hl-error-primary { background-color: ${this.colors.errorPrimary} !important; }`,
|
||||
`.hl-error-wrap { background-color: ${this.colors.errorWrap} !important; }`,
|
||||
`.hl-warn-primary { background-color: ${this.colors.warnPrimary} !important; }`,
|
||||
`.hl-warn-wrap { background-color: ${this.colors.warnWrap} !important; }`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
private classForDecoration(level: 'error' | 'warn', isEntryStart: boolean): HighlightClass {
|
||||
if (level === 'error') return isEntryStart ? 'hl-error-primary' : 'hl-error-wrap'
|
||||
return isEntryStart ? 'hl-warn-primary' : 'hl-warn-wrap'
|
||||
}
|
||||
|
||||
private tagElement(dec: IDecoration | undefined, cls: HighlightClass): void {
|
||||
if (!dec) return
|
||||
const disposable = dec.onRender((el) => {
|
||||
el.classList.add(cls)
|
||||
disposable.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
private decorateLogicalLine(bufferLine: number, level: 'error' | 'warn'): void {
|
||||
const term = this.terminal
|
||||
if (!term) return
|
||||
|
||||
const buffer = term.buffer.active
|
||||
const cursorAbsolute = buffer.baseY + buffer.cursorY
|
||||
const offset = bufferLine - cursorAbsolute
|
||||
const marker = term.registerMarker(offset)
|
||||
if (!marker) return
|
||||
|
||||
const lineText = buffer.getLine(bufferLine)?.translateToString(true) ?? ''
|
||||
const isEntryStart = LOG_ENTRY_START.test(lineText)
|
||||
|
||||
const bgColor = isEntryStart
|
||||
? level === 'error'
|
||||
? this.colors.errorPrimary
|
||||
: this.colors.warnPrimary
|
||||
: level === 'error'
|
||||
? this.colors.errorWrap
|
||||
: this.colors.warnWrap
|
||||
|
||||
const primary = term.registerDecoration({
|
||||
marker,
|
||||
backgroundColor: bgColor,
|
||||
width: term.cols,
|
||||
layer: 'bottom',
|
||||
})
|
||||
this.tagElement(primary, this.classForDecoration(level, isEntryStart))
|
||||
const wraps = this.createWrapDecorations(bufferLine, level)
|
||||
|
||||
this.tracked.push({ marker, level, isEntryStart, primary, wraps })
|
||||
}
|
||||
|
||||
private createWrapDecorations(primaryLine: number, level: 'error' | 'warn'): IDecoration[] {
|
||||
const term = this.terminal
|
||||
if (!term) return []
|
||||
|
||||
const buffer = term.buffer.active
|
||||
const decorations: IDecoration[] = []
|
||||
const cursorAbsolute = buffer.baseY + buffer.cursorY
|
||||
const cls = this.classForDecoration(level, false)
|
||||
const color = level === 'error' ? this.colors.errorWrap : this.colors.warnWrap
|
||||
|
||||
for (let line = primaryLine + 1; line < buffer.length; line++) {
|
||||
const bufLine = buffer.getLine(line)
|
||||
if (!bufLine || !bufLine.isWrapped) break
|
||||
|
||||
const offset = line - cursorAbsolute
|
||||
const wrapMarker = term.registerMarker(offset)
|
||||
if (!wrapMarker) continue
|
||||
|
||||
const dec = term.registerDecoration({
|
||||
marker: wrapMarker,
|
||||
backgroundColor: color,
|
||||
width: term.cols,
|
||||
layer: 'bottom',
|
||||
})
|
||||
if (dec) {
|
||||
this.tagElement(dec, cls)
|
||||
decorations.push(dec)
|
||||
}
|
||||
}
|
||||
|
||||
return decorations
|
||||
}
|
||||
|
||||
private rebuildAllDecorations(): void {
|
||||
const term = this.terminal
|
||||
if (!term) return
|
||||
|
||||
for (const tl of this.tracked) {
|
||||
tl.primary?.dispose()
|
||||
for (const w of tl.wraps) w.dispose()
|
||||
|
||||
if (tl.marker.line === -1) {
|
||||
tl.primary = undefined
|
||||
tl.wraps = []
|
||||
continue
|
||||
}
|
||||
|
||||
const cls = this.classForDecoration(tl.level, tl.isEntryStart)
|
||||
const bgColor = tl.isEntryStart
|
||||
? tl.level === 'error'
|
||||
? this.colors.errorPrimary
|
||||
: this.colors.warnPrimary
|
||||
: tl.level === 'error'
|
||||
? this.colors.errorWrap
|
||||
: this.colors.warnWrap
|
||||
tl.primary = term.registerDecoration({
|
||||
marker: tl.marker,
|
||||
backgroundColor: bgColor,
|
||||
width: term.cols,
|
||||
layer: 'bottom',
|
||||
})
|
||||
this.tagElement(tl.primary, cls)
|
||||
tl.wraps = this.createWrapDecorations(tl.marker.line, tl.level)
|
||||
}
|
||||
|
||||
this.tracked = this.tracked.filter((tl) => tl.marker.line !== -1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
const ERROR_TRIGGERS = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', '\tat']
|
||||
|
||||
export function detectLogLevel(lineText: string): LogLevel | null {
|
||||
if (lineText.includes('/INFO') || lineText.includes('[System] [CHAT]')) return 'info'
|
||||
if (lineText.includes('/WARN')) return 'warn'
|
||||
if (lineText.includes('/DEBUG')) return 'debug'
|
||||
if (lineText.includes('/TRACE')) return 'trace'
|
||||
for (const trigger of ERROR_TRIGGERS) {
|
||||
if (lineText.includes(trigger)) return 'error'
|
||||
}
|
||||
return null
|
||||
}
|
||||
3
packages/ui/src/layouts/shared/console/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ConsolePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export * from './types'
|
||||