feat: server management in app (#5628)
* start new server settings tabs * update properties tab to match design * better stying in general tab * feat: add suffix input for hostname field * implement tables for allocations and DNS records * add tags for dns record type * small gap adjustment * polish advanced page * adjust properties page hierarchy * fix searching properties, empty state and projection radius appearing * pnpm prepr * update copy to match designs * fix suffix input component * style fixes and match heading size * small fix * fix search allocations placeholder * adjust table styles * move all installation settings helper text to below input * update icon to use overflow menu buttons * fix modal to be consistent * open advanced properties when search * remove other and custom properties, and update styles * remove hide/show all java versions * handle mc 26 * refactor: move server settings pages into /ui and add app ServerSettingsModal * hook up server pages for app * add server page header to app * hook up server settings modal * use large size * fix card box shadow style * fix hostname input for app * fix app/website card containers * implement external tabs for billing and admin billing * fix save banner fixed to parent instead of page body * remove unused prop to FriendsList causing warning in app * fix client-only not available for app * fix bottom cut off * wire node auth * implement full copy buttons * dedup copy button tailwind styles * fix hover class not working in @apply * fix spacing * fix error validation styles * apply consistent styles and spacing * feat: update hosting server card (#5609) * fix type errors * fix some stylesheets not imported for storybook * add server listing stories * add fix for frontend stylesheet imports * remove props. * convert copy code to use tailwind * update server listing component styles * update server info label styles * start status/player count info label, more style updates and fixes * add new server card buttons * hook up server cards and implement updated styles * hook up on download button * fix tauri throwing error when api returns 204 No Content * hook up purchase server modal in app * fix upgrading state loading icon * pnpm prepr * filter out servers past 30 days after cancellation * do not apply opacity on lock or spiner icons * fix disabled server icon background * update pending change stage * handle known suspension states * refactor: reduce code duplication for server listing * update disabled state text color * fix loading icon color * clean up copy * fix disabled opacity for server card * update server listing files kept to be countdown * implement resubscribe modal * implement proper provisioning state for resubscribe * fix duplicate attribute and pnpm prepr * feat: add shared UI package auth DI * feat: update purchase server flow (#5714) * implement server list empty state component * fix stories and adjust spacing * implement select plan design refresh * implement auth for empty server list * use refs instead of reactive * pnpm prepr * fix auth usage for empty servers list * move app auth provider setup to src/providers/setup * pnpm prepr * fix max height * style fix * fix getCreds no auth is blocking api client * implement servers guest plan modal and signin which redirects back to modal's next step * refactor guest plan select logic into provider * implement sign in or create account popup * remove force empty serverList * add download button for suspended mod and generic * add handling for when user logs out * QA pass style fixes * more consistent page styles * fix duplicate export * refactor: remove all fallback stuff from resubscribe modal * implement shared download latest backup util * i18n pass * pnpm prepr * fix region being selected if ping failed * pnpm prepr * feat: servers in app finalization (#5744) * feat: start on shared console implementation into logs and overview pages * fix: terminal gap issues * feat: swap word wrap for full screen * fix: stats cards alignment * fix: stats * feat: fix console clear + remove copy * fix: lint * fix: use reset not clear * feat: shared server header & overview page for app and website (#5736) * feat: implement shared server header for app and website * feat: implement wrapped overview page with shared composable and hook it up * pnpm prepr * fix: bugs * qa: cleanup * feat: root.vue shared layout * feat: delete old options pages + fix discovery frontend * fix: discovery * fix: misc style/layout issues * fix page padding * fix: modal height jankiness * feat: implement server install content in app and server setup modal with DI * fix: spacing * remove servers in app feature flag * Revert "remove servers in app feature flag" This reverts commit 86e284c4bdd6fa42c3c8fbaf1efbec41f4d1c6d2. * fix: qa * feat: remove legacy components from apps/frontend/src/components/ui/servers --------- Co-authored-by: Calum H. (IMB11) <contact@cal.engineer> * qa pass (#5738) * fix: qa * feat: qa * fix: server icon fetch fails due to global node auth race condition overriding each other * fix: lint * fix: server icon upload/sync and centralize logic * fix: server settings modal not closing for server reset * fix: better server sorting * feat: copy address in server listing card * fix: notification panel in modal and when overlapping with action bar * fix: empty server list empty state flashing when refresh, fixed by adding isReady auth flag * feat: use floating action bar for save banner * fix: saving state in save bar * fix: edit server icon styling * fix: confirm modal to have consistent buttons * feat: loading animation for server panel + caching improvements for app * pnpm prepr * feat: search page deduplication (#5754) * fix: action bar behind modal * fix: remove warning modal for stopping * fix: server cards states * we hate webkit we hate webkit * fix: update allocation creation to not use modal * fix: properties tab spacing and styles * feat: add files tab copy * fix: advanced properties icon * fix: remove back to all servers link * feat: add files tab link in copy * fix: server header styles to be consistent with instance * fix: add header icons back * feat: update instance settings icon to be consistent * fix: icon container * feat: upload state persistence across tabs * fix: server labels text wrapping * fix: use surface-5 border * fix: loading spinner showing with onboarding below * feat: new server button shows purchase modal in website * fix: billing page not showing quarterly interval * fix: server downgrade not showing updated subscription notification * fix: server settings invalidate saved state and remove server context provider since its already provided in the page * pnpm prepr * add stripe publishable key to app build * feat: console highlighting * fix: rename servers title to modrinth hosting * feat: search fix * fix: qa/styles * fix: ip click active and remove power dont ask again * fix: qa * feat: highlighting fix console * fix: disable conflicts action * fix: error dismiss bug * feat: modal clarification * fix: files perms issue * fix: lint * feat: modal fix * enable show uptime * fix: add loading state to edit server icon * fix: notification panel take in has sidebar from settings * fix: consistency pass on app settings * fix: consistency pass on instance settings * pnpm prepr * fix: nagivate to billing button in app to go to website * fix: stripe return url in app causing app to open modrinth.com in tauri * refactor: better show polling UI code * fix: new server polling comparison to use server ids instead of length * fix: buttonstyled story * fix: button styling * fix: content.vue regression * feat: project url redirects * fix: breadcrumbs * fix: purchase with newly added card * fix: console ordering problems * fix: app-frontend missing env config and staging environment * fix: log syncing for instances and server panel accidentally * fix: QA issues * fix: server page loading state * fix: stats card logic * fix: lint * fix: qa * fix: console height padding * fix: terminal padding + loading indicator * feat: update medal server listing styling * fix: no upgrade button for medal server listing in app * fix: go to overview instead of content tab after onboarding * fix: qa * fix: teleport modals to body * fix: logs tab + qa * fix: local storage for user preferences * fix: qa loading indic * feat: considitonal debug and trace * fix: jump to top on install bug * feat: swap out server hard drive icon to server stack icon * feat: servers in app feature flag default true * fix: highlight row ufll * fix: webkit thing onto a tag * fix: input field * fix: clear fix * fix: lint * fix: fmt * feat: improve share modal and bring it back for sharing log * pnpm prepr * fix: menu overflowing * feat: remove servers in app feature flag * fix: server stat charts no longer showing color * fix: library nav no primary state * fix: better modal height and width * fix: highlighting bugs * fix: empty states * fix: delay import to fix overview page slow load on MacOS * fix: medal server listing too bright on light mode * fix: admon analysis + fix logs * fix: bug * fix: clear purchase intent from sign-in after closing modal * performance: improve server manage stats loading by splitting reactivity * fix: deploy + admon + disable highlighting * fix: clippy --------- Co-authored-by: tdgao <mr.trumgao@gmail.com> Co-authored-by: Truman Gao <106889354+tdgao@users.noreply.github.com> * feat: temp wrangler * fix: lint * fix: logs upload * fix: console empty state and admon regressions * fix: fields * feat: log deleting + prefetch for Logs.vue * feat: move delete before share * feat: clear endpoint * feat: we ball! --------- Co-authored-by: Calum H. <calum@modrinth.com> Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
This commit is contained in:
@@ -1,96 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
v-for="loader in vanillaLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<LoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Mod loaders</h2>
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
v-for="loader in modLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<LoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<h2 class="mb-2 px-2 text-lg font-bold text-contrast">Plugin loaders</h2>
|
||||
<div class="flex w-full flex-col gap-1 rounded-2xl bg-table-alternateRow p-2">
|
||||
<div
|
||||
v-for="loader in pluginLoaders"
|
||||
:key="loader.name"
|
||||
class="group relative flex items-center justify-between rounded-2xl p-2 pr-2.5 hover:bg-bg"
|
||||
>
|
||||
<LoaderSelectorCard
|
||||
:loader="loader"
|
||||
:is-current="isCurrentLoader(loader.name)"
|
||||
:loader-version="data.loader_version"
|
||||
:current-loader="data.loader"
|
||||
:is-installing="isInstalling"
|
||||
@select="selectLoader"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import LoaderSelectorCard from './LoaderSelectorCard.vue'
|
||||
const props = defineProps<{
|
||||
data: {
|
||||
loader: string | null
|
||||
loader_version: string | null
|
||||
}
|
||||
ignoreCurrentInstallation?: boolean
|
||||
isInstalling?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'selectLoader', loader: string): void
|
||||
}>()
|
||||
|
||||
const vanillaLoaders = [{ name: 'Vanilla' as const, displayName: 'Vanilla' }]
|
||||
|
||||
const modLoaders = [
|
||||
{ name: 'Fabric' as const, displayName: 'Fabric' },
|
||||
{ name: 'Quilt' as const, displayName: 'Quilt' },
|
||||
{ name: 'Forge' as const, displayName: 'Forge' },
|
||||
{ name: 'NeoForge' as const, displayName: 'NeoForge' },
|
||||
]
|
||||
|
||||
const pluginLoaders = [
|
||||
{ name: 'Paper' as const, displayName: 'Paper' },
|
||||
{ name: 'Purpur' as const, displayName: 'Purpur' },
|
||||
]
|
||||
|
||||
const isCurrentLoader = (loaderName: string) => {
|
||||
return props.data.loader?.toLowerCase() === loaderName.toLowerCase()
|
||||
}
|
||||
|
||||
const selectLoader = (loader: string) => {
|
||||
emit('selectLoader', loader)
|
||||
}
|
||||
</script>
|
||||
@@ -1,68 +0,0 @@
|
||||
<template>
|
||||
<div class="flex w-full items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="grid size-10 place-content-center rounded-xl border-[1px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
:class="isCurrentLoader ? '[&&]:bg-bg-green' : ''"
|
||||
>
|
||||
<LoaderIcon
|
||||
:loader="loader.name"
|
||||
class="size-6"
|
||||
:class="isCurrentLoader ? 'text-brand' : ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h1 class="m-0 text-xl font-bold leading-none text-contrast">
|
||||
{{ loader.displayName }}
|
||||
</h1>
|
||||
<span
|
||||
v-if="isCurrentLoader"
|
||||
class="hidden items-center gap-1 rounded-full bg-bg-green p-1 px-1.5 text-xs font-semibold text-brand sm:flex"
|
||||
>
|
||||
<CheckIcon class="h-4 w-4" /> Current
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="isCurrentLoader" class="m-0 text-xs text-secondary">{{ loaderVersion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isInstalling" @click="onSelect">
|
||||
<DownloadIcon class="h-5 w-5" /> {{ isCurrentLoader ? 'Reinstall' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, DownloadIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, LoaderIcon } from '@modrinth/ui'
|
||||
|
||||
interface LoaderInfo {
|
||||
name: 'Vanilla' | 'Fabric' | 'Forge' | 'Quilt' | 'Paper' | 'NeoForge' | 'Purpur'
|
||||
displayName: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
loader: LoaderInfo
|
||||
currentLoader: string | null
|
||||
loaderVersion: string | null
|
||||
isInstalling?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', loader: string): void
|
||||
}>()
|
||||
|
||||
const isCurrentLoader = computed(() => {
|
||||
return props.currentLoader?.toLowerCase() === props.loader.name.toLowerCase()
|
||||
})
|
||||
|
||||
const onSelect = () => {
|
||||
emit('select', props.loader.name)
|
||||
}
|
||||
</script>
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
class="parsed-log relative flex h-8 w-full items-center overflow-hidden rounded-lg px-6"
|
||||
@mouseenter="checkOverflow"
|
||||
@touchstart="checkOverflow"
|
||||
>
|
||||
<div ref="logContent" class="log-content flex-1 truncate whitespace-pre">
|
||||
<span v-html="sanitizedLog"></span>
|
||||
</div>
|
||||
<button
|
||||
v-if="isOverflowing"
|
||||
class="ml-2 flex h-6 items-center rounded-md bg-bg px-2 text-xs text-contrast opacity-50 transition-opacity hover:opacity-100"
|
||||
type="button"
|
||||
@click.stop="$emit('show-full-log', props.log)"
|
||||
>
|
||||
...
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Convert from 'ansi-to-html'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
log: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
'show-full-log': [log: string]
|
||||
}>()
|
||||
|
||||
const logContent = ref<HTMLElement | null>(null)
|
||||
const isOverflowing = ref(false)
|
||||
|
||||
const checkOverflow = () => {
|
||||
if (logContent.value && !isOverflowing.value) {
|
||||
isOverflowing.value = logContent.value.scrollWidth > logContent.value.clientWidth
|
||||
}
|
||||
}
|
||||
|
||||
const convert = new Convert({
|
||||
fg: '#FFF',
|
||||
bg: '#000',
|
||||
newline: false,
|
||||
escapeXML: true,
|
||||
stream: false,
|
||||
})
|
||||
|
||||
const sanitizedLog = computed(() =>
|
||||
DOMPurify.sanitize(convert.toHtml(props.log), {
|
||||
ALLOWED_TAGS: ['span'],
|
||||
ALLOWED_ATTR: ['style'],
|
||||
USE_PROFILES: { html: true },
|
||||
}),
|
||||
)
|
||||
|
||||
const preventSelection = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
logContent.value?.addEventListener('mousedown', preventSelection)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
logContent.value?.removeEventListener('mousedown', preventSelection)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.parsed-log {
|
||||
background: transparent;
|
||||
transition: background-color 0.1s;
|
||||
}
|
||||
|
||||
.parsed-log:hover {
|
||||
background: rgba(128, 128, 128, 0.25);
|
||||
transition: 0s;
|
||||
}
|
||||
|
||||
.log-content > span {
|
||||
user-select: none;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
white-space: pre;
|
||||
}
|
||||
</style>
|
||||
@@ -1,276 +0,0 @@
|
||||
<template>
|
||||
<div class="contents">
|
||||
<NewModal ref="confirmActionModal" header="Confirming power action" @close="resetPowerAction">
|
||||
<div class="flex flex-col gap-4 md:w-[400px]">
|
||||
<p class="m-0">
|
||||
Are you sure you want to
|
||||
<span class="lowercase">{{ pendingAction }}</span> the server?
|
||||
</p>
|
||||
<Checkbox
|
||||
v-model="dontAskAgain"
|
||||
label="Don't ask me again"
|
||||
class="text-sm"
|
||||
:disabled="!pendingAction"
|
||||
/>
|
||||
<div class="flex flex-row gap-4">
|
||||
<ButtonStyled type="standard" color="brand" @click="executePowerAction">
|
||||
<button>
|
||||
<CheckIcon class="h-5 w-5" />
|
||||
{{ pendingAction }} server
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled @click="resetPowerAction">
|
||||
<button>
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
|
||||
<NewModal
|
||||
ref="detailsModal"
|
||||
:header="`All of ${server.name || 'Server'} info`"
|
||||
@close="detailsModal?.hide()"
|
||||
>
|
||||
<ServerInfoLabels
|
||||
:server-data="server"
|
||||
:show-game-label="true"
|
||||
:show-loader-label="true"
|
||||
:uptime-seconds="uptimeSeconds"
|
||||
:column="true"
|
||||
class="mb-6 flex flex-col gap-2"
|
||||
/>
|
||||
<div v-if="flags.advancedDebugInfo" class="markdown-body">
|
||||
<pre>{{ server }}</pre>
|
||||
</div>
|
||||
<ButtonStyled type="standard" color="brand" @click="detailsModal?.hide()">
|
||||
<button class="w-full">Close</button>
|
||||
</ButtonStyled>
|
||||
</NewModal>
|
||||
|
||||
<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">
|
||||
<PanelSpinner class="size-5" /> Installing...
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template v-else>
|
||||
<ButtonStyled v-if="showStopButton" type="transparent" size="large">
|
||||
<button :disabled="!canTakeAction" @click="initiateAction('Stop')">
|
||||
<div class="flex gap-1">
|
||||
<StopCircleIcon class="h-5 w-5" />
|
||||
<span>{{ isStopping ? 'Stopping...' : 'Stop' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="standard" color="brand" size="large">
|
||||
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||
<div v-if="isTransitioning" class="grid place-content-center">
|
||||
<LoadingIcon />
|
||||
</div>
|
||||
<component :is="isRunning ? UpdatedIcon : PlayIcon" v-else />
|
||||
<span>{{ primaryActionText }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<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 #details>
|
||||
<InfoIcon class="h-5 w-5" />
|
||||
<span>Details</span>
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
||||
<span>Copy ID</span>
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
ClipboardCopyIcon,
|
||||
InfoIcon,
|
||||
MoreVerticalIcon,
|
||||
PlayIcon,
|
||||
ServerIcon,
|
||||
SlashIcon,
|
||||
StopCircleIcon,
|
||||
UpdatedIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
ServerInfoLabels,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||
import PanelSpinner from './PanelSpinner.vue'
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
|
||||
|
||||
const props = defineProps<{
|
||||
disabled?: boolean
|
||||
uptimeSeconds: number
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const flags = useFeatureFlags()
|
||||
const router = useRouter()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId, server, powerState, busyReasons } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const confirmActionModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const detailsModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const pendingAction = ref<PowerAction | null>(null)
|
||||
const dontAskAgain = ref(false)
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
powerDontAskAgain: false,
|
||||
})
|
||||
|
||||
const isInstalling = computed(() => server.value.status === 'installing')
|
||||
const isRunning = computed(() => powerState.value === 'running')
|
||||
const isStopping = computed(() => powerState.value === 'stopping')
|
||||
const isTransitioning = computed(
|
||||
() => powerState.value === 'starting' || powerState.value === 'stopping',
|
||||
)
|
||||
const showStopButton = computed(() => isRunning.value || isStopping.value)
|
||||
|
||||
const busyTooltip = computed(() =>
|
||||
busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined,
|
||||
)
|
||||
|
||||
const canTakeAction = computed(
|
||||
() => !isTransitioning.value && !props.disabled && busyReasons.value.length === 0,
|
||||
)
|
||||
|
||||
const primaryActionText = computed(() => {
|
||||
switch (powerState.value) {
|
||||
case 'starting':
|
||||
return 'Starting...'
|
||||
case 'stopping':
|
||||
return 'Stopping...'
|
||||
case 'running':
|
||||
return 'Restart'
|
||||
default:
|
||||
return 'Start'
|
||||
}
|
||||
})
|
||||
|
||||
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: 'details',
|
||||
label: 'Details',
|
||||
icon: InfoIcon,
|
||||
action: () => detailsModal.value?.show(),
|
||||
},
|
||||
{
|
||||
id: 'copy-id',
|
||||
label: 'Copy ID',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => copyId(),
|
||||
shown: flags.value.developerMode,
|
||||
},
|
||||
])
|
||||
|
||||
async function copyId() {
|
||||
await navigator.clipboard.writeText(serverId)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
if (action === 'Start') {
|
||||
sendPowerAction(action)
|
||||
return
|
||||
}
|
||||
|
||||
pendingAction.value = action
|
||||
|
||||
if (userPreferences.value.powerDontAskAgain) {
|
||||
executePowerAction()
|
||||
} else {
|
||||
confirmActionModal.value?.show()
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrimaryAction() {
|
||||
initiateAction(isRunning.value ? 'Restart' : 'Start')
|
||||
}
|
||||
|
||||
function executePowerAction() {
|
||||
if (!pendingAction.value) return
|
||||
|
||||
sendPowerAction(pendingAction.value)
|
||||
|
||||
if (dontAskAgain.value) {
|
||||
userPreferences.value.powerDontAskAgain = true
|
||||
}
|
||||
|
||||
resetPowerAction()
|
||||
}
|
||||
|
||||
function resetPowerAction() {
|
||||
confirmActionModal.value?.hide()
|
||||
pendingAction.value = null
|
||||
dontAskAgain.value = false
|
||||
}
|
||||
</script>
|
||||
@@ -1,75 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
:aria-label="`Server is ${getStatusText(state)}`"
|
||||
class="relative inline-flex select-none items-center"
|
||||
@mouseenter="isExpanded = true"
|
||||
@mouseleave="isExpanded = false"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'h-4 w-4 rounded-full transition-all duration-300 ease-in-out',
|
||||
getStatusClass(state).main,
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute inline-flex h-4 w-4 animate-ping rounded-full',
|
||||
getStatusClass(state).bg,
|
||||
]"
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'absolute -left-4 flex w-auto items-center gap-2 rounded-full px-2 py-1 transition-all duration-150 ease-in-out',
|
||||
getStatusClass(state).bg,
|
||||
isExpanded ? 'translate-x-2 scale-100 opacity-100' : 'translate-x-0 scale-90 opacity-0',
|
||||
]"
|
||||
>
|
||||
<div class="h-3 w-3 rounded-full"></div>
|
||||
<span
|
||||
:class="[
|
||||
'origin-left whitespace-nowrap text-sm font-semibold text-contrast transition-all duration-[200ms] ease-in-out',
|
||||
isExpanded ? 'translate-x-0 scale-100' : '-translate-x-1 scale-x-75',
|
||||
]"
|
||||
>
|
||||
{{ getStatusText(state) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ServerState } from '@modrinth/utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const STATUS_CLASSES = {
|
||||
running: { main: 'bg-brand', bg: 'bg-bg-green' },
|
||||
stopped: { main: '', bg: '' },
|
||||
crashed: { main: 'bg-brand-red', bg: 'bg-bg-red' },
|
||||
unknown: { main: '', bg: '' },
|
||||
} as const
|
||||
|
||||
const STATUS_TEXTS: Partial<Record<ServerState, string>> = {
|
||||
running: 'Running',
|
||||
stopped: '',
|
||||
crashed: 'Crashed',
|
||||
unknown: 'Unknown',
|
||||
} as const
|
||||
|
||||
defineProps<{
|
||||
state: ServerState
|
||||
}>()
|
||||
|
||||
const isExpanded = ref(false)
|
||||
|
||||
function getStatusClass(state: ServerState) {
|
||||
if (state in STATUS_CLASSES) {
|
||||
return STATUS_CLASSES[state as keyof typeof STATUS_CLASSES]
|
||||
}
|
||||
return STATUS_CLASSES.unknown
|
||||
}
|
||||
|
||||
function getStatusText(state: ServerState) {
|
||||
return STATUS_TEXTS[state] ?? STATUS_TEXTS.unknown
|
||||
}
|
||||
</script>
|
||||
@@ -1,22 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
class="h-5 w-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,163 +0,0 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="'Changing ' + props.project?.title + ' version'"
|
||||
@hide="onHide"
|
||||
@show="onShow"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<div class="flex flex-col gap-2">
|
||||
<p class="m-0">
|
||||
Select the version of {{ props.project?.title || 'the modpack' }} you want to install on
|
||||
your server.
|
||||
</p>
|
||||
<p v-if="props.currentVersion" class="m-0 text-sm text-secondary">
|
||||
Currently installed: {{ props.currentVersion.version_number }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-4">
|
||||
<Combobox
|
||||
v-if="props.versions?.length"
|
||||
v-model="selectedVersion"
|
||||
:options="versionOptions.map((v) => ({ value: v, label: v }))"
|
||||
:display-value="selectedVersion || 'Select version...'"
|
||||
placeholder="Select version..."
|
||||
name="version"
|
||||
class="w-full max-w-full"
|
||||
/>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<Toggle id="modpack-hard-reset" v-model="hardReset" class="shrink-0" />
|
||||
</div>
|
||||
<div>
|
||||
If enabled, existing mods, worlds, and configurations, will be deleted before installing
|
||||
the new modpack version.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="hardReset ? 'red' : 'brand'">
|
||||
<button
|
||||
:disabled="isLoading || !selectedVersion || props.serverStatus === 'installing'"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<DownloadIcon class="size-4" />
|
||||
{{ isLoading ? 'Installing...' : hardReset ? 'Erase and install' : 'Install' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button :disabled="isLoading" @click="hide">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
} from '@modrinth/ui'
|
||||
import { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const props = defineProps<{
|
||||
project: any
|
||||
versions: any[]
|
||||
currentVersion?: any
|
||||
currentVersionId?: string
|
||||
serverStatus?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?]
|
||||
}>()
|
||||
|
||||
const modal = ref()
|
||||
const hardReset = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const selectedVersion = ref(props.currentVersion?.version_number || '')
|
||||
|
||||
const versionOptions = computed(() => props.versions?.map((v) => v.version_number) || [])
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (!selectedVersion.value || !props.project?.id) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
const versionId = props.versions.find((v) => v.version_number === selectedVersion.value)?.id
|
||||
|
||||
await client.archon.servers_v0.reinstall(
|
||||
serverId,
|
||||
{
|
||||
project_id: props.project.id,
|
||||
version_id: versionId,
|
||||
},
|
||||
hardReset.value,
|
||||
)
|
||||
|
||||
emit('reinstall')
|
||||
hide()
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServersFetchError && error.statusCode === 429) {
|
||||
addNotification({
|
||||
title: 'Cannot reinstall server',
|
||||
text: 'You are being rate limited. Please try again later.',
|
||||
type: 'error',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Reinstall Failed',
|
||||
text: 'An unexpected error occurred while reinstalling. Please try again later.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.serverStatus,
|
||||
(newStatus) => {
|
||||
if (newStatus === 'installing') {
|
||||
hide()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const onShow = () => {
|
||||
hardReset.value = false
|
||||
selectedVersion.value =
|
||||
props.currentVersion?.version_number ?? props.versions?.[0]?.version_number ?? ''
|
||||
}
|
||||
|
||||
const onHide = () => {
|
||||
hardReset.value = false
|
||||
selectedVersion.value = ''
|
||||
isLoading.value = false
|
||||
}
|
||||
|
||||
const show = () => modal.value?.show()
|
||||
const hide = () => modal.value?.hide()
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,538 +0,0 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="versionSelectModal"
|
||||
:header="
|
||||
isSecondPhase
|
||||
? 'Confirming reinstallation'
|
||||
: `${props.currentLoader === selectedLoader ? 'Reinstalling' : 'Installing'}
|
||||
${selectedLoader.toLowerCase() === 'vanilla' ? 'Vanilla Minecraft' : selectedLoader}`
|
||||
"
|
||||
@hide="onHide"
|
||||
@show="onShow"
|
||||
>
|
||||
<div class="flex flex-col gap-4 md:w-[600px]">
|
||||
<p
|
||||
v-if="isSecondPhase"
|
||||
:style="{
|
||||
lineHeight: isSecondPhase ? '1.5' : undefined,
|
||||
marginBottom: isSecondPhase ? '-12px' : '0',
|
||||
marginTop: isSecondPhase ? '-4px' : '-2px',
|
||||
}"
|
||||
>
|
||||
{{
|
||||
'This will reinstall your server and erase all data. Are you sure you want to continue?'
|
||||
}}
|
||||
</p>
|
||||
<div v-if="!isSecondPhase" class="flex flex-col gap-4">
|
||||
<div class="mx-auto flex flex-row items-center gap-4">
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-button-bg shadow-sm"
|
||||
>
|
||||
<LoaderIcon class="size-10" :loader="selectedLoader" />
|
||||
</div>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-10"
|
||||
>
|
||||
<path d="M5 9v6" />
|
||||
<path d="M9 9h3V5l7 7-7 7v-4H9V9z" />
|
||||
</svg>
|
||||
<div
|
||||
class="grid size-16 place-content-center rounded-2xl border-[2px] border-solid border-button-border bg-table-alternateRow shadow-sm"
|
||||
>
|
||||
<ServerIcon class="size-10" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4">
|
||||
<div class="text-lg font-bold text-contrast">Minecraft version</div>
|
||||
<Combobox
|
||||
v-model="selectedMCVersion"
|
||||
name="mcVersion"
|
||||
:options="mcVersions.map((v) => ({ value: v, label: v }))"
|
||||
:display-value="selectedMCVersion || 'Select Minecraft version...'"
|
||||
class="!w-full"
|
||||
placeholder="Select Minecraft version..."
|
||||
/>
|
||||
<div class="mt-2 flex items-center justify-between gap-2">
|
||||
<label for="toggle-snapshots" class="font-semibold"> Show snapshot versions </label>
|
||||
<div
|
||||
v-tooltip="
|
||||
isSnapshotSelected ? 'A snapshot version is currently selected.' : undefined
|
||||
"
|
||||
>
|
||||
<Toggle
|
||||
id="toggle-snapshots"
|
||||
v-model="showSnapshots"
|
||||
:disabled="isSnapshotSelected"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="selectedLoader.toLowerCase() !== 'vanilla'"
|
||||
class="flex w-full flex-col gap-2 rounded-2xl p-4"
|
||||
:class="{
|
||||
'bg-table-alternateRow':
|
||||
!selectedMCVersion || isLoading || selectedLoaderVersions.length > 0,
|
||||
'bg-highlight-red':
|
||||
selectedMCVersion && !isLoading && selectedLoaderVersions.length === 0,
|
||||
}"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-lg font-bold text-contrast">{{ selectedLoader }} version</div>
|
||||
|
||||
<template v-if="!selectedMCVersion">
|
||||
<div
|
||||
class="relative flex h-9 w-full select-none items-center rounded-xl bg-button-bg px-4 opacity-50"
|
||||
>
|
||||
Select a Minecraft version to see available versions
|
||||
<DropdownIcon class="absolute right-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="isLoading">
|
||||
<div
|
||||
class="relative flex h-9 w-full items-center rounded-xl bg-button-bg px-4 opacity-50"
|
||||
>
|
||||
<LoadingIcon class="mr-2 animate-spin" />
|
||||
Loading versions...
|
||||
<DropdownIcon class="absolute right-4" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="selectedLoaderVersions.length > 0">
|
||||
<Combobox
|
||||
v-model="selectedLoaderVersion"
|
||||
name="loaderVersion"
|
||||
:options="selectedLoaderVersions.map((v) => ({ value: v, label: v }))"
|
||||
:display-value="
|
||||
selectedLoaderVersion ||
|
||||
(selectedLoader.toLowerCase() === 'paper' ||
|
||||
selectedLoader.toLowerCase() === 'purpur'
|
||||
? 'Select build number...'
|
||||
: 'Select loader version...')
|
||||
"
|
||||
class="w-full max-w-[100%]"
|
||||
:placeholder="
|
||||
selectedLoader.toLowerCase() === 'paper' ||
|
||||
selectedLoader.toLowerCase() === 'purpur'
|
||||
? `Select build number...`
|
||||
: `Select loader version...`
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div>No versions available for Minecraft {{ selectedMCVersion }}.</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!initialSetup"
|
||||
class="flex w-full flex-col gap-2 rounded-2xl bg-table-alternateRow p-4"
|
||||
>
|
||||
<div class="flex w-full flex-row items-center justify-between">
|
||||
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
|
||||
Erase all data
|
||||
</label>
|
||||
<Toggle id="hard-reset" v-model="hardReset" class="shrink-0" />
|
||||
</div>
|
||||
<div>
|
||||
Removes all data on your server, including your worlds, mods, and configuration files,
|
||||
then reinstalls it with the selected version.
|
||||
</div>
|
||||
<div class="font-bold">This does not affect your backups, which are stored off-site.</div>
|
||||
</div>
|
||||
|
||||
<BackupWarning v-if="!initialSetup" :backup-link="`/hosting/manage/${serverId}/backups`" />
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-start gap-4">
|
||||
<ButtonStyled :color="isDangerous ? 'red' : 'brand'">
|
||||
<button
|
||||
v-tooltip="busyReasons.length > 0 ? formatMessage(busyReasons[0].reason) : undefined"
|
||||
:disabled="canInstall || busyReasons.length > 0"
|
||||
@click="handleReinstall"
|
||||
>
|
||||
<RightArrowIcon />
|
||||
{{
|
||||
isLoading
|
||||
? 'Installing...'
|
||||
: isSecondPhase
|
||||
? 'Erase and install'
|
||||
: hardReset
|
||||
? 'Continue'
|
||||
: 'Install'
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
:disabled="isLoading"
|
||||
@click="
|
||||
() => {
|
||||
if (isSecondPhase) {
|
||||
isSecondPhase = false
|
||||
} else {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
"
|
||||
>
|
||||
<XIcon />
|
||||
{{ isSecondPhase ? 'Go back' : 'Cancel' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DropdownIcon, RightArrowIcon, ServerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
BackupWarning,
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
NewModal,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { type Loaders, ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { $fetch } from 'ofetch'
|
||||
|
||||
import LoaderIcon from './icons/LoaderIcon.vue'
|
||||
import LoadingIcon from './icons/LoadingIcon.vue'
|
||||
|
||||
const { server, serverId, busyReasons } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
interface LoaderVersion {
|
||||
id: string
|
||||
stable: boolean
|
||||
loaders: {
|
||||
id: string
|
||||
url: string
|
||||
stable: boolean
|
||||
}[]
|
||||
}
|
||||
|
||||
type VersionMap = Record<string, LoaderVersion[]>
|
||||
type VersionCache = Record<string, any>
|
||||
|
||||
const props = defineProps<{
|
||||
currentLoader: Loaders | undefined
|
||||
initialSetup?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [any?]
|
||||
}>()
|
||||
|
||||
const versionSelectModal = ref()
|
||||
const isSecondPhase = ref(false)
|
||||
const hardReset = ref(false)
|
||||
const isLoading = ref(false)
|
||||
const loadingServerCheck = ref(false)
|
||||
const serverCheckError = ref('')
|
||||
const showSnapshots = ref(false)
|
||||
|
||||
const selectedLoader = ref<Loaders>('Vanilla')
|
||||
const selectedMCVersion = ref('')
|
||||
const selectedLoaderVersion = ref('')
|
||||
|
||||
const paperVersions = ref<Record<string, number[]>>({})
|
||||
const purpurVersions = ref<Record<string, string[]>>({})
|
||||
const loaderVersions = ref<VersionMap>({})
|
||||
const cachedVersions = ref<VersionCache>({})
|
||||
|
||||
const versionStrings = ['forge', 'fabric', 'quilt', 'neo'] as const
|
||||
|
||||
const isSnapshotSelected = computed(() => {
|
||||
if (selectedMCVersion.value) {
|
||||
const selected = tags.value.gameVersions.find((x) => x.version === selectedMCVersion.value)
|
||||
if (selected?.version_type !== 'release') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const getLoaderVersions = async (loader: string) => {
|
||||
return await $fetch(
|
||||
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/manifest.json`,
|
||||
)
|
||||
}
|
||||
|
||||
const fetchLoaderVersions = async () => {
|
||||
const versions = await Promise.all(
|
||||
versionStrings.map(async (loader) => {
|
||||
const runFetch = async (iterations: number) => {
|
||||
if (iterations > 5) {
|
||||
throw new Error('Failed to fetch loader versions')
|
||||
}
|
||||
try {
|
||||
const res = await getLoaderVersions(loader)
|
||||
return { [loader]: (res as any).gameVersions }
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (_) {
|
||||
return await runFetch(iterations + 1)
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await runFetch(0)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return { [loader]: [] }
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
loaderVersions.value = versions.reduce((acc, val) => ({ ...acc, ...val }), {})
|
||||
}
|
||||
|
||||
const fetchPaperVersions = async (mcVersion: string) => {
|
||||
try {
|
||||
const res = await $fetch(`https://fill.papermc.io/v3/projects/paper/versions/${mcVersion}`)
|
||||
paperVersions.value[mcVersion] = (res as any).builds.sort((a: number, b: number) => b - a)
|
||||
return res
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const fetchPurpurVersions = async (mcVersion: string) => {
|
||||
try {
|
||||
const res = await $fetch(`https://api.purpurmc.org/v2/purpur/${mcVersion}`)
|
||||
purpurVersions.value[mcVersion] = (res as any).builds.all.sort(
|
||||
(a: string, b: string) => parseInt(b) - parseInt(a),
|
||||
)
|
||||
return res
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const selectedLoaderVersions = computed<string[]>(() => {
|
||||
const loader = selectedLoader.value.toLowerCase()
|
||||
|
||||
if (loader === 'paper') {
|
||||
return paperVersions.value[selectedMCVersion.value]?.map((x) => `${x}`) || []
|
||||
}
|
||||
|
||||
if (loader === 'purpur') {
|
||||
return purpurVersions.value[selectedMCVersion.value] || []
|
||||
}
|
||||
|
||||
if (loader === 'vanilla') {
|
||||
return []
|
||||
}
|
||||
|
||||
let apiLoader = loader
|
||||
if (loader === 'neoforge') {
|
||||
apiLoader = 'neo'
|
||||
}
|
||||
|
||||
const backwardsCompatibleVersion = loaderVersions.value[apiLoader]?.find(
|
||||
(x) => x.id === '${modrinth.gameVersion}',
|
||||
)
|
||||
|
||||
if (backwardsCompatibleVersion) {
|
||||
return backwardsCompatibleVersion.loaders.map((x) => x.id)
|
||||
}
|
||||
|
||||
return (
|
||||
loaderVersions.value[apiLoader]
|
||||
?.find((x) => x.id === selectedMCVersion.value)
|
||||
?.loaders.map((x) => x.id) || []
|
||||
)
|
||||
})
|
||||
|
||||
watch(selectedLoader, async () => {
|
||||
if (selectedMCVersion.value) {
|
||||
selectedLoaderVersion.value = ''
|
||||
serverCheckError.value = ''
|
||||
|
||||
await checkVersionAvailability(selectedMCVersion.value)
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
selectedLoaderVersions,
|
||||
(newVersions) => {
|
||||
if (
|
||||
newVersions.length > 0 &&
|
||||
(!selectedLoaderVersion.value || !newVersions.includes(selectedLoaderVersion.value))
|
||||
) {
|
||||
selectedLoaderVersion.value = String(newVersions[0])
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const getLoaderVersion = async (loader: string, version: string) => {
|
||||
return await $fetch(
|
||||
`https://launcher-meta.modrinth.com/${loader?.toLowerCase()}/v0/versions/${version}.json`,
|
||||
)
|
||||
}
|
||||
|
||||
const checkVersionAvailability = async (version: string) => {
|
||||
if (!version || version.trim().length < 3) return
|
||||
|
||||
isLoading.value = true
|
||||
loadingServerCheck.value = true
|
||||
|
||||
try {
|
||||
const mcRes = cachedVersions.value[version] || (await getLoaderVersion('minecraft', version))
|
||||
|
||||
cachedVersions.value[version] = mcRes
|
||||
|
||||
if (!mcRes.downloads?.server) {
|
||||
serverCheckError.value = "We couldn't find a server.jar for this version."
|
||||
return
|
||||
}
|
||||
|
||||
const loader = selectedLoader.value.toLowerCase()
|
||||
if (loader === 'paper' || loader === 'purpur') {
|
||||
const fetchFn = loader === 'paper' ? fetchPaperVersions : fetchPurpurVersions
|
||||
const result = await fetchFn(version)
|
||||
if (!result) {
|
||||
serverCheckError.value = `This Minecraft version is not supported by ${loader}.`
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
serverCheckError.value = ''
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
serverCheckError.value = 'Failed to fetch versions.'
|
||||
} finally {
|
||||
loadingServerCheck.value = false
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(selectedMCVersion, checkVersionAvailability)
|
||||
|
||||
onMounted(() => {
|
||||
fetchLoaderVersions()
|
||||
})
|
||||
|
||||
const tags = useGeneratedState()
|
||||
const mcVersions = computed(() =>
|
||||
tags.value.gameVersions
|
||||
.filter((x) =>
|
||||
showSnapshots.value
|
||||
? x.version_type === 'snapshot' || x.version_type === 'release'
|
||||
: x.version_type === 'release',
|
||||
)
|
||||
.map((x) => x.version),
|
||||
)
|
||||
|
||||
const isDangerous = computed(() => hardReset.value)
|
||||
const canInstall = computed(() => {
|
||||
const conds =
|
||||
!selectedMCVersion.value ||
|
||||
isLoading.value ||
|
||||
loadingServerCheck.value ||
|
||||
serverCheckError.value.trim().length > 0
|
||||
|
||||
if (selectedLoader.value.toLowerCase() === 'vanilla') {
|
||||
return conds
|
||||
}
|
||||
|
||||
return conds || !selectedLoaderVersion.value
|
||||
})
|
||||
|
||||
const handleReinstall = async () => {
|
||||
if (hardReset.value && !isSecondPhase.value) {
|
||||
isSecondPhase.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isLoading.value = true
|
||||
|
||||
try {
|
||||
await client.archon.servers_v0.reinstall(
|
||||
serverId,
|
||||
{
|
||||
loader: selectedLoader.value,
|
||||
loader_version:
|
||||
selectedLoader.value === 'Vanilla' ? undefined : selectedLoaderVersion.value || undefined,
|
||||
game_version: selectedMCVersion.value,
|
||||
},
|
||||
props.initialSetup ? true : hardReset.value,
|
||||
)
|
||||
|
||||
emit('reinstall', {
|
||||
loader: selectedLoader.value,
|
||||
lVersion: selectedLoaderVersion.value,
|
||||
mVersion: selectedMCVersion.value,
|
||||
})
|
||||
|
||||
hide()
|
||||
} catch (error) {
|
||||
if (error instanceof ModrinthServersFetchError && (error as any)?.statusCode === 429) {
|
||||
addNotification({
|
||||
title: 'Cannot reinstall server',
|
||||
text: 'You are being rate limited. Please try again later.',
|
||||
type: 'error',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Reinstall Failed',
|
||||
text: 'An unexpected error occurred while reinstalling. Please try again later.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onShow = () => {
|
||||
selectedMCVersion.value = server.value?.mc_version || ''
|
||||
if (isSnapshotSelected.value) {
|
||||
showSnapshots.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const onHide = () => {
|
||||
hardReset.value = false
|
||||
isSecondPhase.value = false
|
||||
serverCheckError.value = ''
|
||||
loadingServerCheck.value = false
|
||||
isLoading.value = false
|
||||
selectedMCVersion.value = ''
|
||||
serverCheckError.value = ''
|
||||
paperVersions.value = {}
|
||||
purpurVersions.value = {}
|
||||
}
|
||||
|
||||
const show = (loader: Loaders) => {
|
||||
if (selectedLoader.value !== loader) {
|
||||
selectedLoaderVersion.value = ''
|
||||
}
|
||||
selectedLoader.value = loader
|
||||
selectedMCVersion.value = server.value?.mc_version || ''
|
||||
versionSelectModal.value?.show()
|
||||
}
|
||||
const hide = () => versionSelectModal.value?.hide()
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<Transition name="save-banner">
|
||||
<div
|
||||
v-if="props.isVisible"
|
||||
data-pyro-save-banner
|
||||
class="fixed bottom-16 left-0 right-0 z-[10] mx-auto h-fit w-full max-w-4xl transition-all duration-300 sm:bottom-8"
|
||||
>
|
||||
<div class="mx-2 rounded-2xl border-2 border-solid border-button-border bg-bg-raised p-4">
|
||||
<div class="flex flex-col items-center justify-between gap-2 md:flex-row">
|
||||
<span class="font-bold text-contrast">Careful, you have unsaved changes!</span>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="transparent" color="standard">
|
||||
<button :disabled="props.isUpdating" @click="props.reset">Reset</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="standard" :color="props.restart ? 'standard' : 'brand'">
|
||||
<button :disabled="props.isUpdating" @click="props.save">
|
||||
{{ props.isUpdating ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="props.restart" type="standard" color="brand">
|
||||
<button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
|
||||
{{ powerButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled, injectModrinthClient, injectModrinthServerContext } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isUpdating: boolean
|
||||
restart?: boolean
|
||||
save: () => 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 ? 'Save & start' : 'Save & restart'
|
||||
return isStopped.value ? 'Save & start' : 'Save & restart'
|
||||
})
|
||||
|
||||
const saveAndPower = async () => {
|
||||
props.save()
|
||||
await client.archon.servers_v0.power(props.serverId, isStopped.value ? 'Start' : 'Restart')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.save-banner-enter-active {
|
||||
transition:
|
||||
opacity 300ms,
|
||||
transform 300ms;
|
||||
}
|
||||
|
||||
.save-banner-leave-active {
|
||||
transition:
|
||||
opacity 200ms,
|
||||
transform 200ms;
|
||||
}
|
||||
|
||||
.save-banner-enter-from,
|
||||
.save-banner-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(100%) scale(0.98);
|
||||
}
|
||||
|
||||
.save-banner-enter-to,
|
||||
.save-banner-leave-from {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<div class="static w-full grid-cols-1 md:relative md:flex">
|
||||
<div class="static h-full flex-col pb-4 md:flex md:pb-0 md:pr-4">
|
||||
<div class="z-10 flex select-none flex-col gap-2 rounded-2xl bg-bg-raised p-4 md:w-[16rem]">
|
||||
<div
|
||||
v-for="link in navLinks.filter((x) => x.shown === undefined || x.shown)"
|
||||
:key="link.label"
|
||||
>
|
||||
<NuxtLink
|
||||
:to="link.href"
|
||||
class="flex items-center gap-2 rounded-xl p-2 hover:bg-button-bg"
|
||||
:class="{ 'bg-button-bg text-contrast': route.path === link.href }"
|
||||
>
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<component :is="link.icon" class="size-6" />
|
||||
{{ link.label }}
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<RightArrowIcon v-if="link.external" class="size-4" />
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="h-full w-full">
|
||||
<NuxtPage :route="route" @reinstall="onReinstall" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon } from '@modrinth/assets'
|
||||
import type { RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
const emit = defineEmits(['reinstall'])
|
||||
|
||||
defineProps<{
|
||||
navLinks: {
|
||||
label: string
|
||||
href: string
|
||||
icon: Component
|
||||
external?: boolean
|
||||
shown?: boolean
|
||||
}[]
|
||||
route: RouteLocationNormalized
|
||||
}>()
|
||||
|
||||
const onReinstall = (...args: any[]) => {
|
||||
emit('reinstall', ...args)
|
||||
}
|
||||
</script>
|
||||
@@ -1,256 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-stats
|
||||
style="font-variant-numeric: tabular-nums"
|
||||
class="flex select-none flex-col items-center gap-6 md:flex-row"
|
||||
:class="{ 'pointer-events-none': loading }"
|
||||
:aria-hidden="loading"
|
||||
>
|
||||
<div
|
||||
v-for="(metric, index) in metrics"
|
||||
:key="index"
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
>
|
||||
<div class="relative z-10 -ml-3 w-fit rounded-xl px-3 py-1">
|
||||
<div class="relative z-10">
|
||||
<div class="-mb-0.5 mt-0.5 flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 text-3xl font-extrabold text-contrast">
|
||||
{{ metric.value }}
|
||||
</h2>
|
||||
<h3 class="text-sm font-normal text-secondary">/ {{ metric.max }}</h3>
|
||||
</div>
|
||||
<h3 class="flex items-center gap-2 text-base font-normal text-secondary">
|
||||
{{ metric.title }}
|
||||
<IssuesIcon
|
||||
v-if="metric.warning && !loading"
|
||||
v-tooltip="metric.warning"
|
||||
class="size-5"
|
||||
:style="{ color: 'var(--color-orange)' }"
|
||||
/>
|
||||
</h3>
|
||||
</div>
|
||||
<div class="absolute -left-8 -top-4 h-28 w-56 rounded-full bg-bg-raised blur-lg" />
|
||||
</div>
|
||||
|
||||
<component
|
||||
:is="metric.icon"
|
||||
class="absolute right-10 top-10 z-10 size-8"
|
||||
style="width: 2rem; height: 2rem"
|
||||
/>
|
||||
|
||||
<div class="chart-space absolute bottom-0 left-0 right-0">
|
||||
<ClientOnly>
|
||||
<VueApexCharts
|
||||
v-if="metric.showGraph && !loading"
|
||||
type="area"
|
||||
height="142"
|
||||
:options="getChartOptions(metric.warning, index)"
|
||||
:series="[{ name: metric.title, data: metric.data }]"
|
||||
class="chart"
|
||||
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
<nuxt-link
|
||||
:to="loading ? undefined : `/hosting/manage/${serverId}/files`"
|
||||
class="relative isolate min-h-[156px] w-full overflow-hidden rounded-2xl bg-bg-raised p-8"
|
||||
:class="loading ? '' : 'transition-transform duration-100 hover:scale-105 active:scale-100'"
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<h2 class="m-0 -ml-0.5 mt-1 text-3xl font-extrabold text-contrast">
|
||||
{{ loading ? '0 B' : formatBytes(stats.storage_usage_bytes) }}
|
||||
</h2>
|
||||
</div>
|
||||
<h3 class="text-base font-normal text-secondary">Storage usage</h3>
|
||||
<FolderOpenIcon class="absolute right-10 top-10 size-8" />
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CpuIcon, DatabaseIcon, FolderOpenIcon, IssuesIcon } from '@modrinth/assets'
|
||||
import type { Stats } from '@modrinth/utils'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const route = useNativeRoute()
|
||||
const serverId = route.params.id
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
const chartsReady = ref(new Set<number>())
|
||||
|
||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
||||
ramAsNumber: false,
|
||||
})
|
||||
|
||||
const props = withDefaults(defineProps<{ data?: Stats; loading?: boolean }>(), {
|
||||
loading: false,
|
||||
})
|
||||
|
||||
const stats = shallowRef(
|
||||
props.data?.current || {
|
||||
cpu_percent: 0,
|
||||
ram_usage_bytes: 0,
|
||||
ram_total_bytes: 1, // Avoid division by zero
|
||||
storage_usage_bytes: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const onChartReady = (index: number) => {
|
||||
chartsReady.value.add(index)
|
||||
}
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
let value = bytes
|
||||
let unit = 0
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024
|
||||
unit++
|
||||
}
|
||||
return `${Math.round(value * 10) / 10} ${units[unit]}`
|
||||
}
|
||||
|
||||
const cpuData = ref<number[]>(Array(20).fill(0))
|
||||
const ramData = ref<number[]>(Array(20).fill(0))
|
||||
|
||||
const updateGraphData = (arr: number[], newValue: number) => {
|
||||
arr.push(newValue)
|
||||
arr.shift()
|
||||
}
|
||||
|
||||
const metrics = computed(() => {
|
||||
if (props.loading) {
|
||||
return [
|
||||
{
|
||||
title: 'CPU usage',
|
||||
value: '0.00%',
|
||||
max: '100%',
|
||||
icon: CpuIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: false,
|
||||
warning: null,
|
||||
},
|
||||
{
|
||||
title: 'Memory usage',
|
||||
value: '0.00%',
|
||||
max: '100%',
|
||||
icon: DatabaseIcon,
|
||||
data: ramData.value,
|
||||
showGraph: false,
|
||||
warning: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const ramPercent = Math.min(
|
||||
(stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100,
|
||||
100,
|
||||
)
|
||||
const cpuPercent = Math.min(stats.value.cpu_percent, 100)
|
||||
|
||||
updateGraphData(cpuData.value, cpuPercent)
|
||||
updateGraphData(ramData.value, ramPercent)
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'CPU usage',
|
||||
value: `${cpuPercent.toFixed(2)}%`,
|
||||
max: '100%',
|
||||
icon: CpuIcon,
|
||||
data: cpuData.value,
|
||||
showGraph: true,
|
||||
warning: cpuPercent >= 90 ? 'CPU usage is very high' : null,
|
||||
},
|
||||
{
|
||||
title: 'Memory usage',
|
||||
value:
|
||||
userPreferences.value.ramAsNumber || flags.developerMode
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.toFixed(2)}%`,
|
||||
max:
|
||||
userPreferences.value.ramAsNumber || flags.developerMode
|
||||
? formatBytes(stats.value.ram_total_bytes)
|
||||
: '100%',
|
||||
icon: DatabaseIcon,
|
||||
data: ramData.value,
|
||||
showGraph: true,
|
||||
warning: ramPercent >= 90 ? 'Memory usage is very high' : null,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const getChartOptions = (hasWarning: string | null, index: number) => ({
|
||||
chart: {
|
||||
type: 'area',
|
||||
animations: { enabled: false },
|
||||
sparkline: { enabled: true },
|
||||
toolbar: { show: false },
|
||||
padding: {
|
||||
left: -10,
|
||||
right: -10,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
events: {
|
||||
mounted: () => onChartReady(index),
|
||||
updated: () => onChartReady(index),
|
||||
},
|
||||
},
|
||||
stroke: { curve: 'smooth', width: 3 },
|
||||
fill: {
|
||||
type: 'gradient',
|
||||
gradient: {
|
||||
shadeIntensity: 1,
|
||||
opacityFrom: 0.25,
|
||||
opacityTo: 0.05,
|
||||
stops: [0, 100],
|
||||
},
|
||||
},
|
||||
tooltip: { enabled: false },
|
||||
grid: { show: false },
|
||||
xaxis: {
|
||||
labels: { show: false },
|
||||
axisBorder: { show: false },
|
||||
type: 'numeric',
|
||||
tickAmount: 20,
|
||||
range: 20,
|
||||
},
|
||||
yaxis: {
|
||||
show: false,
|
||||
min: 0,
|
||||
max: 100,
|
||||
forceNiceScale: false,
|
||||
},
|
||||
colors: [hasWarning ? 'var(--color-orange)' : 'var(--color-brand)'],
|
||||
dataLabels: {
|
||||
enabled: false,
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.data?.current,
|
||||
(newStats) => {
|
||||
if (newStats) {
|
||||
stats.value = newStats
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chart-space {
|
||||
height: 142px;
|
||||
width: calc(100% + 48px);
|
||||
margin-left: -24px;
|
||||
margin-right: -24px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100% !important;
|
||||
height: 142px !important;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
@@ -1,438 +0,0 @@
|
||||
<template>
|
||||
<div data-pyro-telepopover-wrapper class="relative">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
class="teleport-overflow-menu-trigger"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="transform scale-75 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-125 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-75 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<template
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||
>
|
||||
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</button>
|
||||
<nuxt-link
|
||||
v-else-if="typeof option.action === 'string' && option.action.startsWith('/')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</nuxt-link>
|
||||
<a
|
||||
v-else-if="typeof option.action === 'string' && !option.action.startsWith('http')"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
:href="option.action"
|
||||
target="_blank"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</a>
|
||||
<span v-else>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { onClickOutside, useElementHover } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
interface Option {
|
||||
id: string
|
||||
action?: (() => void) | string
|
||||
shown?: boolean
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
divider: true
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
type Item = Option | Divider
|
||||
|
||||
function isDivider(item: Item): item is Divider {
|
||||
return (item as Divider).divider
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Item[]
|
||||
hoverable?: boolean
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', option: Option): void
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const selectedIndex = ref(-1)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isMouseDown = ref(false)
|
||||
const typeAheadBuffer = ref('')
|
||||
const typeAheadTimeout = ref<number | null>(null)
|
||||
const menuItemsRef = ref<HTMLElement[]>([])
|
||||
|
||||
const hoveringTrigger = useElementHover(triggerRef)
|
||||
const hoveringMenu = useElementHover(menuRef)
|
||||
|
||||
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
|
||||
|
||||
const menuStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
|
||||
|
||||
const calculateMenuPosition = () => {
|
||||
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const menuRect = menuRef.value.getBoundingClientRect()
|
||||
const menuWidth = menuRect.width
|
||||
const menuHeight = menuRect.height
|
||||
const margin = 8
|
||||
|
||||
let top: number
|
||||
let left: number
|
||||
|
||||
// okay gang lets calculate this shit
|
||||
// from the top now yall
|
||||
// y
|
||||
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
|
||||
top = triggerRect.bottom + margin
|
||||
} else if (triggerRect.top - menuHeight - margin >= 0) {
|
||||
top = triggerRect.top - menuHeight - margin
|
||||
} else {
|
||||
top = Math.max(margin, window.innerHeight - menuHeight - margin)
|
||||
}
|
||||
|
||||
// x
|
||||
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
|
||||
left = triggerRect.left
|
||||
} else if (triggerRect.right - menuWidth - margin >= 0) {
|
||||
left = triggerRect.right - menuWidth
|
||||
} else {
|
||||
left = Math.max(margin, window.innerWidth - menuWidth - margin)
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (!props.hoverable) {
|
||||
if (isOpen.value) {
|
||||
closeMenu()
|
||||
} else {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openMenu = () => {
|
||||
isOpen.value = true
|
||||
disableBodyScroll()
|
||||
nextTick(() => {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
focusFirstMenuItem()
|
||||
})
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen.value = false
|
||||
selectedIndex.value = -1
|
||||
enableBodyScroll()
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
|
||||
const selectOption = (option: Option) => {
|
||||
emit('select', option)
|
||||
if (typeof option.action === 'function') {
|
||||
option.action()
|
||||
}
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
isMouseDown.value = true
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (props.hoverable) {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.hoverable) {
|
||||
setTimeout(() => {
|
||||
if (!hovering.value) {
|
||||
closeMenu()
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!isOpen.value || !isMouseDown.value) return
|
||||
|
||||
const menuRect = menuRef.value?.getBoundingClientRect()
|
||||
if (!menuRect) return
|
||||
|
||||
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
|
||||
if (!menuItems) return
|
||||
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
|
||||
if (
|
||||
event.clientX >= itemRect.left &&
|
||||
event.clientX <= itemRect.right &&
|
||||
event.clientY >= itemRect.top &&
|
||||
event.clientY <= itemRect.bottom
|
||||
) {
|
||||
selectedIndex.value = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (option: Option, index: number) => {
|
||||
selectedIndex.value = index
|
||||
selectOption(option)
|
||||
}
|
||||
|
||||
const handleMouseOver = (index: number) => {
|
||||
selectedIndex.value = index
|
||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
||||
}
|
||||
|
||||
// Scrolling is disabled for keyboard navigation
|
||||
const disableBodyScroll = () => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const enableBodyScroll = () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const focusFirstMenuItem = () => {
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
menuItemsRef.value[0].focus?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isOpen.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openMenu()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = 0
|
||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
||||
}
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = filteredOptions.value.length - 1
|
||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
if (selectedIndex.value >= 0) {
|
||||
const option = filteredOptions.value[selectedIndex.value]
|
||||
if (isDivider(option)) break
|
||||
selectOption(option)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeMenu()
|
||||
triggerRef.value?.focus?.()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
if (event.shiftKey) {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
} else {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||
}
|
||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
typeAheadBuffer.value += event.key.toLowerCase()
|
||||
const matchIndex = filteredOptions.value.findIndex(
|
||||
(option) =>
|
||||
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
)
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex
|
||||
menuItemsRef.value[selectedIndex.value].focus?.()
|
||||
}
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
typeAheadTimeout.value = setTimeout(() => {
|
||||
typeAheadBuffer.value = ''
|
||||
}, 1000) as unknown as number
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleResizeOrScroll = () => {
|
||||
if (isOpen.value) {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
}
|
||||
}
|
||||
|
||||
const throttle = (func: (...args: any[]) => void, limit: number): ((...args: any[]) => void) => {
|
||||
let inThrottle: boolean
|
||||
return function (...args: any[]) {
|
||||
if (!inThrottle) {
|
||||
func(...args)
|
||||
inThrottle = true
|
||||
setTimeout(() => (inThrottle = false), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
|
||||
|
||||
onMounted(() => {
|
||||
triggerRef.value?.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.addEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
triggerRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
enableBodyScroll()
|
||||
})
|
||||
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
nextTick(() => {
|
||||
menuRef.value?.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
} else {
|
||||
menuRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onClickOutside(menuRef, (event) => {
|
||||
if (!triggerRef.value?.contains(event.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-chevron-down"
|
||||
>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-chevron-up"
|
||||
>
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M10 12.5 8 15l2 2.5" />
|
||||
<path d="m14 12.5 2 2.5-2 2.5" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,26 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<path
|
||||
d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v3.3"
|
||||
/>
|
||||
<path d="m21.7 19.4-.9-.3" />
|
||||
<path d="m15.2 16.9-.9-.3" />
|
||||
<path d="m16.6 21.7.3-.9" />
|
||||
<path d="m19.1 15.2.3-.9" />
|
||||
<path d="m19.6 21.7-.4-1" />
|
||||
<path d="m16.8 15.3-.4-1" />
|
||||
<path d="m14.3 19.6 1-.4" />
|
||||
<path d="m20.7 16.8 1-.4" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M21.54 15H17a2 2 0 0 0-2 2v4.54" />
|
||||
<path
|
||||
d="M7 3.34V5a3 3 0 0 0 3 3a2 2 0 0 1 2 2c0 1.1.9 2 2 2a2 2 0 0 0 2-2c0-1.1.9-2 2-2h3.17"
|
||||
/>
|
||||
<path d="M11 21.95V18a2 2 0 0 0-2-2a2 2 0 0 1-2-2v-1a2 2 0 0 0-2-2H2.05" />
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-5"
|
||||
>
|
||||
<path d="m21 21-6-6m6 6v-4.8m0 4.8h-4.8" />
|
||||
<path d="M3 16.2V21m0 0h4.8M3 21l6-6" />
|
||||
<path d="M21 7.8V3m0 0h-4.8M21 3l-6 6" />
|
||||
<path d="M3 7.8V3m0 0h4.8M3 3l6 6" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,18 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<circle cx="10" cy="12" r="2" />
|
||||
<path d="m20 17-1.296-1.296a2.41 2.41 0 0 0-3.408 0L9 22" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,15 +0,0 @@
|
||||
<template>
|
||||
<component :is="icon" v-if="icon" />
|
||||
<LoaderIcon v-else />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getLoaderIcon, LoaderIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
loader: string
|
||||
}>()
|
||||
|
||||
const icon = computed(() => getLoaderIcon(props.loader))
|
||||
</script>
|
||||
@@ -1,9 +0,0 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M15.312 11.424a5.5 5.5 0 0 1-9.201 2.466l-.312-.311h2.433a.75.75 0 0 0 0-1.5H3.989a.75.75 0 0 0-.75.75v4.242a.75.75 0 0 0 1.5 0v-2.43l.31.31a7 7 0 0 0 11.712-3.138.75.75 0 0 0-1.449-.39Zm1.23-3.723a.75.75 0 0 0 .219-.53V2.929a.75.75 0 0 0-1.5 0V5.36l-.31-.31A7 7 0 0 0 3.239 8.188a.75.75 0 1 0 1.448.389A5.5 5.5 0 0 1 13.89 6.11l.311.31h-2.432a.75.75 0 0 0 0 1.5h4.243a.75.75 0 0 0 .53-.219Z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,19 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="size-5"
|
||||
>
|
||||
<path d="m15 15 6 6m-6-6v4.8m0-4.8h4.8" />
|
||||
<path d="M9 19.8V15m0 0H4.2M9 15l-6 6" />
|
||||
<path d="M15 4.2V9m0 0h4.8M15 9l6-6" />
|
||||
<path d="M9 4.2V9m0 0H4.2M9 9 3 3" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,16 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="size-8 text-[#FF496E]"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,10 +0,0 @@
|
||||
<template>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32">
|
||||
<path
|
||||
d="M22 5L9 28"
|
||||
stroke="currentColor"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="lucide lucide-file-text"
|
||||
>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4" />
|
||||
<path d="M10 9H8" />
|
||||
<path d="M16 13H8" />
|
||||
<path d="M16 17H8" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,17 +0,0 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="10" x2="14" y1="2" y2="2" />
|
||||
<line x1="12" x2="15" y1="14" y2="11" />
|
||||
<circle cx="12" cy="14" r="8" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,158 +0,0 @@
|
||||
<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" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClockIcon, RocketIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, MedalBackgroundImage } from '@modrinth/ui'
|
||||
import type { UserSubscription } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import dayjsDuration from 'dayjs/plugin/duration'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
|
||||
import ServersUpgradeModalWrapper from '../ServersUpgradeModalWrapper.vue'
|
||||
|
||||
dayjs.extend(dayjsDuration)
|
||||
|
||||
type UpgradeWrapperRef = ComponentPublicInstance<{ open: (id?: string) => void | Promise<void> }>
|
||||
const upgradeModal = ref<UpgradeWrapperRef | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
serverId?: string
|
||||
}>()
|
||||
|
||||
const { data: subscriptions } = await useLazyAsyncData(
|
||||
'countdown-subscriptions',
|
||||
() =>
|
||||
useBaseFetch(`billing/subscriptions`, {
|
||||
internal: true,
|
||||
}) as Promise<UserSubscription[]>,
|
||||
)
|
||||
|
||||
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 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { SettingsIcon } from '@modrinth/assets'
|
||||
import {
|
||||
CopyCode,
|
||||
getDismissableMetadata,
|
||||
NOTICE_LEVELS,
|
||||
ServerNotice,
|
||||
TagItem,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { ServerNotice as ServerNoticeType } from '@modrinth/utils'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'long',
|
||||
})
|
||||
const formatDateTimeShortMonth = useFormatDateTime({
|
||||
timeStyle: 'short',
|
||||
dateStyle: 'medium',
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
notice: ServerNoticeType
|
||||
}>()
|
||||
</script>
|
||||
<template>
|
||||
<div class="col-span-full grid grid-cols-subgrid gap-4 rounded-2xl bg-bg-raised p-4">
|
||||
<div class="col-span-full grid grid-cols-subgrid items-center gap-4">
|
||||
<div>
|
||||
<CopyCode :text="`${notice.id}`" />
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span v-if="notice.announce_at">
|
||||
{{ formatDateTimeShortMonth(notice.announce_at) }} ({{
|
||||
formatRelativeTime(notice.announce_at)
|
||||
}})
|
||||
</span>
|
||||
<template v-else> Never begins </template>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<span v-if="notice.expires" v-tooltip="formatDateTime(notice.expires)">
|
||||
{{ formatRelativeTime(notice.expires) }}
|
||||
</span>
|
||||
<template v-else> Never expires </template>
|
||||
</div>
|
||||
<div
|
||||
:style="
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? {
|
||||
'--_color': NOTICE_LEVELS[notice.level].colors.text,
|
||||
'--_bg-color': NOTICE_LEVELS[notice.level].colors.bg,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
>
|
||||
<TagItem>
|
||||
{{
|
||||
NOTICE_LEVELS[notice.level]
|
||||
? formatMessage(NOTICE_LEVELS[notice.level].name)
|
||||
: notice.level
|
||||
}}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div
|
||||
:style="{
|
||||
'--_color': getDismissableMetadata(notice.dismissable).colors.text,
|
||||
'--_bg-color': getDismissableMetadata(notice.dismissable).colors.bg,
|
||||
}"
|
||||
>
|
||||
<TagItem>
|
||||
{{ formatMessage(getDismissableMetadata(notice.dismissable).name) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
<div class="col-span-2 flex gap-2 md:col-span-1">
|
||||
<!-- <ButtonStyled>
|
||||
<button @click="() => startEditing(notice)">
|
||||
<EditIcon /> {{ formatMessage(commonMessages.editButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="() => deleteNotice(notice)">
|
||||
<TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled> -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-full grid">
|
||||
<ServerNotice
|
||||
:level="notice.level"
|
||||
:message="notice.message"
|
||||
:dismissable="notice.dismissable"
|
||||
:title="notice.title"
|
||||
preview
|
||||
/>
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span v-if="!notice.assigned || notice.assigned.length === 0"
|
||||
>Not assigned to any servers</span
|
||||
>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'server')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === 'node').length }} nodes
|
||||
</span>
|
||||
<span v-else-if="!notice.assigned.some((n) => n.kind === 'node')">
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === 'server').length }} servers
|
||||
</span>
|
||||
<span v-else>
|
||||
Assigned to
|
||||
{{ notice.assigned.filter((n) => n.kind === 'server').length }} servers and
|
||||
{{ notice.assigned.filter((n) => n.kind === 'node').length }} nodes
|
||||
</span>
|
||||
•
|
||||
<button
|
||||
class="m-0 flex items-center gap-1 border-none bg-transparent p-0 text-blue hover:underline hover:brightness-125 active:scale-95 active:brightness-150"
|
||||
@click="() => startEditing(notice, true)"
|
||||
>
|
||||
<SettingsIcon />
|
||||
Edit assignments
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -74,7 +74,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts" generic="T">
|
||||
import { MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
|
||||
import { ChevronDownIcon, MessageIcon, ReplyIcon, SendIcon } from '@modrinth/assets'
|
||||
import type { QuickReply } from '@modrinth/moderation'
|
||||
import {
|
||||
ButtonStyled,
|
||||
@@ -90,7 +90,6 @@ import dayjs from 'dayjs'
|
||||
import { useImageUpload } from '~/composables/image-upload.ts'
|
||||
import { isStaff } from '~/helpers/users.js'
|
||||
|
||||
import ChevronDownIcon from '../servers/icons/ChevronDownIcon.vue'
|
||||
import ThreadMessage from './ThreadMessage.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
Reference in New Issue
Block a user