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,4 +1,7 @@
|
||||
export * from './shared/browse-tab'
|
||||
export * from './shared/console'
|
||||
export * from './shared/content-tab'
|
||||
export * from './shared/files-tab'
|
||||
export * from './shared/installation-settings'
|
||||
export * from './shared/server-settings'
|
||||
export * from './wrapped'
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './use-browse-search'
|
||||
@@ -0,0 +1,311 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
|
||||
import { useSearch } from '#ui/utils/search'
|
||||
import { useServerSearch } from '#ui/utils/server-search'
|
||||
|
||||
import type { BrowseSearchResponse } from '../types'
|
||||
|
||||
export interface UseBrowseSearchOptions {
|
||||
projectType: Ref<string>
|
||||
tags: Ref<{
|
||||
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||
loaders: Labrinth.Tags.v2.Loader[]
|
||||
categories: Labrinth.Tags.v2.Category[]
|
||||
}>
|
||||
providedFilters?: ComputedRef<FilterValue[]>
|
||||
search: (params: string) => Promise<BrowseSearchResponse>
|
||||
persistentQueryParams: string[]
|
||||
getExtraQueryParams?: () => Record<string, string | undefined>
|
||||
maxResultsOptions?: ComputedRef<number[]>
|
||||
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
|
||||
}
|
||||
|
||||
export interface BrowseSearchState {
|
||||
query: Ref<string>
|
||||
|
||||
filters: ComputedRef<FilterType[]>
|
||||
currentFilters: Ref<FilterValue[]>
|
||||
toggledGroups: Ref<string[]>
|
||||
overriddenProvidedFilterTypes: Ref<string[]>
|
||||
|
||||
serverFilterTypes: ComputedRef<FilterType[]>
|
||||
serverCurrentFilters: Ref<FilterValue[]>
|
||||
serverToggledGroups: Ref<string[]>
|
||||
|
||||
effectiveSortTypes: ComputedRef<readonly SortType[]>
|
||||
effectiveCurrentSortType: Ref<SortType>
|
||||
|
||||
loading: Ref<boolean>
|
||||
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
|
||||
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
|
||||
totalHits: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
|
||||
maxResults: Ref<number>
|
||||
currentPage: Ref<number>
|
||||
|
||||
isServerType: ComputedRef<boolean>
|
||||
effectiveLayout: ComputedRef<'list' | 'grid'>
|
||||
deprioritizedTags: ComputedRef<string[]>
|
||||
excludeLoaders: ComputedRef<boolean>
|
||||
|
||||
refreshSearch: () => Promise<void>
|
||||
setPage: (page: number) => Promise<void>
|
||||
clearSearch: () => void
|
||||
onFilterChange: () => void
|
||||
}
|
||||
|
||||
const LOADER_FILTER_TYPES = [
|
||||
'mod_loader',
|
||||
'plugin_loader',
|
||||
'modpack_loader',
|
||||
'shader_loader',
|
||||
'plugin_platform',
|
||||
] as const
|
||||
|
||||
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
|
||||
const debug = useDebugLogger('BrowseSearch')
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
debug('init, projectType:', options.projectType.value)
|
||||
|
||||
const projectTypes = computed(() => [options.projectType.value] as ProjectType[])
|
||||
const isServerType = computed(() => options.projectType.value === 'server')
|
||||
|
||||
const {
|
||||
query,
|
||||
currentSortType,
|
||||
currentFilters,
|
||||
toggledGroups,
|
||||
maxResults,
|
||||
currentPage,
|
||||
overriddenProvidedFilterTypes,
|
||||
filters,
|
||||
sortTypes,
|
||||
requestParams,
|
||||
createPageParams,
|
||||
} = useSearch(projectTypes, options.tags, options.providedFilters ?? computed(() => []))
|
||||
|
||||
const {
|
||||
serverCurrentSortType,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
serverSortTypes,
|
||||
serverFilterTypes,
|
||||
serverRequestParams,
|
||||
createServerPageParams,
|
||||
} = useServerSearch({ tags: options.tags, query, maxResults, currentPage })
|
||||
|
||||
const effectiveRequestParams = computed(() =>
|
||||
isServerType.value ? serverRequestParams.value : requestParams.value,
|
||||
)
|
||||
const effectiveSortTypes = computed(() =>
|
||||
isServerType.value ? (serverSortTypes as readonly SortType[]) : sortTypes,
|
||||
)
|
||||
const effectiveCurrentSortType = computed({
|
||||
get: () => (isServerType.value ? serverCurrentSortType.value : currentSortType.value),
|
||||
set: (v: SortType) => {
|
||||
if (isServerType.value) serverCurrentSortType.value = v
|
||||
else currentSortType.value = v
|
||||
},
|
||||
})
|
||||
|
||||
const effectiveMaxResultsOptions = computed(
|
||||
() => options.maxResultsOptions?.value ?? [5, 10, 15, 20, 50, 100],
|
||||
)
|
||||
|
||||
watch(effectiveMaxResultsOptions, (opts) => {
|
||||
if (!opts.includes(maxResults.value)) {
|
||||
maxResults.value = opts.reduce((prev, curr) =>
|
||||
Math.abs(curr - maxResults.value) <= Math.abs(prev - maxResults.value) ? curr : prev,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const effectiveDisplayMode = computed(() => options.displayMode?.value ?? 'list')
|
||||
const effectiveLayout = computed<'list' | 'grid'>(() =>
|
||||
effectiveDisplayMode.value === 'grid' || effectiveDisplayMode.value === 'gallery'
|
||||
? 'grid'
|
||||
: 'list',
|
||||
)
|
||||
|
||||
const selectedFilterTags = computed(() =>
|
||||
currentFilters.value
|
||||
.filter(
|
||||
(f) =>
|
||||
f.type.startsWith('category_') ||
|
||||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
|
||||
)
|
||||
.map((f) => f.option),
|
||||
)
|
||||
const excludeLoaders = computed(
|
||||
() =>
|
||||
currentFilters.value.some((f) =>
|
||||
LOADER_FILTER_TYPES.includes(f.type as (typeof LOADER_FILTER_TYPES)[number]),
|
||||
) || ['resourcepack', 'datapack'].includes(options.projectType.value),
|
||||
)
|
||||
const loadersNotForThisType = computed(
|
||||
() =>
|
||||
options.tags.value?.loaders
|
||||
?.filter((loader) => !loader.supported_project_types.includes(options.projectType.value))
|
||||
?.map((loader) => loader.name) ?? [],
|
||||
)
|
||||
const deprioritizedTags = computed(() => [
|
||||
...selectedFilterTags.value,
|
||||
...loadersNotForThisType.value,
|
||||
])
|
||||
|
||||
const loading = ref(true)
|
||||
const projectHits = shallowRef<BrowseSearchResponse['projectHits']>([])
|
||||
const serverHits = shallowRef<BrowseSearchResponse['serverHits']>([])
|
||||
const totalHits = ref(0)
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (totalHits.value === 0) return 1
|
||||
return Math.ceil(totalHits.value / maxResults.value)
|
||||
})
|
||||
|
||||
let searchVersion = 0
|
||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
watch(effectiveRequestParams, (newVal, oldVal) => {
|
||||
debug('effectiveRequestParams changed', {
|
||||
from: oldVal?.substring(0, 80),
|
||||
to: newVal?.substring(0, 80),
|
||||
})
|
||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
||||
searchDebounceTimer = setTimeout(() => {
|
||||
refreshSearch()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
async function refreshSearch() {
|
||||
const version = ++searchVersion
|
||||
debug('refreshSearch start', {
|
||||
version,
|
||||
projectType: options.projectType.value,
|
||||
params: effectiveRequestParams.value.substring(0, 100),
|
||||
})
|
||||
|
||||
const currentHitsEmpty = isServerType.value
|
||||
? serverHits.value.length === 0
|
||||
: projectHits.value.length === 0
|
||||
if (currentHitsEmpty) {
|
||||
loading.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await options.search(effectiveRequestParams.value)
|
||||
|
||||
if (version !== searchVersion) {
|
||||
debug('refreshSearch stale, discarding', { version, current: searchVersion })
|
||||
return
|
||||
}
|
||||
|
||||
if (isServerType.value) {
|
||||
serverHits.value = response.serverHits
|
||||
} else {
|
||||
projectHits.value = response.projectHits
|
||||
}
|
||||
totalHits.value = response.total_hits
|
||||
debug('refreshSearch complete', {
|
||||
version,
|
||||
hits: response.total_hits,
|
||||
projectHits: response.projectHits.length,
|
||||
serverHits: response.serverHits.length,
|
||||
})
|
||||
|
||||
updateUrlParams()
|
||||
loading.value = false
|
||||
} catch (err) {
|
||||
debug('refreshSearch error', err)
|
||||
console.error('Browse search error:', err)
|
||||
if (version === searchVersion) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrlParams() {
|
||||
debug('updateUrlParams', { path: route.path })
|
||||
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(route.query)) {
|
||||
if (options.persistentQueryParams.includes(key)) {
|
||||
persistentParams[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const extraParams = options.getExtraQueryParams?.() ?? {}
|
||||
for (const [key, value] of Object.entries(extraParams)) {
|
||||
if (value !== undefined) {
|
||||
persistentParams[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
const params = {
|
||||
...persistentParams,
|
||||
...(isServerType.value ? createServerPageParams() : createPageParams()),
|
||||
}
|
||||
|
||||
router.replace({ path: route.path, query: params })
|
||||
}
|
||||
|
||||
async function setPage(newPageNumber: number) {
|
||||
currentPage.value = newPageNumber
|
||||
await nextTick()
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
query.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
function onFilterChange() {
|
||||
nextTick(() => window.scrollTo({ top: 0, behavior: 'smooth' }))
|
||||
}
|
||||
|
||||
watch(
|
||||
() => options.projectType.value,
|
||||
(newType, oldType) => {
|
||||
debug('projectType changed', { from: oldType, to: newType })
|
||||
currentSortType.value = { display: 'Relevance', name: 'relevance' }
|
||||
query.value = ''
|
||||
},
|
||||
)
|
||||
|
||||
return {
|
||||
query,
|
||||
filters,
|
||||
currentFilters,
|
||||
toggledGroups,
|
||||
overriddenProvidedFilterTypes,
|
||||
serverFilterTypes,
|
||||
serverCurrentFilters,
|
||||
serverToggledGroups,
|
||||
effectiveSortTypes,
|
||||
effectiveCurrentSortType,
|
||||
loading,
|
||||
projectHits,
|
||||
serverHits,
|
||||
totalHits,
|
||||
pageCount,
|
||||
maxResults,
|
||||
currentPage,
|
||||
isServerType,
|
||||
effectiveLayout,
|
||||
deprioritizedTags,
|
||||
excludeLoaders,
|
||||
refreshSearch,
|
||||
setPage,
|
||||
clearSearch,
|
||||
onFilterChange,
|
||||
}
|
||||
}
|
||||
63
packages/ui/src/layouts/shared/browse-tab/header.vue
Normal file
63
packages/ui/src/layouts/shared/browse-tab/header.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { GameIcon, LeftArrowIcon, MinecraftServerIcon } from '@modrinth/assets'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import ContentPageHeader from '#ui/components/base/ContentPageHeader.vue'
|
||||
import { useServerImage } from '#ui/composables/use-server-image'
|
||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const MEDAL_ICON_URL = 'https://cdn-raw.modrinth.com/medal_icon.webp'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const router = useRouter()
|
||||
const installContext = computed(() => ctx.installContext?.value ?? null)
|
||||
|
||||
const serverId = computed(() => installContext.value?.serverId ?? '')
|
||||
const upstream = computed(() => installContext.value?.upstream ?? null)
|
||||
|
||||
const { image: fetchedIcon } = useServerImage(serverId, upstream, {
|
||||
enabled: computed(() => !!installContext.value?.serverId),
|
||||
})
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
if (installContext.value?.isMedal) return MEDAL_ICON_URL
|
||||
return fetchedIcon.value ?? installContext.value?.iconSrc ?? MinecraftServerIcon
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="installContext">
|
||||
<ContentPageHeader class="mb-2">
|
||||
<template #icon>
|
||||
<Avatar :src="iconSrc" size="64px" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ installContext.name }}
|
||||
</template>
|
||||
<template #summary>
|
||||
<span class="flex items-center gap-2 text-sm font-semibold text-secondary">
|
||||
<GameIcon class="h-5 w-5 text-secondary" />
|
||||
{{ formatLoaderLabel(installContext.loader) }} {{ installContext.gameVersion }}
|
||||
</span>
|
||||
</template>
|
||||
<template #actions>
|
||||
<ButtonStyled>
|
||||
<button @click="router.push(installContext.backUrl)">
|
||||
<LeftArrowIcon />
|
||||
{{ installContext.backLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
<h1 class="m-0 mb-1 text-xl font-extrabold">{{ installContext.heading }}</h1>
|
||||
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
|
||||
{{ installContext.warning }}
|
||||
</Admonition>
|
||||
</template>
|
||||
</template>
|
||||
6
packages/ui/src/layouts/shared/browse-tab/index.ts
Normal file
6
packages/ui/src/layouts/shared/browse-tab/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './composables'
|
||||
export { default as BrowseInstallHeader } from './header.vue'
|
||||
export { default as BrowsePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export { default as BrowseSidebar } from './sidebar.vue'
|
||||
export * from './types'
|
||||
262
packages/ui/src/layouts/shared/browse-tab/layout.vue
Normal file
262
packages/ui/src/layouts/shared/browse-tab/layout.vue
Normal file
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { SearchIcon } from '@modrinth/assets'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
||||
import NavTabs from '#ui/components/base/NavTabs.vue'
|
||||
import Pagination from '#ui/components/base/Pagination.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
|
||||
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
|
||||
import SearchFilterControl from '#ui/components/search/SearchFilterControl.vue'
|
||||
import type { SortType } from '#ui/utils/search'
|
||||
|
||||
import BrowseInstallHeader from './header.vue'
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
|
||||
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
|
||||
ctx.effectiveSortTypes.value.map((st) => ({
|
||||
value: st,
|
||||
label: st.display,
|
||||
})),
|
||||
)
|
||||
|
||||
const maxResultsOptions = computed<ComboboxOption<number>[]>(() =>
|
||||
(ctx.maxResultsOptions?.value ?? [5, 10, 15, 20, 50, 100]).map((n) => ({
|
||||
value: n,
|
||||
label: String(n),
|
||||
})),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
|
||||
<BrowseInstallHeader />
|
||||
</template>
|
||||
|
||||
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
|
||||
|
||||
<StyledInput
|
||||
v-model="ctx.query.value"
|
||||
:icon="SearchIcon"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
:placeholder="`Search ${ctx.projectType.value}s...`"
|
||||
clearable
|
||||
wrapper-class="w-full"
|
||||
:input-class="ctx.variant === 'web' ? '!h-12' : 'h-12'"
|
||||
@clear="ctx.clearSearch()"
|
||||
/>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<Combobox
|
||||
:model-value="ctx.effectiveCurrentSortType.value"
|
||||
:options="sortOptions"
|
||||
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[16rem]'"
|
||||
placeholder="Sort by"
|
||||
@update:model-value="(val: SortType) => (ctx.effectiveCurrentSortType.value = val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="font-semibold text-primary">Sort by:</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<Combobox
|
||||
:model-value="ctx.maxResults.value"
|
||||
:options="maxResultsOptions"
|
||||
:class="ctx.variant === 'web' ? '!w-auto flex-grow md:flex-grow-0' : 'max-w-[9rem]'"
|
||||
placeholder="View"
|
||||
@update:model-value="(val: number) => (ctx.maxResults.value = val)"
|
||||
>
|
||||
<template #prefix>
|
||||
<span class="font-semibold text-primary">View:</span>
|
||||
</template>
|
||||
</Combobox>
|
||||
|
||||
<div v-if="ctx.filtersMenuOpen && !ctx.filtersMenuOpen.value" class="lg:hidden">
|
||||
<ButtonStyled>
|
||||
<button @click="ctx.filtersMenuOpen.value = true">Filter results...</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<ButtonStyled v-if="ctx.cycleDisplayMode" circular>
|
||||
<button @click="ctx.cycleDisplayMode!()">
|
||||
<slot name="display-mode-icon" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<Pagination
|
||||
:page="ctx.currentPage.value"
|
||||
:count="ctx.pageCount.value"
|
||||
:class="ctx.variant === 'web' ? 'mx-auto sm:ml-auto sm:mr-0' : 'ml-auto'"
|
||||
@switch-page="ctx.setPage"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchFilterControl
|
||||
v-if="ctx.isServerType.value"
|
||||
v-model:selected-filters="ctx.serverCurrentFilters.value"
|
||||
:filters="ctx.serverFilterTypes.value"
|
||||
:provided-filters="[]"
|
||||
:overridden-provided-filter-types="[]"
|
||||
/>
|
||||
<SearchFilterControl
|
||||
v-else
|
||||
v-model:selected-filters="ctx.currentFilters.value"
|
||||
:filters="ctx.filters.value.filter((f) => f.display !== 'none')"
|
||||
:provided-filters="ctx.providedFilters?.value ?? []"
|
||||
:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
|
||||
:provided-message="lockedMessages?.providedBy"
|
||||
/>
|
||||
|
||||
<div class="search">
|
||||
<section v-if="ctx.loading.value" class="offline">
|
||||
<component :is="ctx.loadingComponent ?? LoadingIndicator" />
|
||||
</section>
|
||||
<section v-else-if="ctx.offline?.value && ctx.totalHits.value === 0" class="offline">
|
||||
You are currently offline. Connect to the internet to browse Modrinth!
|
||||
</section>
|
||||
<section
|
||||
v-else-if="
|
||||
ctx.isServerType.value
|
||||
? ctx.serverHits.value.length === 0
|
||||
: ctx.projectHits.value.length === 0
|
||||
"
|
||||
class="offline"
|
||||
>
|
||||
<p>No results found for your query!</p>
|
||||
</section>
|
||||
|
||||
<ProjectCardList v-else :layout="ctx.effectiveLayout.value">
|
||||
<template v-if="ctx.isServerType.value">
|
||||
<ProjectCard
|
||||
v-for="result in ctx.serverHits.value"
|
||||
:key="`server-card-${result.project_id}`"
|
||||
:title="result.name"
|
||||
:icon-url="result.icon_url || undefined"
|
||||
:summary="result.summary"
|
||||
:tags="result.categories"
|
||||
:link="ctx.getServerProjectLink(result)"
|
||||
:server-online-players="result.minecraft_java_server?.ping?.data?.players_online ?? 0"
|
||||
:server-region="result.minecraft_server?.region"
|
||||
:server-recent-plays="result.minecraft_java_server?.verified_plays_2w ?? 0"
|
||||
:server-modpack-content="ctx.getServerModpackContent?.(result)"
|
||||
:server-ping="ctx.serverPings?.value?.[result.project_id]"
|
||||
:server-status-online="!!result.minecraft_java_server?.ping?.data"
|
||||
:hide-online-players-label="ctx.variant === 'app'"
|
||||
:hide-recent-plays-label="ctx.variant === 'app'"
|
||||
:layout="ctx.effectiveLayout.value"
|
||||
:max-tags="2"
|
||||
is-server-project
|
||||
exclude-loaders
|
||||
:color="result.color ?? undefined"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
|
||||
@mouseenter="ctx.onServerProjectHover?.(result)"
|
||||
@mouseleave="ctx.onProjectHoverEnd?.()"
|
||||
>
|
||||
<template v-if="ctx.getCardActions" #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
|
||||
:key="action.key"
|
||||
:color="action.color"
|
||||
:type="action.type"
|
||||
:circular="action.circular"
|
||||
>
|
||||
<button
|
||||
v-tooltip="action.tooltip"
|
||||
:disabled="action.disabled"
|
||||
@click.stop="action.onClick"
|
||||
>
|
||||
<component :is="action.icon" />
|
||||
<template v-if="!action.circular">{{ action.label }}</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
<template v-else>
|
||||
<ProjectCard
|
||||
v-for="result in ctx.projectHits.value"
|
||||
:key="result.project_id"
|
||||
:link="ctx.getProjectLink(result)"
|
||||
:title="result.title"
|
||||
:icon-url="result.icon_url"
|
||||
:author="{
|
||||
name: result.author,
|
||||
link:
|
||||
ctx.variant === 'web'
|
||||
? `/user/${result.author}`
|
||||
: `https://modrinth.com/user/${result.author}`,
|
||||
}"
|
||||
:date-updated="result.date_modified"
|
||||
:date-published="result.date_created"
|
||||
:displayed-date="
|
||||
ctx.effectiveCurrentSortType.value.name === 'newest' ? 'published' : 'updated'
|
||||
"
|
||||
:downloads="result.downloads"
|
||||
:summary="result.description"
|
||||
:tags="result.display_categories"
|
||||
:all-tags="result.categories"
|
||||
:deprioritized-tags="ctx.deprioritizedTags.value"
|
||||
:exclude-loaders="ctx.excludeLoaders.value"
|
||||
:followers="result.follows"
|
||||
:banner="result.featured_gallery ?? undefined"
|
||||
:color="result.color ?? undefined"
|
||||
:environment="
|
||||
['mod', 'modpack'].includes(ctx.projectType.value)
|
||||
? {
|
||||
clientSide: result.client_side as Labrinth.Projects.v2.Environment,
|
||||
serverSide: result.server_side as Labrinth.Projects.v2.Environment,
|
||||
}
|
||||
: undefined
|
||||
"
|
||||
:layout="ctx.effectiveLayout.value"
|
||||
@contextmenu.prevent.stop="(event: MouseEvent) => ctx.onContextMenu?.(event, result)"
|
||||
@mouseenter="ctx.onProjectHover?.(result)"
|
||||
@mouseleave="ctx.onProjectHoverEnd?.()"
|
||||
>
|
||||
<template v-if="ctx.getCardActions" #actions>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled
|
||||
v-for="action in ctx.getCardActions(result, ctx.projectType.value)"
|
||||
:key="action.key"
|
||||
:color="action.color"
|
||||
:type="action.type"
|
||||
:circular="action.circular"
|
||||
>
|
||||
<button
|
||||
v-tooltip="action.tooltip"
|
||||
:disabled="action.disabled"
|
||||
@click.stop="action.onClick"
|
||||
>
|
||||
<component :is="action.icon" />
|
||||
<template v-if="!action.circular">{{ action.label }}</template>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</ProjectCard>
|
||||
</template>
|
||||
</ProjectCardList>
|
||||
|
||||
<div :class="ctx.variant === 'web' ? 'pagination-after' : 'flex justify-end'">
|
||||
<Pagination
|
||||
:page="ctx.currentPage.value"
|
||||
:count="ctx.pageCount.value"
|
||||
:class="ctx.variant === 'web' ? 'justify-end' : 'pagination-after'"
|
||||
@switch-page="ctx.setPage"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<slot name="after" />
|
||||
</template>
|
||||
@@ -0,0 +1,106 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Component, ComputedRef, MaybeRef, Ref, ShallowRef } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
import type { FilterType, FilterValue, SortType } from '#ui/utils/search'
|
||||
|
||||
import type {
|
||||
BrowseInstallContext,
|
||||
BrowseSearchResponse,
|
||||
CardAction,
|
||||
ServerModpackContent,
|
||||
} from '../types'
|
||||
|
||||
export interface BrowseManagerContext {
|
||||
tags: Ref<{
|
||||
gameVersions: Labrinth.Tags.v2.GameVersion[]
|
||||
loaders: Labrinth.Tags.v2.Loader[]
|
||||
categories: Labrinth.Tags.v2.Category[]
|
||||
}>
|
||||
projectType: Ref<string>
|
||||
|
||||
query: Ref<string>
|
||||
filters: ComputedRef<FilterType[]>
|
||||
currentFilters: Ref<FilterValue[]>
|
||||
toggledGroups: Ref<string[]>
|
||||
overriddenProvidedFilterTypes: Ref<string[]>
|
||||
serverFilterTypes: ComputedRef<FilterType[]>
|
||||
serverCurrentFilters: Ref<FilterValue[]>
|
||||
serverToggledGroups: Ref<string[]>
|
||||
effectiveSortTypes: ComputedRef<readonly SortType[]>
|
||||
effectiveCurrentSortType: Ref<SortType>
|
||||
loading: Ref<boolean>
|
||||
projectHits: ShallowRef<BrowseSearchResponse['projectHits']>
|
||||
serverHits: ShallowRef<BrowseSearchResponse['serverHits']>
|
||||
totalHits: Ref<number>
|
||||
pageCount: ComputedRef<number>
|
||||
maxResults: Ref<number>
|
||||
currentPage: Ref<number>
|
||||
isServerType: ComputedRef<boolean>
|
||||
effectiveLayout: ComputedRef<'list' | 'grid'>
|
||||
deprioritizedTags: ComputedRef<string[]>
|
||||
excludeLoaders: ComputedRef<boolean>
|
||||
refreshSearch: () => Promise<void>
|
||||
setPage: (page: number) => Promise<void>
|
||||
clearSearch: () => void
|
||||
onFilterChange: () => void
|
||||
|
||||
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) => string | RouteLocationRaw
|
||||
getServerProjectLink: (
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
) => string | RouteLocationRaw
|
||||
|
||||
selectableProjectTypes: ComputedRef<{ label: string; href: string; shown?: boolean }[]>
|
||||
showProjectTypeTabs: ComputedRef<boolean>
|
||||
|
||||
variant: 'app' | 'web'
|
||||
|
||||
getCardActions?: (
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
projectType: string,
|
||||
) => CardAction[]
|
||||
|
||||
installContext?: ComputedRef<BrowseInstallContext | null>
|
||||
providedFilters?: ComputedRef<FilterValue[]>
|
||||
hideInstalled?: Ref<boolean>
|
||||
showHideInstalled?: ComputedRef<boolean>
|
||||
hideInstalledLabel?: ComputedRef<string>
|
||||
onInstalled?: (projectId: string) => void
|
||||
|
||||
displayMode?: Ref<'list' | 'grid' | 'gallery'> | ComputedRef<'list' | 'grid' | 'gallery'>
|
||||
cycleDisplayMode?: () => void
|
||||
maxResultsOptions?: ComputedRef<number[]>
|
||||
|
||||
serverPings?: Ref<Record<string, number | undefined>>
|
||||
getServerModpackContent?: (
|
||||
result: Labrinth.Search.v3.ResultSearchProject,
|
||||
) => ServerModpackContent | undefined
|
||||
|
||||
onProjectHover?: (result: Labrinth.Search.v2.ResultSearchProject) => void
|
||||
onServerProjectHover?: (result: Labrinth.Search.v3.ResultSearchProject) => void
|
||||
onProjectHoverEnd?: () => void
|
||||
onContextMenu?: (
|
||||
event: MouseEvent,
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
) => void
|
||||
offline?: Ref<boolean>
|
||||
|
||||
filtersMenuOpen?: Ref<boolean>
|
||||
|
||||
lockedFilterMessages?: MaybeRef<{
|
||||
gameVersion?: string
|
||||
modLoader?: string
|
||||
environment?: string
|
||||
syncButton?: string
|
||||
providedBy?: string
|
||||
gameVersionShaderMessage?: string
|
||||
}>
|
||||
|
||||
loadingComponent?: Component
|
||||
}
|
||||
|
||||
export const [injectBrowseManager, provideBrowseManager] = createContext<BrowseManagerContext>(
|
||||
'BrowsePageLayout',
|
||||
'browseManagerContext',
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export * from './browse-manager'
|
||||
177
packages/ui/src/layouts/shared/browse-tab/sidebar.vue
Normal file
177
packages/ui/src/layouts/shared/browse-tab/sidebar.vue
Normal file
@@ -0,0 +1,177 @@
|
||||
<script setup lang="ts">
|
||||
import { InfoIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, toValue } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import SearchSidebarFilter from '#ui/components/search/SearchSidebarFilter.vue'
|
||||
|
||||
import { injectBrowseManager } from './providers/browse-manager'
|
||||
|
||||
const ctx = injectBrowseManager()
|
||||
|
||||
const isApp = computed(() => ctx.variant === 'app')
|
||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||
|
||||
function closeFiltersMenu() {
|
||||
if (ctx.filtersMenuOpen) {
|
||||
ctx.filtersMenuOpen.value = false
|
||||
}
|
||||
window.scrollTo({ top: 0, behavior: 'instant' as ScrollBehavior })
|
||||
}
|
||||
|
||||
const filterClass = computed(() => {
|
||||
if (isApp.value) {
|
||||
return 'border-0 border-b-[1px] [&:first-child>button]:pt-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
|
||||
}
|
||||
if (ctx.filtersMenuOpen?.value) {
|
||||
return 'border-0 border-b-[1px] border-solid border-divider last:border-b-0'
|
||||
}
|
||||
return 'card-shadow rounded-2xl bg-bg-raised'
|
||||
})
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
if (isApp.value) {
|
||||
return 'button-animation flex flex-col gap-1 px-4 py-3 w-full bg-transparent cursor-pointer border-none hover:bg-button-bg'
|
||||
}
|
||||
return 'button-animation flex flex-col gap-1 px-6 py-4 w-full bg-transparent cursor-pointer border-none'
|
||||
})
|
||||
|
||||
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
|
||||
const innerPanelClass = computed(() => (isApp.value ? 'ml-2 mr-3' : 'p-1'))
|
||||
|
||||
function getFilterOpenByDefault(filterId: string): boolean {
|
||||
if (ctx.isServerType.value) {
|
||||
return ![
|
||||
'server_category_minecraft_server_meta',
|
||||
'server_category_minecraft_server_community',
|
||||
'server_game_version',
|
||||
'server_status',
|
||||
].includes(filterId)
|
||||
}
|
||||
if (isApp.value) {
|
||||
return filterId.startsWith('category') || filterId === 'environment' || filterId === 'license'
|
||||
}
|
||||
if (
|
||||
lockedMessages.value?.gameVersionShaderMessage &&
|
||||
ctx.projectType.value === 'shader' &&
|
||||
filterId === 'game_version'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot name="prepend" />
|
||||
|
||||
<div v-if="ctx.filtersMenuOpen?.value" class="fixed inset-0 z-40 bg-bg" />
|
||||
|
||||
<div
|
||||
class="flex flex-col"
|
||||
:class="{
|
||||
'gap-3': !isApp,
|
||||
'fixed inset-0 z-50 m-4 mb-0 overflow-auto rounded-t-3xl bg-bg-raised':
|
||||
ctx.filtersMenuOpen?.value,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-if="ctx.filtersMenuOpen?.value"
|
||||
class="sticky top-0 z-10 mx-1 flex items-center justify-between gap-3 border-0 border-b-[1px] border-solid border-divider bg-bg-raised px-6 py-4"
|
||||
>
|
||||
<h3 class="m-0 text-lg text-contrast">Filters</h3>
|
||||
<ButtonStyled circular>
|
||||
<button @click="closeFiltersMenu">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ctx.showHideInstalled?.value"
|
||||
:class="
|
||||
isApp
|
||||
? 'border-0 border-b-[1px] p-4 last:border-b-0 border-[--brand-gradient-border] border-solid'
|
||||
: 'card-shadow rounded-2xl bg-bg-raised p-4'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
v-model="ctx.hideInstalled!.value"
|
||||
:label="ctx.hideInstalledLabel?.value ?? 'Hide installed content'"
|
||||
class="filter-checkbox"
|
||||
@update:model-value="ctx.onFilterChange()"
|
||||
@click.prevent.stop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="ctx.isServerType.value">
|
||||
<SearchSidebarFilter
|
||||
v-for="filterType in ctx.serverFilterTypes.value.filter((f) => f.options.length > 0)"
|
||||
:key="`server-filter-${filterType.id}`"
|
||||
v-model:selected-filters="ctx.serverCurrentFilters.value"
|
||||
v-model:toggled-groups="ctx.serverToggledGroups.value"
|
||||
:provided-filters="[]"
|
||||
:filter-type="filterType"
|
||||
:class="filterClass"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:open-by-default="getFilterOpenByDefault(filterType.id)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg'">
|
||||
{{ filterType.formatted_name }}
|
||||
</h3>
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
<template v-else>
|
||||
<SearchSidebarFilter
|
||||
v-for="filter in ctx.filters.value.filter((f) => f.display !== 'none')"
|
||||
:key="`filter-${filter.id}`"
|
||||
v-model:selected-filters="ctx.currentFilters.value"
|
||||
v-model:toggled-groups="ctx.toggledGroups.value"
|
||||
v-model:overridden-provided-filter-types="ctx.overriddenProvidedFilterTypes.value"
|
||||
:provided-filters="ctx.providedFilters?.value ?? []"
|
||||
:filter-type="filter"
|
||||
:class="filterClass"
|
||||
:button-class="buttonClass"
|
||||
:content-class="contentClass"
|
||||
:inner-panel-class="innerPanelClass"
|
||||
:open-by-default="getFilterOpenByDefault(filter.id)"
|
||||
>
|
||||
<template #header>
|
||||
<h3 :class="isApp ? 'text-base m-0' : 'm-0 text-lg'">
|
||||
{{ filter.formatted_name }}
|
||||
</h3>
|
||||
</template>
|
||||
<template
|
||||
v-if="
|
||||
lockedMessages?.gameVersionShaderMessage &&
|
||||
ctx.projectType.value === 'shader' &&
|
||||
filter.id === 'game_version'
|
||||
"
|
||||
#prefix
|
||||
>
|
||||
<div class="mb-4 grid grid-cols-[auto_1fr] gap-2 px-3 text-sm font-medium text-blue">
|
||||
<InfoIcon class="mt-1 size-4" />
|
||||
<span>{{ lockedMessages.gameVersionShaderMessage }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="lockedMessages?.gameVersion" #locked-game_version>
|
||||
{{ lockedMessages.gameVersion }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.modLoader" #locked-mod_loader>
|
||||
{{ lockedMessages.modLoader }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.environment" #locked-environment>
|
||||
{{ lockedMessages.environment }}
|
||||
</template>
|
||||
<template v-if="lockedMessages?.syncButton" #sync-button>
|
||||
{{ lockedMessages.syncButton }}
|
||||
</template>
|
||||
</SearchSidebarFilter>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
46
packages/ui/src/layouts/shared/browse-tab/types.ts
Normal file
46
packages/ui/src/layouts/shared/browse-tab/types.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import type { Component } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
export interface BrowseSearchResponse {
|
||||
projectHits: (Labrinth.Search.v2.ResultSearchProject & {
|
||||
installed?: boolean
|
||||
installing?: boolean
|
||||
})[]
|
||||
serverHits: Labrinth.Search.v3.ResultSearchProject[]
|
||||
total_hits: number
|
||||
per_page: number
|
||||
}
|
||||
|
||||
export interface BrowseInstallContext {
|
||||
name: string
|
||||
loader: string
|
||||
gameVersion: string
|
||||
serverId?: string | null
|
||||
upstream?: { project_id?: string | null } | null
|
||||
iconSrc?: string | null
|
||||
isMedal?: boolean
|
||||
backUrl: string | RouteLocationRaw
|
||||
backLabel: string
|
||||
heading: string
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface CardAction {
|
||||
key: string
|
||||
label: string
|
||||
icon: Component
|
||||
disabled?: boolean
|
||||
color?: 'brand' | 'red'
|
||||
type?: 'standard' | 'outlined' | 'transparent'
|
||||
circular?: boolean
|
||||
tooltip?: string
|
||||
onClick: () => void | Promise<void>
|
||||
}
|
||||
|
||||
export interface ServerModpackContent {
|
||||
name: string
|
||||
icon?: string
|
||||
onclick?: () => void
|
||||
showCustomModpackTooltip: boolean
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<ButtonStyled v-if="showClear && hasLogs" type="transparent">
|
||||
<button @click="emit('clear')">
|
||||
<XIcon />
|
||||
Clear
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="showDelete" type="transparent" hover-color-fill="background" color="red">
|
||||
<button
|
||||
v-tooltip="deleteDisabled ? deleteDisabledTooltip : undefined"
|
||||
:disabled="deleteDisabled"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="hasLogs" type="transparent">
|
||||
<button
|
||||
v-tooltip="shareDisabled ? shareDisabledTooltip : undefined"
|
||||
:disabled="shareDisabled || sharing"
|
||||
@click="emit('share')"
|
||||
>
|
||||
<SpinnerIcon v-if="sharing" class="animate-spin" />
|
||||
<ShareIcon v-else />
|
||||
Share
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="emit('toggle-fullscreen')">
|
||||
<ContractIcon v-if="fullscreen" />
|
||||
<ExpandIcon v-else />
|
||||
{{ fullscreen ? 'Collapse' : 'Expand' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ContractIcon,
|
||||
ExpandIcon,
|
||||
ShareIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
|
||||
defineProps<{
|
||||
showClear?: boolean
|
||||
hasLogs?: boolean
|
||||
shareDisabled?: boolean
|
||||
shareDisabledTooltip?: string
|
||||
sharing?: boolean
|
||||
fullscreen?: boolean
|
||||
showDelete?: boolean
|
||||
deleteDisabled?: boolean
|
||||
deleteDisabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
clear: []
|
||||
share: []
|
||||
'toggle-fullscreen': []
|
||||
delete: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<FilterPills v-model="selectedFilters" :options="visibleOptions">
|
||||
<template #all> All </template>
|
||||
</FilterPills>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import FilterPills from '#ui/components/base/FilterPills.vue'
|
||||
|
||||
import type { ConditionalLevel } from '../composables/console-filtering'
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
type FilterValue = LogLevel | 'all'
|
||||
|
||||
const ALWAYS_VISIBLE: Array<{ id: LogLevel; label: string }> = [
|
||||
{ id: 'error', label: 'Error' },
|
||||
{ id: 'warn', label: 'Warn' },
|
||||
{ id: 'info', label: 'Info' },
|
||||
]
|
||||
|
||||
const CONDITIONAL_OPTIONS: Array<{ id: ConditionalLevel; label: string }> = [
|
||||
{ id: 'debug', label: 'Debug' },
|
||||
{ id: 'trace', label: 'Trace' },
|
||||
]
|
||||
|
||||
const props = defineProps<{
|
||||
presentLevels: Set<ConditionalLevel>
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<Set<FilterValue>>({ required: true })
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [value: FilterValue]
|
||||
}>()
|
||||
|
||||
const visibleOptions = computed(() => [
|
||||
...ALWAYS_VISIBLE,
|
||||
...CONDITIONAL_OPTIONS.filter((opt) => props.presentLevels.has(opt.id)),
|
||||
])
|
||||
|
||||
const selectedFilters = computed({
|
||||
get() {
|
||||
if (modelValue.value.has('all')) return []
|
||||
return [...modelValue.value] as string[]
|
||||
},
|
||||
set(ids: string[]) {
|
||||
if (ids.length === 0) {
|
||||
emit('toggle', 'all')
|
||||
} else {
|
||||
const current = selectedFilters.value
|
||||
const added = ids.find((id) => !current.includes(id))
|
||||
const removed = current.find((id) => !ids.includes(id))
|
||||
if (added) emit('toggle', added as FilterValue)
|
||||
if (removed) emit('toggle', removed as FilterValue)
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { LogLevel, LogLine } from '../types'
|
||||
|
||||
export type FilterPredicate = (line: LogLine) => boolean
|
||||
|
||||
function highlightMatches(text: string, query: string): string {
|
||||
if (!query) return text
|
||||
const lower = text.toLowerCase()
|
||||
let result = ''
|
||||
let pos = 0
|
||||
while (pos < text.length) {
|
||||
const idx = lower.indexOf(query, pos)
|
||||
if (idx === -1) {
|
||||
result += text.slice(pos)
|
||||
break
|
||||
}
|
||||
result += text.slice(pos, idx)
|
||||
result += `\x1b[1;7m${text.slice(idx, idx + query.length)}\x1b[27;22m`
|
||||
pos = idx + query.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function colorize(line: LogLine, searchQuery?: string): string {
|
||||
const text = searchQuery ? highlightMatches(line.text, searchQuery) : line.text
|
||||
switch (line.level) {
|
||||
case 'error':
|
||||
return `\x1b[31;40m${text}\x1b[K\x1b[0m`
|
||||
case 'warn':
|
||||
return `\x1b[33;40m${text}\x1b[K\x1b[0m`
|
||||
case 'debug':
|
||||
case 'trace':
|
||||
return `\x1b[90m${text}\x1b[0m`
|
||||
default:
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
export type ConditionalLevel = 'debug' | 'trace'
|
||||
|
||||
export function useConsoleFilters() {
|
||||
const activeFilters = ref<Set<LogLevel | 'all'>>(new Set(['all']))
|
||||
|
||||
function toggleFilter(level: LogLevel | 'all') {
|
||||
const next = new Set(activeFilters.value)
|
||||
if (level === 'all') {
|
||||
next.clear()
|
||||
next.add('all')
|
||||
} else {
|
||||
next.delete('all')
|
||||
if (next.has(level)) {
|
||||
next.delete(level)
|
||||
} else {
|
||||
next.add(level)
|
||||
}
|
||||
if (next.size === 0) {
|
||||
next.add('all')
|
||||
}
|
||||
}
|
||||
activeFilters.value = next
|
||||
}
|
||||
|
||||
function buildFilterPredicate(): FilterPredicate | null {
|
||||
if (activeFilters.value.has('all')) return null
|
||||
const allowed = activeFilters.value
|
||||
return (line: LogLine) => {
|
||||
return allowed.has(line.level ?? 'info')
|
||||
}
|
||||
}
|
||||
|
||||
return { activeFilters, toggleFilter, buildFilterPredicate }
|
||||
}
|
||||
|
||||
export function rewriteTerminal(
|
||||
terminal: Terminal,
|
||||
allLines: LogLine[],
|
||||
predicate: FilterPredicate | null,
|
||||
searchQuery?: string,
|
||||
callback?: () => void,
|
||||
) {
|
||||
terminal.reset()
|
||||
terminal.write('\x1b[?25l')
|
||||
|
||||
const filtered = predicate ? allLines.filter(predicate) : allLines
|
||||
if (filtered.length === 0) {
|
||||
callback?.()
|
||||
return
|
||||
}
|
||||
|
||||
terminal.write('\x1b[?2026h')
|
||||
terminal.write(filtered.map((line) => colorize(line, searchQuery)).join('\r\n'), () => {
|
||||
terminal.write('\x1b[?2026l')
|
||||
callback?.()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export {
|
||||
colorize,
|
||||
type ConditionalLevel,
|
||||
type FilterPredicate,
|
||||
rewriteTerminal,
|
||||
useConsoleFilters,
|
||||
} from './console-filtering'
|
||||
export { computeHighlightColors, LogHighlightAddon } from './log-highlight-addon'
|
||||
export { detectLogLevel } from './log-level'
|
||||
@@ -0,0 +1,242 @@
|
||||
import type { IDecoration, IDisposable, IMarker, ITerminalAddon, Terminal } from '@xterm/xterm'
|
||||
|
||||
import { getCssVar } from '#ui/composables/terminal'
|
||||
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
export interface HighlightColors {
|
||||
errorPrimary: string
|
||||
errorWrap: string
|
||||
warnPrimary: string
|
||||
warnWrap: string
|
||||
}
|
||||
|
||||
interface TrackedLine {
|
||||
marker: IMarker
|
||||
level: 'error' | 'warn'
|
||||
isEntryStart: boolean
|
||||
primary: IDecoration | undefined
|
||||
wraps: IDecoration[]
|
||||
}
|
||||
|
||||
type HighlightClass = 'hl-error-primary' | 'hl-error-wrap' | 'hl-warn-primary' | 'hl-warn-wrap'
|
||||
|
||||
const LOG_ENTRY_START = /^\[\d{2}:\d{2}:\d{2}\]/
|
||||
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
const h = hex.startsWith('#') ? hex.slice(1) : hex
|
||||
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
function blendHex(base: string, overlay: string, alpha: number): string {
|
||||
const [br, bg, bb] = parseHex(base)
|
||||
const [or, og, ob] = parseHex(overlay)
|
||||
const r = Math.round(br + (or - br) * alpha)
|
||||
const g = Math.round(bg + (og - bg) * alpha)
|
||||
const b = Math.round(bb + (ob - bb) * alpha)
|
||||
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function computeHighlightColors(): HighlightColors {
|
||||
const bg = getCssVar('--surface-2', '#1d1f23')
|
||||
const red = getCssVar('--color-red', '#ff496e')
|
||||
const orange = getCssVar('--color-orange', '#ffa347')
|
||||
return {
|
||||
errorPrimary: blendHex(bg, red, 0.15),
|
||||
errorWrap: blendHex(bg, red, 0.04),
|
||||
warnPrimary: blendHex(bg, orange, 0.15),
|
||||
warnWrap: blendHex(bg, orange, 0.04),
|
||||
}
|
||||
}
|
||||
|
||||
export class LogHighlightAddon implements ITerminalAddon {
|
||||
private terminal: Terminal | null = null
|
||||
private tracked: TrackedLine[] = []
|
||||
private colors: HighlightColors
|
||||
private disposables: IDisposable[] = []
|
||||
private styleElement: HTMLStyleElement | null = null
|
||||
|
||||
constructor(colors: HighlightColors) {
|
||||
this.colors = colors
|
||||
}
|
||||
|
||||
activate(terminal: Terminal): void {
|
||||
this.terminal = terminal
|
||||
this.injectStylesheet()
|
||||
this.disposables.push(terminal.onResize(() => this.rebuildAllDecorations()))
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const d of this.disposables) d.dispose()
|
||||
this.disposables = []
|
||||
this.clearAll()
|
||||
this.styleElement?.remove()
|
||||
this.styleElement = null
|
||||
this.terminal = null
|
||||
}
|
||||
|
||||
applyFromLine(startLine: number, levels: Array<LogLevel | null>): void {
|
||||
const term = this.terminal
|
||||
if (!term) return
|
||||
|
||||
const buffer = term.buffer.active
|
||||
let levelIdx = 0
|
||||
|
||||
for (let line = startLine; line < buffer.length && levelIdx < levels.length; line++) {
|
||||
const bufLine = buffer.getLine(line)
|
||||
if (!bufLine || bufLine.isWrapped) continue
|
||||
|
||||
const level = levels[levelIdx++]
|
||||
if (level === 'error' || level === 'warn') {
|
||||
this.decorateLogicalLine(line, level)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
for (const tl of this.tracked) {
|
||||
tl.primary?.dispose()
|
||||
for (const w of tl.wraps) w.dispose()
|
||||
tl.marker.dispose()
|
||||
}
|
||||
this.tracked = []
|
||||
}
|
||||
|
||||
updateColors(colors: HighlightColors): void {
|
||||
this.colors = colors
|
||||
this.updateStylesheet()
|
||||
this.rebuildAllDecorations()
|
||||
}
|
||||
|
||||
private injectStylesheet(): void {
|
||||
const el = this.terminal?.element
|
||||
if (!el) return
|
||||
this.styleElement = document.createElement('style')
|
||||
this.updateStylesheet()
|
||||
el.appendChild(this.styleElement)
|
||||
}
|
||||
|
||||
private updateStylesheet(): void {
|
||||
if (!this.styleElement) return
|
||||
this.styleElement.textContent = [
|
||||
`.hl-error-primary { background-color: ${this.colors.errorPrimary} !important; }`,
|
||||
`.hl-error-wrap { background-color: ${this.colors.errorWrap} !important; }`,
|
||||
`.hl-warn-primary { background-color: ${this.colors.warnPrimary} !important; }`,
|
||||
`.hl-warn-wrap { background-color: ${this.colors.warnWrap} !important; }`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
private classForDecoration(level: 'error' | 'warn', isEntryStart: boolean): HighlightClass {
|
||||
if (level === 'error') return isEntryStart ? 'hl-error-primary' : 'hl-error-wrap'
|
||||
return isEntryStart ? 'hl-warn-primary' : 'hl-warn-wrap'
|
||||
}
|
||||
|
||||
private tagElement(dec: IDecoration | undefined, cls: HighlightClass): void {
|
||||
if (!dec) return
|
||||
const disposable = dec.onRender((el) => {
|
||||
el.classList.add(cls)
|
||||
disposable.dispose()
|
||||
})
|
||||
}
|
||||
|
||||
private decorateLogicalLine(bufferLine: number, level: 'error' | 'warn'): void {
|
||||
const term = this.terminal
|
||||
if (!term) return
|
||||
|
||||
const buffer = term.buffer.active
|
||||
const cursorAbsolute = buffer.baseY + buffer.cursorY
|
||||
const offset = bufferLine - cursorAbsolute
|
||||
const marker = term.registerMarker(offset)
|
||||
if (!marker) return
|
||||
|
||||
const lineText = buffer.getLine(bufferLine)?.translateToString(true) ?? ''
|
||||
const isEntryStart = LOG_ENTRY_START.test(lineText)
|
||||
|
||||
const bgColor = isEntryStart
|
||||
? level === 'error'
|
||||
? this.colors.errorPrimary
|
||||
: this.colors.warnPrimary
|
||||
: level === 'error'
|
||||
? this.colors.errorWrap
|
||||
: this.colors.warnWrap
|
||||
|
||||
const primary = term.registerDecoration({
|
||||
marker,
|
||||
backgroundColor: bgColor,
|
||||
width: term.cols,
|
||||
layer: 'bottom',
|
||||
})
|
||||
this.tagElement(primary, this.classForDecoration(level, isEntryStart))
|
||||
const wraps = this.createWrapDecorations(bufferLine, level)
|
||||
|
||||
this.tracked.push({ marker, level, isEntryStart, primary, wraps })
|
||||
}
|
||||
|
||||
private createWrapDecorations(primaryLine: number, level: 'error' | 'warn'): IDecoration[] {
|
||||
const term = this.terminal
|
||||
if (!term) return []
|
||||
|
||||
const buffer = term.buffer.active
|
||||
const decorations: IDecoration[] = []
|
||||
const cursorAbsolute = buffer.baseY + buffer.cursorY
|
||||
const cls = this.classForDecoration(level, false)
|
||||
const color = level === 'error' ? this.colors.errorWrap : this.colors.warnWrap
|
||||
|
||||
for (let line = primaryLine + 1; line < buffer.length; line++) {
|
||||
const bufLine = buffer.getLine(line)
|
||||
if (!bufLine || !bufLine.isWrapped) break
|
||||
|
||||
const offset = line - cursorAbsolute
|
||||
const wrapMarker = term.registerMarker(offset)
|
||||
if (!wrapMarker) continue
|
||||
|
||||
const dec = term.registerDecoration({
|
||||
marker: wrapMarker,
|
||||
backgroundColor: color,
|
||||
width: term.cols,
|
||||
layer: 'bottom',
|
||||
})
|
||||
if (dec) {
|
||||
this.tagElement(dec, cls)
|
||||
decorations.push(dec)
|
||||
}
|
||||
}
|
||||
|
||||
return decorations
|
||||
}
|
||||
|
||||
private rebuildAllDecorations(): void {
|
||||
const term = this.terminal
|
||||
if (!term) return
|
||||
|
||||
for (const tl of this.tracked) {
|
||||
tl.primary?.dispose()
|
||||
for (const w of tl.wraps) w.dispose()
|
||||
|
||||
if (tl.marker.line === -1) {
|
||||
tl.primary = undefined
|
||||
tl.wraps = []
|
||||
continue
|
||||
}
|
||||
|
||||
const cls = this.classForDecoration(tl.level, tl.isEntryStart)
|
||||
const bgColor = tl.isEntryStart
|
||||
? tl.level === 'error'
|
||||
? this.colors.errorPrimary
|
||||
: this.colors.warnPrimary
|
||||
: tl.level === 'error'
|
||||
? this.colors.errorWrap
|
||||
: this.colors.warnWrap
|
||||
tl.primary = term.registerDecoration({
|
||||
marker: tl.marker,
|
||||
backgroundColor: bgColor,
|
||||
width: term.cols,
|
||||
layer: 'bottom',
|
||||
})
|
||||
this.tagElement(tl.primary, cls)
|
||||
tl.wraps = this.createWrapDecorations(tl.marker.line, tl.level)
|
||||
}
|
||||
|
||||
this.tracked = this.tracked.filter((tl) => tl.marker.line !== -1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { LogLevel } from '../types'
|
||||
|
||||
const ERROR_TRIGGERS = ['/ERROR', 'Exception:', ':?]', 'Error', '[thread', '\tat']
|
||||
|
||||
export function detectLogLevel(lineText: string): LogLevel | null {
|
||||
if (lineText.includes('/INFO') || lineText.includes('[System] [CHAT]')) return 'info'
|
||||
if (lineText.includes('/WARN')) return 'warn'
|
||||
if (lineText.includes('/DEBUG')) return 'debug'
|
||||
if (lineText.includes('/TRACE')) return 'trace'
|
||||
for (const trigger of ERROR_TRIGGERS) {
|
||||
if (lineText.includes(trigger)) return 'error'
|
||||
}
|
||||
return null
|
||||
}
|
||||
3
packages/ui/src/layouts/shared/console/index.ts
Normal file
3
packages/ui/src/layouts/shared/console/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as ConsolePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export * from './types'
|
||||
357
packages/ui/src/layouts/shared/console/layout.vue
Normal file
357
packages/ui/src/layouts/shared/console/layout.vue
Normal file
@@ -0,0 +1,357 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex min-h-0 flex-1 flex-col gap-4"
|
||||
:class="isFullscreen ? `fixed inset-0 z-50 bg-surface-1 p-6 py-8 ${isApp ? 'pt-12' : ''}` : ''"
|
||||
>
|
||||
<CollapsibleAdmonition
|
||||
v-if="ctx.crashAnalysis?.value"
|
||||
type="critical"
|
||||
:header="crashHeader"
|
||||
:items="crashItems"
|
||||
dismissible
|
||||
@dismiss="ctx.onDismissCrash?.()"
|
||||
/>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<StyledInput
|
||||
v-model="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
placeholder="Search logs"
|
||||
wrapper-class="flex-1"
|
||||
input-class="!h-10"
|
||||
clearable
|
||||
/>
|
||||
<div v-if="ctx.logSources?.value && ctx.activeLogSourceIndex" class="w-[220px]">
|
||||
<Combobox
|
||||
:model-value="ctx.activeLogSourceIndex.value"
|
||||
:options="logSourceOptions"
|
||||
@update:model-value="(v) => (ctx.activeLogSourceIndex!.value = v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<ConsoleFilterPills
|
||||
v-model="activeFilters"
|
||||
:present-levels="presentLevels"
|
||||
@toggle="handleFilterToggle"
|
||||
/>
|
||||
<ConsoleActionButtons
|
||||
:show-clear="isLiveSource"
|
||||
:has-logs="hasLogs"
|
||||
:share-disabled="resolvedShareDisabled"
|
||||
:sharing="isSharing"
|
||||
:fullscreen="isFullscreen"
|
||||
:show-delete="showDelete"
|
||||
:delete-disabled="resolvedDeleteDisabled"
|
||||
:delete-disabled-tooltip="ctx.deleteDisabledTooltip"
|
||||
@clear="handleClear"
|
||||
@share="handleShare"
|
||||
@toggle-fullscreen="toggleFullscreen"
|
||||
@delete="handleDelete"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseTerminal
|
||||
ref="terminalRef"
|
||||
class="min-h-0 flex-1"
|
||||
:show-input="resolvedShowInput"
|
||||
:disable-input="resolvedDisableInput"
|
||||
:fullscreen="isFullscreen"
|
||||
:empty-state-type="ctx.emptyStateType"
|
||||
@command="handleCommand"
|
||||
@ready="handleTerminalReady"
|
||||
/>
|
||||
</div>
|
||||
<ShareModal ref="shareModal" header="Share Logs" link :social-buttons="false" />
|
||||
<NewModal ref="deleteModal" header="Delete log file" :fade="'danger'" max-width="500px">
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="critical" header="This is irreversible">
|
||||
Deleting this log file cannot be undone. Are you sure you want to continue?
|
||||
</Admonition>
|
||||
</div>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="deleteModal?.hide()">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button :disabled="isDeleting" @click="confirmDelete">
|
||||
<TrashIcon />
|
||||
Delete
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SearchIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import type { Terminal } from '@xterm/xterm'
|
||||
import { computed, isRef, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import BaseTerminal from '#ui/components/base/BaseTerminal.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import type { CollapsibleAdmonitionItem } from '#ui/components/base/CollapsibleAdmonition.vue'
|
||||
import CollapsibleAdmonition from '#ui/components/base/CollapsibleAdmonition.vue'
|
||||
import Combobox from '#ui/components/base/Combobox.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import ShareModal from '#ui/components/modal/ShareModal.vue'
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
import { injectModalBehavior } from '#ui/providers/modal-behavior'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications.ts'
|
||||
|
||||
import ConsoleActionButtons from './components/ConsoleActionButtons.vue'
|
||||
import ConsoleFilterPills from './components/ConsoleFilterPills.vue'
|
||||
import { colorize, rewriteTerminal, useConsoleFilters } from './composables'
|
||||
import type { ConditionalLevel } from './composables/console-filtering'
|
||||
import { injectConsoleManager } from './providers'
|
||||
import type { LogLevel, LogLine } from './types'
|
||||
|
||||
const ctx = injectConsoleManager()
|
||||
const client = injectModrinthClient()
|
||||
const modalBehavior = injectModalBehavior()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const crashHeader = computed(() => {
|
||||
const problems = ctx.crashAnalysis?.value?.analysis.problems ?? []
|
||||
const count = problems.length
|
||||
return `${count} problem${count !== 1 ? 's' : ''} detected`
|
||||
})
|
||||
|
||||
const crashItems = computed<CollapsibleAdmonitionItem[]>(() => {
|
||||
const problems = ctx.crashAnalysis?.value?.analysis.problems ?? []
|
||||
return problems.map((p) => ({
|
||||
title: p.message,
|
||||
descriptions: p.solutions.map((s) => s.message),
|
||||
}))
|
||||
})
|
||||
|
||||
const terminalRef = ref<InstanceType<typeof BaseTerminal> | null>(null)
|
||||
const shareModal = ref<InstanceType<typeof ShareModal> | null>(null)
|
||||
const deleteModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
const isDeleting = ref(false)
|
||||
const searchQuery = ref('')
|
||||
const isFullscreen = ref(false)
|
||||
const isApp =
|
||||
typeof window !== 'undefined' && !!(window as Record<string, unknown>).__TAURI_INTERNALS__
|
||||
const isSharing = ref(false)
|
||||
const { activeFilters, toggleFilter, buildFilterPredicate } = useConsoleFilters()
|
||||
const hasLogs = computed(() => ctx.logLines.value.length > 0)
|
||||
const presentLevels = computed(() => {
|
||||
const levels = new Set<ConditionalLevel>()
|
||||
for (const line of ctx.logLines.value) {
|
||||
if (line.level === 'debug') levels.add('debug')
|
||||
if (line.level === 'trace') levels.add('trace')
|
||||
if (levels.size === 2) break
|
||||
}
|
||||
return levels
|
||||
})
|
||||
const isLiveSource = computed(() => {
|
||||
const sources = ctx.logSources?.value
|
||||
const index = ctx.activeLogSourceIndex?.value
|
||||
if (!sources || index === undefined) return true
|
||||
return sources[index]?.live ?? true
|
||||
})
|
||||
const logSourceOptions = computed(() =>
|
||||
(ctx.logSources?.value ?? []).map((s, i) => ({ value: i, label: s.name })),
|
||||
)
|
||||
|
||||
function buildCombinedPredicate(): ((line: LogLine) => boolean) | null {
|
||||
const levelPred = buildFilterPredicate()
|
||||
const query = searchQuery.value.trim().toLowerCase()
|
||||
if (!levelPred && !query) return null
|
||||
return (line: LogLine) => {
|
||||
if (levelPred && !levelPred(line)) return false
|
||||
if (query && !line.text.toLowerCase().includes(query)) return false
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (isFullscreen.value) {
|
||||
document.body.style.overflow = ''
|
||||
modalBehavior?.onHide?.()
|
||||
}
|
||||
})
|
||||
|
||||
let lastWrittenIndex = 0
|
||||
let searchDebounce: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const resolvedShowInput = computed(() => {
|
||||
const v = ctx.showCommandInput
|
||||
if (v === undefined) return false
|
||||
if (typeof v === 'boolean') return v
|
||||
return isRef(v) ? v.value : v
|
||||
})
|
||||
|
||||
const resolvedDisableInput = computed(() => {
|
||||
const v = ctx.disableCommandInput
|
||||
if (!v) return false
|
||||
return isRef(v) ? v.value : v
|
||||
})
|
||||
|
||||
const resolvedShareDisabled = computed(() => {
|
||||
const v = ctx.shareDisabled
|
||||
if (!v) return false
|
||||
return isRef(v) ? v.value : v
|
||||
})
|
||||
|
||||
const showDelete = computed(() => !isLiveSource.value && ctx.onDelete != null)
|
||||
|
||||
const resolvedDeleteDisabled = computed(() => {
|
||||
const v = ctx.deleteDisabled
|
||||
if (!v) return false
|
||||
return isRef(v) ? v.value : v
|
||||
})
|
||||
|
||||
function handleTerminalReady(_terminal: Terminal) {
|
||||
rewriteFiltered()
|
||||
}
|
||||
|
||||
function handleFilterToggle(value: LogLevel | 'all') {
|
||||
toggleFilter(value)
|
||||
rewriteFiltered()
|
||||
}
|
||||
|
||||
function activeSearchQuery(): string {
|
||||
return searchQuery.value.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function rewriteFiltered() {
|
||||
const term = terminalRef.value?.terminal
|
||||
if (!term) return
|
||||
const lines = ctx.logLines.value
|
||||
if (lines.length === 0 && isLiveSource.value) {
|
||||
writeEmptyState()
|
||||
return
|
||||
}
|
||||
terminalRef.value?.clearEmptyState()
|
||||
const predicate = buildCombinedPredicate()
|
||||
rewriteTerminal(term, lines, predicate, activeSearchQuery())
|
||||
lastWrittenIndex = lines.length
|
||||
}
|
||||
|
||||
function toggleFullscreen() {
|
||||
isFullscreen.value = !isFullscreen.value
|
||||
if (isFullscreen.value) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
modalBehavior?.onShow?.()
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
modalBehavior?.onHide?.()
|
||||
}
|
||||
nextTick(() => {
|
||||
terminalRef.value?.fit()
|
||||
})
|
||||
}
|
||||
|
||||
function writeEmptyState() {
|
||||
terminalRef.value?.writeEmptyState()
|
||||
lastWrittenIndex = 0
|
||||
}
|
||||
|
||||
watch(ctx.logLines, (lines, oldLines) => {
|
||||
const term = terminalRef.value?.terminal
|
||||
if (!term) return
|
||||
|
||||
if (lines.length === 0 && isLiveSource.value) {
|
||||
writeEmptyState()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
terminalRef.value?.showingEmptyState ||
|
||||
lines !== oldLines ||
|
||||
lines.length < lastWrittenIndex
|
||||
) {
|
||||
terminalRef.value?.clearEmptyState()
|
||||
rewriteFiltered()
|
||||
return
|
||||
}
|
||||
|
||||
const predicate = buildCombinedPredicate()
|
||||
const query = activeSearchQuery()
|
||||
const newLines: string[] = []
|
||||
for (let i = lastWrittenIndex; i < lines.length; i++) {
|
||||
if (!predicate || predicate(lines[i])) {
|
||||
newLines.push(colorize(lines[i], query))
|
||||
}
|
||||
}
|
||||
if (newLines.length > 0) {
|
||||
const buffer = term.buffer.active
|
||||
const onFreshLine = buffer.cursorX === 0
|
||||
const data = onFreshLine ? newLines.join('\r\n') : '\r\n' + newLines.join('\r\n')
|
||||
term.write(data)
|
||||
}
|
||||
lastWrittenIndex = lines.length
|
||||
})
|
||||
|
||||
watch(searchQuery, () => {
|
||||
if (searchDebounce) clearTimeout(searchDebounce)
|
||||
searchDebounce = setTimeout(() => {
|
||||
rewriteFiltered()
|
||||
}, 200)
|
||||
})
|
||||
|
||||
function handleCommand(cmd: string) {
|
||||
ctx.sendCommand?.(cmd)
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
terminalRef.value?.reset()
|
||||
lastWrittenIndex = 0
|
||||
ctx.onClear?.()
|
||||
}
|
||||
|
||||
function handleDelete() {
|
||||
deleteModal.value?.show()
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (!ctx.onDelete) return
|
||||
isDeleting.value = true
|
||||
try {
|
||||
await ctx.onDelete()
|
||||
deleteModal.value?.hide()
|
||||
} catch (err) {
|
||||
console.error('Failed to delete log file:', err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to delete log file',
|
||||
text: typeof err === 'string' ? err : 'Unknown error.',
|
||||
})
|
||||
} finally {
|
||||
isDeleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
const predicate = buildCombinedPredicate()
|
||||
const lines = predicate ? ctx.logLines.value.filter(predicate) : ctx.logLines.value
|
||||
const content = lines.map((l) => l.text).join('\n')
|
||||
|
||||
isSharing.value = true
|
||||
try {
|
||||
const data = await client.mclogs.logs_v1.create(content)
|
||||
if (data.url) {
|
||||
shareModal.value?.show(data.url)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to share logs:', err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to share logs',
|
||||
text: typeof err === 'string' ? err : 'Unknown error.',
|
||||
})
|
||||
} finally {
|
||||
isSharing.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { Mclogs } from '@modrinth/api-client'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type { LogLine, LogSource } from '../types'
|
||||
|
||||
export interface ConsoleManagerContext {
|
||||
logLines: Ref<LogLine[]>
|
||||
|
||||
logSources?: ComputedRef<LogSource[]>
|
||||
activeLogSourceIndex?: Ref<number>
|
||||
|
||||
sendCommand?: (cmd: string) => void
|
||||
showCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
|
||||
disableCommandInput?: boolean | Ref<boolean> | ComputedRef<boolean>
|
||||
|
||||
loading?: Ref<boolean> | ComputedRef<boolean>
|
||||
|
||||
onClear?: () => void
|
||||
onDelete?: () => Promise<void>
|
||||
deleteDisabled?: Ref<boolean> | ComputedRef<boolean>
|
||||
deleteDisabledTooltip?: string
|
||||
|
||||
shareDisabled?: Ref<boolean> | ComputedRef<boolean>
|
||||
|
||||
emptyStateType?: 'server' | 'instance'
|
||||
|
||||
crashAnalysis?: Ref<Mclogs.Insights.v1.InsightsResponse | null>
|
||||
onDismissCrash?: () => void
|
||||
}
|
||||
|
||||
export const [injectConsoleManager, provideConsoleManager] = createContext<ConsoleManagerContext>(
|
||||
'ConsolePageLayout',
|
||||
'consoleManagerContext',
|
||||
)
|
||||
@@ -0,0 +1 @@
|
||||
export * from './console-manager'
|
||||
21
packages/ui/src/layouts/shared/console/types.ts
Normal file
21
packages/ui/src/layouts/shared/console/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'trace'
|
||||
|
||||
export interface LogLine {
|
||||
text: string
|
||||
level: LogLevel | null
|
||||
}
|
||||
|
||||
export interface Log4jEvent {
|
||||
logger_name?: string
|
||||
level?: string
|
||||
thread_name?: string
|
||||
timestamp_millis?: number
|
||||
message?: string
|
||||
throwable?: string
|
||||
}
|
||||
|
||||
export interface LogSource {
|
||||
id: string
|
||||
name: string
|
||||
live: boolean
|
||||
}
|
||||
@@ -13,7 +13,11 @@
|
||||
formatMessage(messages.admonitionHeader, { action: downgrade ? 'downgrade' : 'update' })
|
||||
"
|
||||
>
|
||||
{{ formatMessage(server ? messages.admonitionBody : messages.admonitionBodyApp) }}
|
||||
{{
|
||||
formatMessage(messages.admonitionBody, {
|
||||
action: downgrade ? 'downgrade' : 'update',
|
||||
})
|
||||
}}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
@@ -57,7 +61,6 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
downgrade?: boolean
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
}>()
|
||||
|
||||
@@ -81,11 +84,8 @@ const messages = defineMessages({
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'content.confirm-modpack-update.admonition-body',
|
||||
defaultMessage: 'Any mods or content you added on top of the modpack will be deleted.',
|
||||
},
|
||||
admonitionBodyApp: {
|
||||
id: 'content.confirm-modpack-update.admonition-body-app',
|
||||
defaultMessage: 'Any mods or content you added on top of the modpack will be preserved.',
|
||||
defaultMessage:
|
||||
'{action, select, downgrade {Downgrading} other {Updating}} may cause compatibility issues. Mods or content you added on top of the modpack will be kept, but may not be compatible with the new version.',
|
||||
},
|
||||
confirmButton: {
|
||||
id: 'content.confirm-modpack-update.confirm-button',
|
||||
|
||||
@@ -679,7 +679,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
{{ option.label }}
|
||||
</button>
|
||||
<div class="hidden @[900px]:block">
|
||||
<ButtonStyled type="transparent" hover-color-fill="none">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
:aria-label="
|
||||
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
||||
@@ -699,7 +699,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="@[900px]:hidden">
|
||||
<ButtonStyled type="transparent" hover-color-fill="none">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
:aria-label="
|
||||
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
|
||||
@@ -729,7 +729,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="transparent" hover-color-fill="none">
|
||||
<ButtonStyled type="transparent">
|
||||
<button :disabled="refreshing || ctx.isBusy.value" @click="handleRefresh">
|
||||
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
|
||||
@@ -1,53 +1,4 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" class="mb-4">
|
||||
<template #icon>
|
||||
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{
|
||||
ctx.uploadingLabel
|
||||
? ctx.uploadingLabel(
|
||||
ctx.uploadState.value.completedFiles,
|
||||
ctx.uploadState.value.totalFiles,
|
||||
)
|
||||
: formatMessage(messages.uploadingFiles, {
|
||||
completed: ctx.uploadState.value.completedFiles,
|
||||
total: ctx.uploadState.value.totalFiles,
|
||||
})
|
||||
}}
|
||||
<span v-if="ctx.uploadState.value.currentFileName" class="font-normal text-secondary">
|
||||
— {{ ctx.uploadState.value.currentFileName }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="text-secondary">
|
||||
{{
|
||||
formatMessage(messages.uploadProgress, {
|
||||
uploaded: formatBytes(ctx.uploadState.value.uploadedBytes),
|
||||
total: formatBytes(ctx.uploadState.value.totalBytes),
|
||||
percent: Math.round(uploadOverallProgress * 100),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template v-if="ctx.cancelUpload" #top-right-actions>
|
||||
<ButtonStyled type="outlined" color="blue">
|
||||
<button class="!border" @click="ctx.cancelUpload?.()">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #progress>
|
||||
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
|
||||
</template>
|
||||
</Admonition>
|
||||
</Transition>
|
||||
<TransitionGroup
|
||||
name="fs-op"
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
@@ -89,13 +40,13 @@
|
||||
— {{ op.current_file?.split('/')?.pop() }}
|
||||
</template>
|
||||
</span>
|
||||
<template v-if="op.id && ctx.dismissOperation" #top-right-actions>
|
||||
<template v-if="op.id" #top-right-actions>
|
||||
<ButtonStyled
|
||||
v-if="op.state !== 'done' && !op.state?.startsWith('fail')"
|
||||
type="outlined"
|
||||
color="blue"
|
||||
>
|
||||
<button class="!border" @click="ctx.dismissOperation?.(op.id!, 'cancel')">
|
||||
<button class="!border" @click="ctx.dismissOperation(op.id!, 'cancel')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -106,7 +57,7 @@
|
||||
hover-color-fill="background"
|
||||
:color="op.state === 'done' ? 'green' : 'red'"
|
||||
>
|
||||
<button @click="ctx.dismissOperation?.(op.id!, 'dismiss')">
|
||||
<button @click="ctx.dismissOperation(op.id!, 'dismiss')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -125,29 +76,19 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PackageOpenIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import { PackageOpenIcon, XIcon } from '@modrinth/assets'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import ProgressBar from '#ui/components/base/ProgressBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadingFiles: {
|
||||
id: 'files.operations.uploading-files',
|
||||
defaultMessage: 'Uploading files ({completed}/{total})',
|
||||
},
|
||||
uploadProgress: {
|
||||
id: 'files.operations.upload-progress',
|
||||
defaultMessage: '{uploaded} / {total} ({percent}%)',
|
||||
},
|
||||
extracting: {
|
||||
id: 'files.operations.extracting',
|
||||
defaultMessage: 'Extracting {source}',
|
||||
@@ -166,13 +107,7 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = injectFileManager()
|
||||
const ctx = injectModrinthServerContext()
|
||||
|
||||
const activeOperations = computed(() => ctx.activeOperations?.value ?? [])
|
||||
|
||||
const uploadOverallProgress = computed(() => {
|
||||
const state = ctx.uploadState?.value
|
||||
if (!state || !state.isUploading || state.totalFiles === 0) return 0
|
||||
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
|
||||
})
|
||||
const activeOperations = ctx.activeOperations
|
||||
</script>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { getEditorLanguage, getFileExtension, isImageFile } from '#ui/utils/file-extensions'
|
||||
|
||||
@@ -37,12 +38,6 @@ import { injectFileManager } from '../../providers/file-manager'
|
||||
import type { EditingFile } from '../../types'
|
||||
import FileImageViewer from './FileImageViewer.vue'
|
||||
|
||||
interface MclogsResponse {
|
||||
success: boolean
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
file: EditingFile | null
|
||||
editorComponent: Component | null
|
||||
@@ -55,6 +50,7 @@ const emit = defineEmits<{
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const messages = defineMessages({
|
||||
failedToOpenTitle: {
|
||||
@@ -237,13 +233,7 @@ async function shareToMclogs() {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.mclo.gs/1/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ content: fileContent.value }),
|
||||
})
|
||||
|
||||
const data = (await response.json()) as MclogsResponse
|
||||
const data = await client.mclogs.logs_v1.create(fileContent.value)
|
||||
|
||||
if (data.success && data.url) {
|
||||
await navigator.clipboard.writeText(data.url)
|
||||
@@ -253,7 +243,7 @@ async function shareToMclogs() {
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
throw new Error(data.error)
|
||||
throw new Error('mclo.gs upload failed')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing file:', error)
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
<template #header>{{ ctx.busyWarning.value }}</template>
|
||||
{{ formatMessage(messages.busyWarning) }}
|
||||
</Admonition>
|
||||
<FileOperationAdmonitions />
|
||||
<div class="relative flex w-full flex-col">
|
||||
<div class="relative isolate flex w-full flex-col gap-4">
|
||||
<FileNavbar
|
||||
@@ -235,7 +234,6 @@ import FileEditor from './components/editor/FileEditor.vue'
|
||||
import FileContextMenu from './components/FileContextMenu.vue'
|
||||
import FileManagerError from './components/FileManagerError.vue'
|
||||
import FileNavbar from './components/FileNavbar.vue'
|
||||
import FileOperationAdmonitions from './components/FileOperationAdmonitions.vue'
|
||||
import FileTableHeader from './components/FileTableHeader.vue'
|
||||
import FileTableRow from './components/FileTableRow.vue'
|
||||
import FileCreateItemModal from './components/modals/FileCreateItemModal.vue'
|
||||
|
||||
@@ -207,30 +207,8 @@ export function useInstallationForm(
|
||||
|
||||
async function confirmDisableConflicts() {
|
||||
try {
|
||||
if (ctx.disableIncompatibleContent && ctx.previewSave) {
|
||||
isVerifying.value = true
|
||||
abortController = new AbortController()
|
||||
const loaderVersionId =
|
||||
selectedPlatform.value !== 'vanilla'
|
||||
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
|
||||
: null
|
||||
|
||||
let preview: ContentDiffPreview | null
|
||||
try {
|
||||
preview = await ctx.previewSave(
|
||||
selectedPlatform.value,
|
||||
selectedGameVersion.value,
|
||||
loaderVersionId,
|
||||
abortController.signal,
|
||||
)
|
||||
} finally {
|
||||
isVerifying.value = false
|
||||
abortController = null
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
await ctx.disableIncompatibleContent(preview.diffs)
|
||||
}
|
||||
if (ctx.disableIncompatibleContent) {
|
||||
await ctx.disableIncompatibleContent(selectedGameVersion.value)
|
||||
}
|
||||
|
||||
incompatibleContentVariant.value = null
|
||||
|
||||
@@ -100,6 +100,12 @@ const showModpackVersionActions = computed(() => {
|
||||
return typeof val === 'boolean' ? val : val.value
|
||||
})
|
||||
|
||||
const isLocalFile = computed(() => {
|
||||
const val = ctx.isLocalFile
|
||||
if (val == null) return false
|
||||
return typeof val === 'boolean' ? val : val.value
|
||||
})
|
||||
|
||||
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
|
||||
pendingUpdateVersion.value = version
|
||||
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
|
||||
@@ -381,14 +387,6 @@ const messages = defineMessages({
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(messages.unlinkDescription, {
|
||||
type: ctx.isServer ? 'server' : 'instance',
|
||||
projectType: showModpackVersionActions ? 'modpack' : 'server',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
@@ -407,20 +405,21 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(messages.unlinkDescription, {
|
||||
type: ctx.isServer ? 'server' : 'instance',
|
||||
projectType: showModpackVersionActions ? 'modpack' : 'server',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Reinstall -->
|
||||
<div v-if="showModpackVersionActions" class="flex flex-col gap-2.5">
|
||||
<div v-if="showModpackVersionActions || isLocalFile" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.reinstallModpackTitle) }}
|
||||
</span>
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(messages.reinstallModpackDescription, {
|
||||
type: ctx.isServer ? 'server' : 'instance',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
@@ -440,10 +439,17 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(messages.reinstallModpackDescription, {
|
||||
type: ctx.isServer ? 'server' : 'instance',
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Repair -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<!-- Repair (hidden for local file modpacks — reinstall covers this) -->
|
||||
<div v-if="!isLocalFile" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{
|
||||
formatMessage(
|
||||
@@ -451,15 +457,6 @@ const messages = defineMessages({
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(
|
||||
ctx.isServer
|
||||
? messages.repairServerDescription
|
||||
: messages.repairInstanceDescription,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
@@ -477,6 +474,15 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(
|
||||
ctx.isServer
|
||||
? messages.repairServerDescription
|
||||
: messages.repairInstanceDescription,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -617,16 +623,6 @@ const messages = defineMessages({
|
||||
<span class="font-semibold text-contrast">{{ row.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<CircleAlertIcon class="mt-0.5 size-5 shrink-0 text-orange" />
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(
|
||||
ctx.isServer ? messages.editWarningServer : messages.editWarningInstance,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
@@ -640,6 +636,16 @@ const messages = defineMessages({
|
||||
</ButtonStyled>
|
||||
<slot name="unlinked-extra-buttons" />
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
<CircleAlertIcon class="mt-0.5 size-5 shrink-0 text-orange" />
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(
|
||||
ctx.isServer ? messages.editWarningServer : messages.editWarningInstance,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Repair section -->
|
||||
@@ -651,15 +657,6 @@ const messages = defineMessages({
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(
|
||||
ctx.isServer
|
||||
? messages.repairServerDescription
|
||||
: messages.repairInstanceDescription,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
@@ -677,6 +674,15 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">
|
||||
{{
|
||||
formatMessage(
|
||||
ctx.isServer
|
||||
? messages.repairServerDescription
|
||||
: messages.repairInstanceDescription,
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -686,81 +692,82 @@ const messages = defineMessages({
|
||||
|
||||
<!-- Modals -->
|
||||
<Teleport to="body">
|
||||
<ContentUpdaterModal
|
||||
v-if="form.updatingModpack.value"
|
||||
ref="contentUpdaterModal"
|
||||
:versions="form.updatingProjectVersions.value"
|
||||
:current-game-version="ctx.updaterModalProps.value.currentGameVersion"
|
||||
:current-loader="ctx.updaterModalProps.value.currentLoader"
|
||||
:current-version-id="ctx.updaterModalProps.value.currentVersionId"
|
||||
:is-app="ctx.isApp"
|
||||
project-type="modpack"
|
||||
:project-icon-url="ctx.updaterModalProps.value.projectIconUrl"
|
||||
:project-name="ctx.updaterModalProps.value.projectName"
|
||||
:loading="form.loadingVersions.value"
|
||||
:loading-changelog="form.loadingChangelog.value"
|
||||
@update="handleModpackUpdateRequest"
|
||||
@cancel="form.resetUpdateState()"
|
||||
@version-select="form.handleUpdaterVersionSelect"
|
||||
@version-hover="form.handleUpdaterVersionHover"
|
||||
/>
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateModal"
|
||||
:downgrade="isUpdateDowngrade"
|
||||
:server="ctx.isServer"
|
||||
:backup-tip="
|
||||
[ctx.modpack.value?.title, pendingUpdateVersion?.version_number].filter(Boolean).join(' ')
|
||||
"
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
<ConfirmRepairModal ref="repairModal" :server="ctx.isServer" @repair="handleRepair" />
|
||||
<ConfirmReinstallModal
|
||||
ref="reinstallModal"
|
||||
:server="ctx.isServer"
|
||||
:backup-tip="ctx.modpack.value?.title"
|
||||
@reinstall="handleReinstall"
|
||||
/>
|
||||
<ConfirmUnlinkModal
|
||||
ref="unlinkModal"
|
||||
:server="ctx.isServer"
|
||||
:backup-tip="ctx.modpack.value?.title"
|
||||
@unlink="handleUnlink"
|
||||
/>
|
||||
<div class="relative z-[100]">
|
||||
<ContentUpdaterModal
|
||||
v-if="form.updatingModpack.value"
|
||||
ref="contentUpdaterModal"
|
||||
:versions="form.updatingProjectVersions.value"
|
||||
:current-game-version="ctx.updaterModalProps.value.currentGameVersion"
|
||||
:current-loader="ctx.updaterModalProps.value.currentLoader"
|
||||
:current-version-id="ctx.updaterModalProps.value.currentVersionId"
|
||||
:is-app="ctx.isApp"
|
||||
project-type="modpack"
|
||||
:project-icon-url="ctx.updaterModalProps.value.projectIconUrl"
|
||||
:project-name="ctx.updaterModalProps.value.projectName"
|
||||
:loading="form.loadingVersions.value"
|
||||
:loading-changelog="form.loadingChangelog.value"
|
||||
@update="handleModpackUpdateRequest"
|
||||
@cancel="form.resetUpdateState()"
|
||||
@version-select="form.handleUpdaterVersionSelect"
|
||||
@version-hover="form.handleUpdaterVersionHover"
|
||||
/>
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateModal"
|
||||
:downgrade="isUpdateDowngrade"
|
||||
:backup-tip="
|
||||
[ctx.modpack.value?.title, pendingUpdateVersion?.version_number].filter(Boolean).join(' ')
|
||||
"
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
<ConfirmRepairModal ref="repairModal" :server="ctx.isServer" @repair="handleRepair" />
|
||||
<ConfirmReinstallModal
|
||||
ref="reinstallModal"
|
||||
:server="ctx.isServer"
|
||||
:backup-tip="ctx.modpack.value?.title"
|
||||
@reinstall="handleReinstall"
|
||||
/>
|
||||
<ConfirmUnlinkModal
|
||||
ref="unlinkModal"
|
||||
:server="ctx.isServer"
|
||||
:backup-tip="ctx.modpack.value?.title"
|
||||
@unlink="handleUnlink"
|
||||
/>
|
||||
|
||||
<IncompatibleContentModal
|
||||
v-if="form.incompatibleContentVariant.value"
|
||||
ref="incompatibleContentModal"
|
||||
:variant="form.incompatibleContentVariant.value"
|
||||
:loading="form.isVerifying.value || form.isSaving.value"
|
||||
@confirm-loader-change="form.confirmLoaderChange()"
|
||||
@auto-fix="form.confirmAutoFix()"
|
||||
@disable-conflicts="form.confirmDisableConflicts()"
|
||||
@reset-server="handleIncompatibleResetServer"
|
||||
@cancel="form.cancelPreview()"
|
||||
/>
|
||||
<IncompatibleContentModal
|
||||
v-if="form.incompatibleContentVariant.value"
|
||||
ref="incompatibleContentModal"
|
||||
:variant="form.incompatibleContentVariant.value"
|
||||
:loading="form.isVerifying.value || form.isSaving.value"
|
||||
@confirm-loader-change="form.confirmLoaderChange()"
|
||||
@auto-fix="form.confirmAutoFix()"
|
||||
@disable-conflicts="form.confirmDisableConflicts()"
|
||||
@reset-server="handleIncompatibleResetServer"
|
||||
@cancel="form.cancelPreview()"
|
||||
/>
|
||||
|
||||
<ContentDiffModal
|
||||
v-if="form.pendingPreview.value && !form.incompatibleContentVariant.value"
|
||||
ref="contentDiffModal"
|
||||
:header="formatMessage(messages.confirmVersionChangeHeader)"
|
||||
:description="
|
||||
formatMessage(messages.confirmVersionChangeDescription, {
|
||||
gameVersion: form.pendingPreview.value.newGameVersion,
|
||||
})
|
||||
"
|
||||
:admonition-header="formatMessage(messages.confirmVersionChangeHeader)"
|
||||
:diffs="form.pendingPreview.value.diffs"
|
||||
:has-unknown-content="form.pendingPreview.value.hasUnknownContent"
|
||||
:confirm-label="formatMessage(messages.confirmVersionChange)"
|
||||
:confirm-icon="SaveIcon"
|
||||
:removed-label="formatMessage(messages.removedIncompatible)"
|
||||
:show-backup-creator="ctx.isServer"
|
||||
@confirm="form.confirmSave()"
|
||||
@cancel="form.cancelPreview()"
|
||||
/>
|
||||
<ContentDiffModal
|
||||
v-if="form.pendingPreview.value && !form.incompatibleContentVariant.value"
|
||||
ref="contentDiffModal"
|
||||
:header="formatMessage(messages.confirmVersionChangeHeader)"
|
||||
:description="
|
||||
formatMessage(messages.confirmVersionChangeDescription, {
|
||||
gameVersion: form.pendingPreview.value.newGameVersion,
|
||||
})
|
||||
"
|
||||
:admonition-header="formatMessage(messages.confirmVersionChangeHeader)"
|
||||
:diffs="form.pendingPreview.value.diffs"
|
||||
:has-unknown-content="form.pendingPreview.value.hasUnknownContent"
|
||||
:confirm-label="formatMessage(messages.confirmVersionChange)"
|
||||
:confirm-icon="SaveIcon"
|
||||
:removed-label="formatMessage(messages.removedIncompatible)"
|
||||
:show-backup-creator="ctx.isServer"
|
||||
@confirm="form.confirmSave()"
|
||||
@cancel="form.cancelPreview()"
|
||||
/>
|
||||
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
</div>
|
||||
<slot name="extra-modals" />
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
@@ -4,7 +4,6 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type {
|
||||
ContentDiffItem,
|
||||
ContentDiffPreview,
|
||||
GameVersionOption,
|
||||
InstallationInfoRow,
|
||||
@@ -55,6 +54,9 @@ export interface InstallationSettingsContext {
|
||||
/** When false, hides change-version and reinstall buttons in linked state (default: true) */
|
||||
showModpackVersionActions?: boolean | ComputedRef<boolean>
|
||||
|
||||
/** True when the linked modpack was uploaded as a local file rather than from Modrinth */
|
||||
isLocalFile?: boolean | ComputedRef<boolean>
|
||||
|
||||
repairing?: Ref<boolean>
|
||||
reinstalling?: Ref<boolean>
|
||||
|
||||
@@ -67,10 +69,11 @@ export interface InstallationSettingsContext {
|
||||
disableAllContent?: () => Promise<void>
|
||||
|
||||
/**
|
||||
* Disable only the incompatible addons identified in a content diff preview.
|
||||
* Used when the user chooses "Disable conflicts" instead of "Auto-fix".
|
||||
* Disable addons that are incompatible with the target game version.
|
||||
* Fetches version metadata in bulk, disables any addon whose game_versions
|
||||
* doesn't include the target, plus any custom (non-Modrinth) content.
|
||||
*/
|
||||
disableIncompatibleContent?: (diffs: ContentDiffItem[]) => Promise<void>
|
||||
disableIncompatibleContent?: (targetGameVersion: string) => Promise<void>
|
||||
|
||||
/**
|
||||
* Save the installation settings without auto-resolving content.
|
||||
|
||||
3
packages/ui/src/layouts/shared/server-settings/index.ts
Normal file
3
packages/ui/src/layouts/shared/server-settings/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './pages'
|
||||
export * from './providers'
|
||||
export * from './tabs'
|
||||
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<div class="flex h-full w-full flex-col gap-4">
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- SFTP section -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col items-center justify-between gap-0.5 sm:flex-row">
|
||||
<span class="text-lg font-semibold text-contrast">SFTP</span>
|
||||
<ButtonStyled>
|
||||
<a
|
||||
v-tooltip="'This button only works with compatible SFTP clients (e.g. WinSCP)'"
|
||||
class="!w-full sm:!w-auto"
|
||||
:href="sftpUrl"
|
||||
target="_blank"
|
||||
>
|
||||
<ExternalIcon class="h-5 w-5" />
|
||||
Launch SFTP
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 rounded-2xl bg-surface-2 p-4">
|
||||
<span class="text-lg font-semibold text-contrast">Server Address</span>
|
||||
<div
|
||||
v-tooltip="'Copy SFTP server address'"
|
||||
class="copy-field hover:bg-button-bg-hover"
|
||||
@click="copyToClipboard('Server address', server?.sftp_host)"
|
||||
>
|
||||
<span class="cursor-pointer font-semibold text-primary">
|
||||
{{ server?.sftp_host }}
|
||||
</span>
|
||||
<div class="grid h-10 w-10 place-content-center">
|
||||
<CopyIcon class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 sm:mt-0 sm:flex-row">
|
||||
<div class="flex w-full flex-col justify-center gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">Username</span>
|
||||
<div
|
||||
v-tooltip="'Copy SFTP username'"
|
||||
class="copy-field hover:bg-button-bg-hover"
|
||||
@click="copyToClipboard('Username', server?.sftp_username)"
|
||||
>
|
||||
<div class="truncate font-semibold">
|
||||
{{ server?.sftp_username }}
|
||||
</div>
|
||||
<div class="grid h-10 w-9 place-content-center">
|
||||
<CopyIcon class="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-col justify-center gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">Password</span>
|
||||
<div
|
||||
class="copy-field-has-button [&:hover:not(:has(button:hover))]:bg-button-bg-hover"
|
||||
@click="copyToClipboard('Password', server?.sftp_password)"
|
||||
>
|
||||
<div class="flex items-center gap-1.5 h-full w-full">
|
||||
<div
|
||||
v-tooltip="'Copy SFTP Password'"
|
||||
class="h-full flex justify-between grow items-center"
|
||||
>
|
||||
<div class="truncate font-semibold">
|
||||
{{
|
||||
showPassword
|
||||
? server?.sftp_password
|
||||
: '*'.repeat(server?.sftp_password?.length ?? 0)
|
||||
}}
|
||||
</div>
|
||||
<CopyIcon class="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="transparent" circular>
|
||||
<button
|
||||
v-tooltip="showPassword ? 'Hide password' : 'Show password'"
|
||||
class="hover:bg-button-bg-hover grid h-10 w-10 place-content-center rounded-lg"
|
||||
@click.stop="showPassword = !showPassword"
|
||||
>
|
||||
<!-- look into doing stop propagation here -->
|
||||
<EyeIcon v-if="showPassword" class="h-5 w-5" />
|
||||
<EyeOffIcon v-else class="h-5 w-5" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Startup command section -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex h-10 flex-col items-end justify-between gap-4 sm:flex-row">
|
||||
<label for="startup-command-field" class="mb-0.5 flex flex-col gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">Startup command</span>
|
||||
</label>
|
||||
<ButtonStyled v-if="startupCommand !== defaultStartupCommand" type="transparent">
|
||||
<button
|
||||
:disabled="isStartupLoading || startupCommand === defaultStartupCommand"
|
||||
class="relative !w-full sm:!w-auto"
|
||||
@click="resetToDefault"
|
||||
>
|
||||
<UpdatedIcon class="h-5 w-5" />
|
||||
Default
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<StyledInput
|
||||
id="startup-command-field"
|
||||
v-model="startupCommand"
|
||||
multiline
|
||||
resize="vertical"
|
||||
input-class="font-mono field-sizing-content"
|
||||
:disabled="isStartupLoading"
|
||||
/>
|
||||
<div
|
||||
v-if="isStartupLoading"
|
||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
||||
>
|
||||
<SpinnerIcon class="h-6 w-6 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<span> The command that runs when your server is started. </span>
|
||||
</div>
|
||||
|
||||
<!-- Java version section -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">Java version</span>
|
||||
</div>
|
||||
<div class="relative max-w-xs">
|
||||
<Combobox
|
||||
:id="'java-version-field'"
|
||||
v-model="javaVersion"
|
||||
name="java-version"
|
||||
:options="displayedJavaVersions"
|
||||
:display-value="javaVersionLabel ?? 'Java Version'"
|
||||
:disabled="isStartupLoading"
|
||||
>
|
||||
<template #dropdown-footer>
|
||||
<button
|
||||
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
|
||||
@mousedown.prevent
|
||||
@click="showAllVersions = !showAllVersions"
|
||||
>
|
||||
<EyeOffIcon v-if="showAllVersions" class="size-4" />
|
||||
<EyeIcon v-else class="size-4" />
|
||||
{{ showAllVersions ? 'Hide extra versions' : 'Show all versions' }}
|
||||
</button>
|
||||
</template>
|
||||
</Combobox>
|
||||
<div
|
||||
v-if="isStartupLoading"
|
||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
||||
>
|
||||
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<span> The Java version your server runs on. </span>
|
||||
</div>
|
||||
|
||||
<!-- Java runtime section -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">Java runtime</span>
|
||||
</div>
|
||||
<div class="relative max-w-xs">
|
||||
<Combobox
|
||||
:id="'runtime-field'"
|
||||
v-model="jreVendor"
|
||||
name="runtime"
|
||||
:options="JRE_VENDORS"
|
||||
:display-value="jreVendorLabel ?? 'Runtime'"
|
||||
:disabled="isStartupLoading"
|
||||
/>
|
||||
<div
|
||||
v-if="isStartupLoading"
|
||||
class="bg-bg/50 absolute inset-0 flex items-center justify-center rounded-xl"
|
||||
>
|
||||
<SpinnerIcon class="h-5 w-5 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
<span> The Java runtime your server will use. </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SaveBanner
|
||||
:is-visible="!!hasUnsavedChanges || isPending"
|
||||
:server-id="serverId"
|
||||
:is-updating="isPending"
|
||||
:save="() => saveStartup()"
|
||||
:reset="resetStartup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
CopyIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
SpinnerIcon,
|
||||
UpdatedIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { ButtonStyled, Combobox, StyledInput } from '#ui/components'
|
||||
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { server, serverId, worldId } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// SFTP state
|
||||
const showPassword = ref(false)
|
||||
const sftpUrl = computed(() => `sftp://${server.value?.sftp_username}@${server.value?.sftp_host}`)
|
||||
|
||||
const copyToClipboard = (name: string, textToCopy?: string) => {
|
||||
navigator.clipboard.writeText(textToCopy || '')
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: `${name} copied to clipboard!`,
|
||||
})
|
||||
}
|
||||
|
||||
// Startup state
|
||||
const startupQueryKey = computed(() => ['servers', 'startup', 'v1', serverId, worldId.value])
|
||||
|
||||
const { data: startupData, isLoading: isStartupLoading } = useQuery({
|
||||
queryKey: startupQueryKey,
|
||||
queryFn: () => client.archon.options_v1.getStartup(serverId, worldId.value!),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
const JAVA_VERSIONS = [
|
||||
{ value: 8, label: 'Java 8' },
|
||||
{ value: 11, label: 'Java 11' },
|
||||
{ value: 17, label: 'Java 17' },
|
||||
{ value: 21, label: 'Java 21' },
|
||||
{ value: 25, label: 'Java 25' },
|
||||
]
|
||||
|
||||
const showAllVersions = ref(false)
|
||||
|
||||
type MinecraftReleaseVersion = {
|
||||
major: number
|
||||
minor: number
|
||||
}
|
||||
|
||||
function parseMinecraftReleaseVersion(version: string): MinecraftReleaseVersion | null {
|
||||
const [majorPart, minorPart] = version.split('.')
|
||||
|
||||
if (!majorPart || !minorPart) return null
|
||||
|
||||
const major = Number(majorPart)
|
||||
const minor = Number(minorPart)
|
||||
|
||||
if (!Number.isInteger(major) || !Number.isInteger(minor)) return null
|
||||
|
||||
return { major, minor }
|
||||
}
|
||||
|
||||
function filterJavaVersions(compatibleVersions: number[]) {
|
||||
return JAVA_VERSIONS.filter((version) => compatibleVersions.includes(version.value))
|
||||
}
|
||||
|
||||
const displayedJavaVersions = computed(() => {
|
||||
if (showAllVersions.value) return JAVA_VERSIONS
|
||||
|
||||
const mcVersion = server.value?.mc_version ?? ''
|
||||
if (!mcVersion) return JAVA_VERSIONS
|
||||
|
||||
const releaseVersion = parseMinecraftReleaseVersion(mcVersion)
|
||||
if (!releaseVersion) return JAVA_VERSIONS
|
||||
|
||||
if (releaseVersion.major > 1) {
|
||||
if (releaseVersion.major >= 26) {
|
||||
return filterJavaVersions([25])
|
||||
}
|
||||
|
||||
return JAVA_VERSIONS
|
||||
}
|
||||
|
||||
if (releaseVersion.minor >= 20) return filterJavaVersions([21])
|
||||
if (releaseVersion.minor >= 17) return filterJavaVersions([17, 21])
|
||||
if (releaseVersion.minor >= 12) return filterJavaVersions([8, 11, 17, 21])
|
||||
if (releaseVersion.minor >= 6) return filterJavaVersions([8, 11])
|
||||
return filterJavaVersions([8])
|
||||
})
|
||||
|
||||
const JRE_VENDORS: { value: Archon.Content.v1.JreVendor; label: string }[] = [
|
||||
{ value: 'corretto', label: 'Corretto' },
|
||||
{ value: 'temurin', label: 'Temurin' },
|
||||
{ value: 'graal', label: 'GraalVM' },
|
||||
]
|
||||
|
||||
const savedStartupCommand = computed(() => startupData.value?.startup_command ?? '')
|
||||
const savedJavaVersion = computed(() => startupData.value?.java_version ?? undefined)
|
||||
const savedJreVendor = computed(() => startupData.value?.jre_vendor ?? undefined)
|
||||
const defaultStartupCommand = computed(
|
||||
() => startupData.value?.original_invocation ?? savedStartupCommand.value,
|
||||
)
|
||||
|
||||
const startupCommand = ref('')
|
||||
const javaVersion = ref<number>()
|
||||
const jreVendor = ref<Archon.Content.v1.JreVendor>()
|
||||
|
||||
const javaVersionLabel = computed(
|
||||
() => JAVA_VERSIONS.find((v) => v.value === javaVersion.value)?.label,
|
||||
)
|
||||
const jreVendorLabel = computed(() => JRE_VENDORS.find((v) => v.value === jreVendor.value)?.label)
|
||||
|
||||
function syncFormFromData() {
|
||||
startupCommand.value = savedStartupCommand.value
|
||||
javaVersion.value = savedJavaVersion.value
|
||||
jreVendor.value = savedJreVendor.value
|
||||
}
|
||||
|
||||
watch(
|
||||
startupData,
|
||||
(newData, oldData) => {
|
||||
if (newData && !oldData) {
|
||||
syncFormFromData()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
startupCommand.value !== savedStartupCommand.value ||
|
||||
javaVersion.value !== savedJavaVersion.value ||
|
||||
jreVendor.value !== savedJreVendor.value,
|
||||
)
|
||||
|
||||
const { mutate: saveStartup, isPending } = useMutation({
|
||||
mutationFn: () =>
|
||||
client.archon.options_v1.patchStartup(serverId, worldId.value!, {
|
||||
startup_command: startupCommand.value || null,
|
||||
java_version: javaVersion.value ?? null,
|
||||
jre_vendor: jreVendor.value ?? null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: startupQueryKey.value })
|
||||
syncFormFromData()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server settings updated',
|
||||
text: 'Your server settings were successfully changed.',
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update server arguments',
|
||||
text: 'Please try again later.',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function resetStartup() {
|
||||
syncFormFromData()
|
||||
}
|
||||
|
||||
function resetToDefault() {
|
||||
startupCommand.value = defaultStartupCommand.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.copy-field {
|
||||
@apply flex h-10 cursor-pointer items-center justify-between gap-2 rounded-lg bg-button-bg px-3 pr-1.5 transition-all;
|
||||
@apply hover:brightness-125 active:scale-95;
|
||||
}
|
||||
|
||||
.copy-field-has-button {
|
||||
@apply flex h-10 cursor-pointer items-center justify-between gap-2 rounded-lg bg-button-bg px-3 pr-1.5 transition-all;
|
||||
@apply [&:hover:not(:has(button:hover))]:brightness-125 [&:active:not(:has(button:active))]:scale-95;
|
||||
}
|
||||
</style>
|
||||
288
packages/ui/src/layouts/shared/server-settings/pages/general.vue
Normal file
288
packages/ui/src/layouts/shared/server-settings/pages/general.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="relative h-full w-full">
|
||||
<div v-if="data" class="flex h-full w-full flex-col">
|
||||
<div class="flex flex-col gap-6">
|
||||
<div class="flex justify-start gap-16">
|
||||
<div class="flex max-w-[500px] grow flex-col gap-6">
|
||||
<!-- Server name -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="server-name-field" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">Server name</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<StyledInput
|
||||
id="server-name-field"
|
||||
v-model="serverName"
|
||||
wrapper-class="w-full"
|
||||
:maxlength="48"
|
||||
@keyup.enter="!serverName && saveGeneral"
|
||||
/>
|
||||
<span>This name is only visible on Modrinth.</span>
|
||||
<div class="text-red font-medium">
|
||||
<span v-if="!isValidServerName"> Server name cannot be empty. </span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hostname -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="server-subdomain" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">Hostname</span>
|
||||
<div
|
||||
class="flex w-full overflow-hidden rounded-xl bg-button-bg px-3 [box-shadow:var(--shadow-inset-sm)] transition-[box-shadow] duration-100 ease-in-out focus-within:[box-shadow:0_0_0_0.25rem_var(--color-brand-shadow)]"
|
||||
>
|
||||
<div class="relative inline-flex min-h-9 items-center">
|
||||
<span
|
||||
class="pointer-events-none invisible whitespace-pre px-px text-base font-medium"
|
||||
aria-hidden="true"
|
||||
>{{ serverSubdomain || 'Enter subdomain...' }}</span
|
||||
>
|
||||
<input
|
||||
id="server-subdomain"
|
||||
:value="serverSubdomain"
|
||||
placeholder="Enter subdomain..."
|
||||
:maxlength="32"
|
||||
class="absolute left-px inset-0 bg-transparent !p-0 text-base font-medium text-primary !shadow-none transition-colors placeholder:text-secondary focus:text-contrast"
|
||||
autocomplete="off"
|
||||
@input="serverSubdomain = ($event.target as HTMLInputElement).value"
|
||||
@keyup.enter="saveGeneral"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex min-h-9 shrink-0 select-none items-center py-2 pr-4 font-medium opacity-50 [filter:grayscale(50%)]"
|
||||
:class="!serverSubdomain ? '!ml-auto' : ''"
|
||||
>
|
||||
.modrinth.gg
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
<span>Your friends can connect to your server using this address.</span>
|
||||
<div v-if="!isValidSubdomain" class="text-red font-medium">
|
||||
<span v-if="!isValidLengthSubdomain">
|
||||
Subdomain must be at least 5 characters long.
|
||||
</span>
|
||||
<span v-if="!isValidCharsSubdomain">
|
||||
Subdomain can only contain alphanumeric characters and dashes.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EditServerIcon v-if="!data.is_medal" />
|
||||
</div>
|
||||
|
||||
<!-- preferences -->
|
||||
<div
|
||||
v-for="(prefConfig, key) in preferences"
|
||||
:key="key"
|
||||
class="flex items-center justify-between gap-2"
|
||||
>
|
||||
<label :for="`pref-${key}`" class="flex flex-col gap-1">
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">{{ prefConfig.displayName }}</span>
|
||||
<div
|
||||
v-if="!prefConfig.implemented"
|
||||
class="hidden items-center gap-1 rounded-full bg-surface-2 p-1 px-1.5 text-xs font-semibold sm:flex"
|
||||
>
|
||||
Coming Soon
|
||||
</div>
|
||||
</div>
|
||||
<span>{{ prefConfig.description }}</span>
|
||||
</label>
|
||||
<Toggle
|
||||
:id="`pref-${key}`"
|
||||
v-model="newUserPreferences[key]"
|
||||
class="flex-none"
|
||||
:disabled="!prefConfig.implemented"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div class="text-lg m-0 font-semibold text-contrast">Info</div>
|
||||
<div class="flex flex-col gap-2.5 rounded-xl bg-surface-2 p-4">
|
||||
<div
|
||||
v-for="property in infoProperties"
|
||||
:key="property.name"
|
||||
class="flex items-center justify-between gap-4"
|
||||
>
|
||||
<template v-if="property.value !== 'Unknown'">
|
||||
<span>{{ property.name }}</span>
|
||||
<CopyCode :text="property.value" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else />
|
||||
<SaveBanner
|
||||
:is-visible="(!!hasUnsavedChanges && !!isValidServerName) || isUpdating"
|
||||
:server-id="serverId"
|
||||
:is-updating="isUpdating || busyReasons.length > 0"
|
||||
:save="saveGeneral"
|
||||
:reset="resetGeneral"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { CopyCode, StyledInput, Toggle } from '#ui/components'
|
||||
import EditServerIcon from '#ui/components/servers/edit-server-icon/EditServerIcon.vue'
|
||||
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { server: data, serverId, busyReasons } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const serverName = ref(data.value?.name)
|
||||
const serverSubdomain = ref(data.value?.net?.domain ?? '')
|
||||
|
||||
watch(data, (newData) => {
|
||||
if (newData) {
|
||||
serverName.value = newData.name
|
||||
serverSubdomain.value = newData.net?.domain ?? ''
|
||||
}
|
||||
})
|
||||
const isValidLengthSubdomain = computed(() => serverSubdomain.value.length >= 5)
|
||||
const isValidCharsSubdomain = computed(
|
||||
() => !serverSubdomain.value || /^[a-zA-Z0-9-]+$/.test(serverSubdomain.value),
|
||||
)
|
||||
const isValidSubdomain = computed(() => isValidLengthSubdomain.value && isValidCharsSubdomain.value)
|
||||
|
||||
const isUpdating = ref(false)
|
||||
const isValidServerName = computed(() => (serverName.value?.length ?? 0) > 0)
|
||||
|
||||
watch(serverName, (newValue, oldValue) => {
|
||||
if (!(newValue?.length ?? 0)) {
|
||||
serverName.value = oldValue
|
||||
}
|
||||
})
|
||||
|
||||
// Preferences
|
||||
const preferences = {
|
||||
hideSubdomainLabel: {
|
||||
displayName: 'Hide subdomain label',
|
||||
description: 'When enabled, the subdomain label will be hidden from the server header.',
|
||||
implemented: true,
|
||||
},
|
||||
// autoRestart: {
|
||||
// displayName: 'Auto restarts',
|
||||
// description: 'Automatically restart the server if it crashes.',
|
||||
// implemented: false,
|
||||
// },
|
||||
ramAsNumber: {
|
||||
displayName: 'RAM as bytes',
|
||||
description: 'Show RAM usage in bytes instead of a percentage.',
|
||||
implemented: true,
|
||||
},
|
||||
} as const
|
||||
|
||||
type PreferenceKeys = keyof typeof preferences
|
||||
|
||||
type UserPreferences = {
|
||||
[K in PreferenceKeys]: boolean
|
||||
}
|
||||
|
||||
const defaultPreferences: UserPreferences = {
|
||||
hideSubdomainLabel: false,
|
||||
// autoRestart: false,
|
||||
ramAsNumber: false,
|
||||
}
|
||||
|
||||
const userPreferences = useStorage<UserPreferences>(
|
||||
`pyro-server-${serverId}-preferences`,
|
||||
defaultPreferences,
|
||||
)
|
||||
|
||||
const newUserPreferences = ref<UserPreferences>(JSON.parse(JSON.stringify(userPreferences.value)))
|
||||
|
||||
// Info properties
|
||||
const infoProperties = [
|
||||
{ name: 'Server ID', value: serverId ?? 'Unknown' },
|
||||
{ name: 'Node', value: data.value?.node?.instance ?? 'Unknown' },
|
||||
]
|
||||
|
||||
// Unsaved changes tracking (API fields + preferences)
|
||||
const hasUnsavedChanges = computed(
|
||||
() =>
|
||||
(serverName.value && serverName.value !== data.value?.name) ||
|
||||
serverSubdomain.value !== data.value?.net?.domain ||
|
||||
JSON.stringify(newUserPreferences.value) !== JSON.stringify(userPreferences.value),
|
||||
)
|
||||
|
||||
const saveGeneral = async () => {
|
||||
if (!isValidServerName.value || !isValidSubdomain.value) return
|
||||
|
||||
try {
|
||||
isUpdating.value = true
|
||||
if (serverName.value !== data.value?.name) {
|
||||
await client.archon.servers_v0.updateName(serverId, serverName.value ?? '')
|
||||
}
|
||||
if (serverSubdomain.value !== data.value?.net?.domain) {
|
||||
try {
|
||||
const result = await client.archon.servers_v0.checkSubdomainAvailability(
|
||||
serverSubdomain.value,
|
||||
)
|
||||
const available = result.available
|
||||
|
||||
if (!available) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Subdomain not available',
|
||||
text: 'The subdomain you entered is already in use.',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await client.archon.servers_v0.changeSubdomain(serverId, serverSubdomain.value)
|
||||
} catch (error) {
|
||||
console.error('Error checking subdomain availability:', error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Error checking availability',
|
||||
text: 'Failed to verify if the subdomain is available.',
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Save preferences to localStorage
|
||||
userPreferences.value = { ...newUserPreferences.value }
|
||||
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['servers', 'detail', serverId],
|
||||
})
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server settings updated',
|
||||
text: 'Your server settings were successfully changed.',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update server settings',
|
||||
text: 'An error occurred while attempting to update your server settings.',
|
||||
})
|
||||
} finally {
|
||||
isUpdating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetGeneral = () => {
|
||||
serverName.value = data.value?.name || ''
|
||||
serverSubdomain.value = data.value?.net?.domain ?? ''
|
||||
newUserPreferences.value = { ...userPreferences.value }
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as ServerSettingsAdvancedPage } from './advanced.vue'
|
||||
export { default as ServerSettingsGeneralPage } from './general.vue'
|
||||
export { default as ServerSettingsInstallationPage } from './installation.vue'
|
||||
export { default as ServerSettingsNetworkPage } from './network.vue'
|
||||
export { default as ServerSettingsPropertiesPage } from './properties.vue'
|
||||
@@ -0,0 +1,872 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Teleport to="body">
|
||||
<div class="relative z-[100]">
|
||||
<ConfirmModal
|
||||
ref="resetToOnboardingModal"
|
||||
:title="formatMessage(messages.resetToOnboardingModalTitle)"
|
||||
:description="formatMessage(messages.resetToOnboardingModalDescription)"
|
||||
:proceed-label="formatMessage(messages.resetToOnboardingButton)"
|
||||
@proceed="confirmResetToOnboarding"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<InstallationSettingsLayout ref="installationSettingsLayout" @reset-server="setupModal?.show()">
|
||||
<template #extra>
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">{{
|
||||
formatMessage(messages.resetServerTitle)
|
||||
}}</span>
|
||||
<div>
|
||||
<ButtonStyled color="red">
|
||||
<button class="!shadow-none" :disabled="isInstalling" @click="setupModal?.show()">
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.resetServerButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">
|
||||
{{ formatMessage(messages.resetServerDescription) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #extra-modals>
|
||||
<Teleport to="body">
|
||||
<div class="relative z-[100]">
|
||||
<ServerSetupModal
|
||||
ref="setupModal"
|
||||
@reinstall="onReinstall"
|
||||
@browse-modpacks="onBrowseModpacks"
|
||||
/>
|
||||
<UploadProgressModal ref="uploadProgressModal" />
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
</InstallationSettingsLayout>
|
||||
|
||||
<div v-if="isSiteAdmin" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.supportOptionsTitle) }}
|
||||
</span>
|
||||
<div>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
class="!shadow-none"
|
||||
:disabled="!worldId || isResettingToOnboarding"
|
||||
@click="resetToOnboardingModal?.show()"
|
||||
>
|
||||
<RotateCounterClockwiseIcon class="size-5" />
|
||||
{{ formatMessage(messages.resetToOnboardingButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon, LauncherMeta } from '@modrinth/api-client'
|
||||
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
ConfirmModal,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
injectServerSettings,
|
||||
injectTags,
|
||||
InstallationSettingsLayout,
|
||||
provideInstallationSettings,
|
||||
ServerSetupModal,
|
||||
UploadProgressModal,
|
||||
useDebugLogger,
|
||||
useModrinthServersConsole,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { injectFilePicker } from '#ui/providers/file-picker'
|
||||
|
||||
const debug = useDebugLogger('LoaderPage')
|
||||
const client = injectModrinthClient()
|
||||
const { server, serverId, worldId, isSyncingContent, busyReasons } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const tags = injectTags()
|
||||
const { formatMessage } = useVIntl()
|
||||
const serverSettings = injectServerSettings()
|
||||
const filePicker = injectFilePicker()
|
||||
const modrinthServersConsole = useModrinthServersConsole()
|
||||
|
||||
const uploadProgressModal =
|
||||
useTemplateRef<InstanceType<typeof UploadProgressModal>>('uploadProgressModal')
|
||||
|
||||
const messages = defineMessages({
|
||||
resetServerTitle: {
|
||||
id: 'hosting.loader.reset-server',
|
||||
defaultMessage: 'Reset server',
|
||||
},
|
||||
resetServerDescription: {
|
||||
id: 'hosting.loader.reset-server-description',
|
||||
defaultMessage:
|
||||
'Removes all data on your server, including your worlds, mods, and configuration files. Backups will remain and can be restored.',
|
||||
},
|
||||
loaderVersionLabel: {
|
||||
id: 'hosting.loader.loader-version',
|
||||
defaultMessage: '{loader, select, null {Loader} other {{loader}}} version',
|
||||
},
|
||||
failedToLoadVersions: {
|
||||
id: 'hosting.loader.failed-to-load-versions',
|
||||
defaultMessage: 'Failed to load versions',
|
||||
},
|
||||
failedToChangeVersion: {
|
||||
id: 'hosting.loader.failed-to-change-version',
|
||||
defaultMessage: 'Failed to change modpack version',
|
||||
},
|
||||
failedToSaveSettings: {
|
||||
id: 'hosting.loader.failed-to-save-settings',
|
||||
defaultMessage: 'Failed to save installation settings',
|
||||
},
|
||||
repairStartedTitle: {
|
||||
id: 'hosting.loader.repair-started-title',
|
||||
defaultMessage: 'Repair completed',
|
||||
},
|
||||
repairStartedText: {
|
||||
id: 'hosting.loader.repair-started-text',
|
||||
defaultMessage: 'Your server installation has been repaired.',
|
||||
},
|
||||
failedToRepair: {
|
||||
id: 'hosting.loader.failed-to-repair',
|
||||
defaultMessage: 'Failed to repair server',
|
||||
},
|
||||
failedToReinstall: {
|
||||
id: 'hosting.loader.failed-to-reinstall',
|
||||
defaultMessage: 'Failed to reinstall modpack',
|
||||
},
|
||||
failedToUnlink: {
|
||||
id: 'hosting.loader.failed-to-unlink',
|
||||
defaultMessage: 'Failed to unlink modpack',
|
||||
},
|
||||
supportOptionsTitle: {
|
||||
id: 'hosting.loader.support-options-title',
|
||||
defaultMessage: 'Support options',
|
||||
},
|
||||
resetToOnboardingButton: {
|
||||
id: 'hosting.loader.reset-to-onboarding-button',
|
||||
defaultMessage: 'Reset to onboarding',
|
||||
},
|
||||
resetToOnboardingModalTitle: {
|
||||
id: 'hosting.loader.reset-to-onboarding-modal-title',
|
||||
defaultMessage: 'Reset to onboarding',
|
||||
},
|
||||
resetToOnboardingModalDescription: {
|
||||
id: 'hosting.loader.reset-to-onboarding-modal-description',
|
||||
defaultMessage:
|
||||
'This will send the server back into onboarding so setup can be completed again. Are you sure you want to continue?',
|
||||
},
|
||||
resetToOnboardingSuccessTitle: {
|
||||
id: 'hosting.loader.reset-to-onboarding-success-title',
|
||||
defaultMessage: 'Server reset to onboarding',
|
||||
},
|
||||
resetToOnboardingSuccessDescription: {
|
||||
id: 'hosting.loader.reset-to-onboarding-success-description',
|
||||
defaultMessage: 'The server has been returned to the onboarding flow.',
|
||||
},
|
||||
failedToResetToOnboarding: {
|
||||
id: 'hosting.loader.failed-to-reset-to-onboarding',
|
||||
defaultMessage: 'Failed to reset server to onboarding',
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
reinstall: [unknown?]
|
||||
'reinstall-failed': []
|
||||
}>()
|
||||
|
||||
const isInstalling = computed(() => {
|
||||
const val =
|
||||
server.value?.status === 'installing' || isSyncingContent.value || busyReasons.value.length > 0
|
||||
debug(
|
||||
'isInstalling:',
|
||||
val,
|
||||
'server.status:',
|
||||
server.value?.status,
|
||||
'isSyncingContent:',
|
||||
isSyncingContent.value,
|
||||
)
|
||||
return val
|
||||
})
|
||||
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
|
||||
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
|
||||
|
||||
async function invalidateServerState() {
|
||||
debug('invalidateServerState: starting')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
|
||||
])
|
||||
debug('invalidateServerState: complete')
|
||||
}
|
||||
|
||||
const addonsQuery = useQuery({
|
||||
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
const modpack = computed(() => addonsQuery.data.value?.modpack ?? null)
|
||||
|
||||
const modpackProjectId = computed(() => {
|
||||
const spec = modpack.value?.spec
|
||||
return spec?.platform === 'modrinth' ? spec.project_id : null
|
||||
})
|
||||
|
||||
const modpackVersionsQuery = useQuery({
|
||||
queryKey: computed(() => ['labrinth', 'versions', 'v2', modpackProjectId.value]),
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v2.getProjectVersions(modpackProjectId.value!, {
|
||||
include_changelog: false,
|
||||
}),
|
||||
enabled: computed(() => !!modpackProjectId.value),
|
||||
})
|
||||
|
||||
const isSiteAdmin = computed(() => serverSettings.currentUserRole.value === 'admin')
|
||||
|
||||
const editingPlatform = ref(server.value?.loader?.toLowerCase() ?? 'vanilla')
|
||||
const editingGameVersion = ref(server.value?.mc_version ?? '')
|
||||
const resetToOnboardingModal = ref<InstanceType<typeof ConfirmModal>>()
|
||||
const isResettingToOnboarding = ref(false)
|
||||
|
||||
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
|
||||
|
||||
function toApiLoaderName(loader: string): string {
|
||||
return loader === 'neoforge' ? 'neo' : loader
|
||||
}
|
||||
|
||||
const apiLoaderName = computed(() =>
|
||||
modLoaders.includes(editingPlatform.value) ? toApiLoaderName(editingPlatform.value) : null,
|
||||
)
|
||||
|
||||
const manifestQuery = useQuery({
|
||||
queryKey: computed(() => ['loader-manifest', apiLoaderName.value] as const),
|
||||
queryFn: () => client.launchermeta.manifest_v0.getManifest(apiLoaderName.value!),
|
||||
enabled: computed(() => !!apiLoaderName.value),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const paperBuildsQuery = useQuery({
|
||||
queryKey: computed(() => ['paper-builds', editingGameVersion.value] as const),
|
||||
queryFn: () => client.paper.versions_v3.getBuilds(editingGameVersion.value),
|
||||
enabled: computed(() => editingPlatform.value === 'paper' && !!editingGameVersion.value),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const purpurBuildsQuery = useQuery({
|
||||
queryKey: computed(() => ['purpur-builds', editingGameVersion.value] as const),
|
||||
queryFn: () => client.purpur.versions_v2.getBuilds(editingGameVersion.value),
|
||||
enabled: computed(() => editingPlatform.value === 'purpur' && !!editingGameVersion.value),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const paperSupportedVersionsQuery = useQuery({
|
||||
queryKey: ['paper-supported-versions'] as const,
|
||||
queryFn: async () => {
|
||||
const project = await client.paper.versions_v3.getProject()
|
||||
return new Set(Object.values(project.versions).flat())
|
||||
},
|
||||
enabled: computed(() => editingPlatform.value === 'paper'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const purpurSupportedVersionsQuery = useQuery({
|
||||
queryKey: ['purpur-supported-versions'] as const,
|
||||
queryFn: async () => {
|
||||
const project = await client.purpur.versions_v2.getProject()
|
||||
return new Set(project.versions)
|
||||
},
|
||||
enabled: computed(() => editingPlatform.value === 'purpur'),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
type LoaderVersionEntry = LauncherMeta.Manifest.v0.LoaderVersion
|
||||
|
||||
function getLoaderVersionsForGameVersion(
|
||||
loader: string,
|
||||
gameVersion: string,
|
||||
): LoaderVersionEntry[] {
|
||||
if (loader === 'paper') {
|
||||
return (paperBuildsQuery.data.value?.builds ?? [])
|
||||
.toSorted((a, b) => b - a)
|
||||
.map((b) => ({ id: String(b), stable: true }))
|
||||
}
|
||||
if (loader === 'purpur') {
|
||||
return (purpurBuildsQuery.data.value?.builds.all ?? [])
|
||||
.toSorted((a, b) => parseInt(b) - parseInt(a))
|
||||
.map((b) => ({ id: b, stable: true }))
|
||||
}
|
||||
|
||||
const manifest = manifestQuery.data.value?.gameVersions
|
||||
if (!manifest) return []
|
||||
|
||||
const placeholder = manifest.find((x) => x.id === '${modrinth.gameVersion}')
|
||||
if (placeholder) return placeholder.loaders
|
||||
|
||||
const entry = manifest.find((x) => x.id === gameVersion)
|
||||
return entry?.loaders ?? []
|
||||
}
|
||||
|
||||
function toApiLoader(loader: string): Archon.Content.v1.Modloader {
|
||||
if (loader === 'neoforge') return 'neo_forge'
|
||||
return loader as Archon.Content.v1.Modloader
|
||||
}
|
||||
|
||||
provideInstallationSettings({
|
||||
loading: computed(() => !server.value || addonsQuery.isLoading.value),
|
||||
installationInfo: computed(() => {
|
||||
const addons = addonsQuery.data.value
|
||||
const rawLoader = addons?.modloader ?? server.value?.loader ?? null
|
||||
const loader = rawLoader ? formatLoaderLabel(rawLoader) : null
|
||||
const gameVersion = addons?.game_version ?? server.value?.mc_version ?? null
|
||||
const loaderVersion = addons?.modloader_version ?? server.value?.loader_version ?? null
|
||||
|
||||
debug('installationInfo computed:', {
|
||||
'addons?.modloader': addons?.modloader,
|
||||
'server.loader': server.value?.loader,
|
||||
rawLoader,
|
||||
loader,
|
||||
'addons?.game_version': addons?.game_version,
|
||||
'server.mc_version': server.value?.mc_version,
|
||||
gameVersion,
|
||||
'addons?.modloader_version': addons?.modloader_version,
|
||||
'server.loader_version': server.value?.loader_version,
|
||||
loaderVersion,
|
||||
'addonsQuery.isLoading': addonsQuery.isLoading.value,
|
||||
'addonsQuery.isFetching': addonsQuery.isFetching.value,
|
||||
})
|
||||
|
||||
const rows = [
|
||||
{ label: formatMessage(commonMessages.platformLabel), value: loader },
|
||||
{ label: formatMessage(commonMessages.gameVersionLabel), value: gameVersion },
|
||||
]
|
||||
if (loader !== 'Vanilla') {
|
||||
rows.push({
|
||||
label: formatMessage(messages.loaderVersionLabel, { loader: loader ?? 'null' }),
|
||||
value: loaderVersion,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(() => {
|
||||
const val = !!modpack.value
|
||||
debug('isLinked:', val, 'modpack:', modpackProjectId.value)
|
||||
return val
|
||||
}),
|
||||
isBusy: isInstalling,
|
||||
modpack: computed(() => {
|
||||
if (!modpack.value) return null
|
||||
const isLocal = modpack.value.spec.platform === 'local_file'
|
||||
return {
|
||||
iconUrl: modpack.value.icon_url,
|
||||
title:
|
||||
modpack.value.title ?? (isLocal ? modpack.value.spec.name : modpack.value.spec.project_id),
|
||||
link: modpackProjectId.value ? `/project/${modpackProjectId.value}` : undefined,
|
||||
versionNumber: modpack.value.version_number,
|
||||
filename: isLocal ? modpack.value.spec.filename : undefined,
|
||||
owner: modpack.value.owner
|
||||
? {
|
||||
id: modpack.value.owner.id,
|
||||
name: modpack.value.owner.name,
|
||||
iconUrl: modpack.value.owner.icon_url,
|
||||
type: modpack.value.owner.type as 'user' | 'organization',
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
}),
|
||||
currentPlatform: computed(() => server.value?.loader?.toLowerCase() ?? 'vanilla'),
|
||||
currentGameVersion: computed(() => server.value?.mc_version ?? ''),
|
||||
currentLoaderVersion: computed(() => server.value?.loader_version ?? ''),
|
||||
availablePlatforms: ['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur'],
|
||||
|
||||
editingPlatformRef: editingPlatform,
|
||||
editingGameVersionRef: editingGameVersion,
|
||||
|
||||
resolveGameVersions(loader, showSnapshots) {
|
||||
const versions = showSnapshots
|
||||
? tags.gameVersions.value
|
||||
: tags.gameVersions.value.filter((v) => v.version_type === 'release')
|
||||
|
||||
if (loader && loader !== 'vanilla') {
|
||||
if (loader === 'paper') {
|
||||
const supported = paperSupportedVersionsQuery.data.value
|
||||
if (supported) {
|
||||
return versions
|
||||
.filter((v) => supported.has(v.version))
|
||||
.map((v) => ({ value: v.version, label: v.version }))
|
||||
}
|
||||
} else if (loader === 'purpur') {
|
||||
const supported = purpurSupportedVersionsQuery.data.value
|
||||
if (supported) {
|
||||
return versions
|
||||
.filter((v) => supported.has(v.version))
|
||||
.map((v) => ({ value: v.version, label: v.version }))
|
||||
}
|
||||
} else {
|
||||
const manifest = manifestQuery.data.value?.gameVersions
|
||||
if (manifest) {
|
||||
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
|
||||
if (!hasPlaceholder) {
|
||||
const supportedVersions = new Set(
|
||||
manifest.filter((x) => x.loaders.length > 0).map((x) => x.id),
|
||||
)
|
||||
return versions
|
||||
.filter((v) => supportedVersions.has(v.version))
|
||||
.map((v) => ({ value: v.version, label: v.version }))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return versions.map((v) => ({ value: v.version, label: v.version }))
|
||||
},
|
||||
|
||||
resolveLoaderVersions(loader, gameVersion) {
|
||||
if (loader === 'vanilla' || !gameVersion) return []
|
||||
return getLoaderVersionsForGameVersion(loader, gameVersion)
|
||||
},
|
||||
|
||||
resolveHasSnapshots(loader) {
|
||||
if (loader === 'vanilla') {
|
||||
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
|
||||
}
|
||||
if (loader === 'paper') {
|
||||
const supported = paperSupportedVersionsQuery.data.value
|
||||
if (!supported) return false
|
||||
return tags.gameVersions.value.some(
|
||||
(v) => v.version_type !== 'release' && supported.has(v.version),
|
||||
)
|
||||
}
|
||||
if (loader === 'purpur') {
|
||||
const supported = purpurSupportedVersionsQuery.data.value
|
||||
if (!supported) return false
|
||||
return tags.gameVersions.value.some(
|
||||
(v) => v.version_type !== 'release' && supported.has(v.version),
|
||||
)
|
||||
}
|
||||
const manifest = manifestQuery.data.value?.gameVersions
|
||||
if (!manifest) return false
|
||||
const hasPlaceholder = manifest.some((x) => x.id === '${modrinth.gameVersion}')
|
||||
if (hasPlaceholder) {
|
||||
return tags.gameVersions.value.some((v) => v.version_type !== 'release')
|
||||
}
|
||||
const supportedVersions = new Set(manifest.filter((x) => x.loaders.length > 0).map((x) => x.id))
|
||||
const supported = tags.gameVersions.value.filter((v) => supportedVersions.has(v.version))
|
||||
return supported.some((v) => v.version_type !== 'release')
|
||||
},
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
debug('save: called with', { platform, gameVersion, loaderVersionId })
|
||||
const currentPlatform = server.value?.loader?.toLowerCase() ?? 'vanilla'
|
||||
const platformChanged = platform !== currentPlatform
|
||||
const gameVersionChanged = gameVersion !== (server.value?.mc_version ?? '')
|
||||
const loaderVersionChanged =
|
||||
loaderVersionId !== null && loaderVersionId !== (server.value?.loader_version ?? '')
|
||||
|
||||
let resolvedLoaderVersion = loaderVersionId
|
||||
if (!resolvedLoaderVersion && platform !== 'vanilla') {
|
||||
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
|
||||
resolvedLoaderVersion = versions[0]?.id ?? null
|
||||
}
|
||||
|
||||
debug('save: emitting reinstall before API call')
|
||||
emit(
|
||||
'reinstall',
|
||||
platformChanged || loaderVersionChanged
|
||||
? { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion }
|
||||
: { mVersion: gameVersion },
|
||||
)
|
||||
try {
|
||||
if (platformChanged || loaderVersionChanged) {
|
||||
const request: Archon.Content.v1.InstallWorldContent = {
|
||||
content_variant: 'bare',
|
||||
loader: toApiLoader(platform),
|
||||
version: resolvedLoaderVersion ?? '',
|
||||
game_version: gameVersion || undefined,
|
||||
soft_override: true,
|
||||
}
|
||||
debug('save: platform/loader version changed, calling installContent', request)
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
|
||||
} else if (gameVersionChanged) {
|
||||
debug('save: game version only, calling applyGameVersionUpdate', gameVersion)
|
||||
await client.archon.content_v1.applyGameVersionUpdate(serverId, worldId.value!, gameVersion)
|
||||
}
|
||||
debug('save: succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
} catch (err) {
|
||||
debug('save: failed, emitting reinstall-failed', err)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToSaveSettings),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called')
|
||||
try {
|
||||
await client.archon.content_v1.repair(serverId, worldId.value!)
|
||||
debug('repair: API succeeded, invalidating')
|
||||
await invalidateServerState()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.repairStartedTitle),
|
||||
text: formatMessage(messages.repairStartedText),
|
||||
})
|
||||
} catch (err) {
|
||||
debug('repair: failed', err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToRepair),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
if (!modpack.value) return
|
||||
if (modpack.value.spec.platform === 'local_file') {
|
||||
debug('reinstallModpack: local file, opening file picker')
|
||||
const picked = await filePicker.pickModpackFile()
|
||||
if (!picked) return
|
||||
try {
|
||||
const handle = client.kyros.content_v1.uploadModpackFile(
|
||||
worldId.value!,
|
||||
picked.file,
|
||||
{ known: {} },
|
||||
{ softOverride: true },
|
||||
)
|
||||
await uploadProgressModal.value!.track(handle)
|
||||
emit('reinstall')
|
||||
invalidateServerState()
|
||||
} catch (err) {
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToReinstall),
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
if (modpack.value.spec.platform !== 'modrinth') return
|
||||
debug(
|
||||
'reinstallModpack: called, project:',
|
||||
modpack.value.spec.project_id,
|
||||
'version:',
|
||||
modpack.value.spec.version_id,
|
||||
)
|
||||
debug('reinstallModpack: emitting reinstall before API call')
|
||||
emit('reinstall')
|
||||
try {
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: modpack.value.spec.project_id,
|
||||
version_id: modpack.value.spec.version_id,
|
||||
},
|
||||
soft_override: true,
|
||||
})
|
||||
debug('reinstallModpack: installContent succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
} catch (err) {
|
||||
debug('reinstallModpack: failed, emitting reinstall-failed', err)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToReinstall),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
debug('unlinkModpack: called')
|
||||
const previousData = addonsQuery.data.value
|
||||
if (previousData) {
|
||||
debug('unlinkModpack: optimistically removing modpack from cache')
|
||||
queryClient.setQueryData(['content', 'list', 'v1', serverId], {
|
||||
...previousData,
|
||||
modpack: null,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await client.archon.content_v1.unlinkModpack(serverId, worldId.value!)
|
||||
debug('unlinkModpack: API succeeded')
|
||||
} catch (err) {
|
||||
debug('unlinkModpack: failed, reverting cache', err)
|
||||
if (previousData) {
|
||||
queryClient.setQueryData(['content', 'list', 'v1', serverId], previousData)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToUnlink),
|
||||
})
|
||||
} finally {
|
||||
debug('unlinkModpack: invalidating queries')
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['servers', 'detail', serverId],
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['content', 'list', 'v1', serverId],
|
||||
}),
|
||||
])
|
||||
debug('unlinkModpack: invalidation complete')
|
||||
}
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => modpackVersionsQuery.data.value ?? null,
|
||||
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called, project:', modpackProjectId.value)
|
||||
if (!modpackProjectId.value) throw new Error('No modpack project ID')
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(
|
||||
modpackProjectId.value,
|
||||
{
|
||||
include_changelog: false,
|
||||
},
|
||||
)
|
||||
debug('fetchModpackVersions: got', versions.length, 'versions')
|
||||
return versions
|
||||
} catch (err) {
|
||||
debug('fetchModpackVersions: failed', err)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToLoadVersions),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async getVersionChangelog(versionId) {
|
||||
debug('getVersionChangelog: called, versionId:', versionId)
|
||||
try {
|
||||
return await client.labrinth.versions_v2.getVersion(versionId)
|
||||
} catch {
|
||||
debug('getVersionChangelog: failed for', versionId)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
if (!modpackProjectId.value) return
|
||||
debug('onModpackVersionConfirm: called, version:', version.id)
|
||||
debug('onModpackVersionConfirm: emitting reinstall before API call')
|
||||
emit('reinstall')
|
||||
try {
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: modpackProjectId.value,
|
||||
version_id: version.id,
|
||||
},
|
||||
soft_override: true,
|
||||
})
|
||||
debug('onModpackVersionConfirm: installContent succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
} catch (err) {
|
||||
debug('onModpackVersionConfirm: failed, emitting reinstall-failed', err)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToChangeVersion),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
isApp: serverSettings.isApp.value,
|
||||
currentVersionId:
|
||||
modpack.value?.spec.platform === 'modrinth' ? modpack.value.spec.version_id : '',
|
||||
projectIconUrl: modpack.value?.icon_url ?? undefined,
|
||||
projectName:
|
||||
modpack.value?.title ?? modpackProjectId.value ?? formatMessage(commonMessages.modpackLabel),
|
||||
currentGameVersion: addonsQuery.data.value?.game_version ?? server.value?.mc_version ?? '',
|
||||
currentLoader: addonsQuery.data.value?.modloader ?? server.value?.loader ?? '',
|
||||
})),
|
||||
|
||||
isServer: true,
|
||||
isApp: serverSettings.isApp.value,
|
||||
showModpackVersionActions: computed(() => modpack.value?.spec.platform === 'modrinth'),
|
||||
isLocalFile: computed(() => modpack.value?.spec.platform === 'local_file'),
|
||||
|
||||
lockPlatform: false,
|
||||
hideLoaderVersion: false,
|
||||
|
||||
async disableAllContent() {
|
||||
debug('disableAllContent: fetching all addons')
|
||||
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
|
||||
const items = (addons.addons ?? [])
|
||||
.filter((a) => !a.disabled)
|
||||
.map((a) => ({ kind: a.kind, filename: a.filename }))
|
||||
if (items.length > 0) {
|
||||
debug('disableAllContent: disabling', items.length, 'addons')
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, items)
|
||||
}
|
||||
debug('disableAllContent: done')
|
||||
},
|
||||
|
||||
async disableIncompatibleContent(targetGameVersion) {
|
||||
debug('disableIncompatibleContent: fetching addons')
|
||||
const addons = await client.archon.content_v1.getAddons(serverId, worldId.value!)
|
||||
const activeAddons = (addons.addons ?? []).filter((a) => !a.disabled)
|
||||
|
||||
const modrinthAddons = activeAddons.filter((a) => a.version?.id)
|
||||
const customAddons = activeAddons.filter((a) => !a.version?.id)
|
||||
|
||||
const incompatibleItems: { kind: (typeof activeAddons)[number]['kind']; filename: string }[] =
|
||||
customAddons.map((a) => ({ kind: a.kind, filename: a.filename }))
|
||||
|
||||
if (modrinthAddons.length > 0) {
|
||||
const versionIds = modrinthAddons.map((a) => a.version!.id)
|
||||
const versions = await client.labrinth.versions_v2.getVersions(versionIds)
|
||||
const incompatibleVersionIds = new Set(
|
||||
versions.filter((v) => !v.game_versions.includes(targetGameVersion)).map((v) => v.id),
|
||||
)
|
||||
for (const addon of modrinthAddons) {
|
||||
if (incompatibleVersionIds.has(addon.version!.id)) {
|
||||
incompatibleItems.push({ kind: addon.kind, filename: addon.filename })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (incompatibleItems.length > 0) {
|
||||
debug('disableIncompatibleContent: disabling', incompatibleItems.length, 'addons')
|
||||
await client.archon.content_v1.disableAddons(serverId, worldId.value!, incompatibleItems)
|
||||
}
|
||||
debug('disableIncompatibleContent: done')
|
||||
},
|
||||
|
||||
async saveWithoutAutoFix(platform, gameVersion, loaderVersionId) {
|
||||
debug('saveWithoutAutoFix: called with', { platform, gameVersion, loaderVersionId })
|
||||
let resolvedLoaderVersion = loaderVersionId
|
||||
if (!resolvedLoaderVersion && platform !== 'vanilla') {
|
||||
const versions = getLoaderVersionsForGameVersion(platform, gameVersion)
|
||||
resolvedLoaderVersion = versions[0]?.id ?? null
|
||||
}
|
||||
emit('reinstall', { loader: platform, lVersion: resolvedLoaderVersion, mVersion: gameVersion })
|
||||
try {
|
||||
const request: Archon.Content.v1.InstallWorldContent = {
|
||||
content_variant: 'bare',
|
||||
loader: toApiLoader(platform),
|
||||
version: resolvedLoaderVersion ?? '',
|
||||
game_version: gameVersion || undefined,
|
||||
soft_override: true,
|
||||
}
|
||||
debug('saveWithoutAutoFix: calling installContent', request)
|
||||
await client.archon.content_v1.installContent(serverId, worldId.value!, request)
|
||||
debug('saveWithoutAutoFix: succeeded, invalidating')
|
||||
invalidateServerState()
|
||||
} catch (err) {
|
||||
debug('saveWithoutAutoFix: failed', err)
|
||||
emit('reinstall-failed')
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToSaveSettings),
|
||||
})
|
||||
throw err
|
||||
}
|
||||
},
|
||||
|
||||
async previewSave(_platform, gameVersion, _loaderVersionId, signal) {
|
||||
const result = await client.archon.content_v1.getUpdateGameVersionPreview(
|
||||
serverId,
|
||||
worldId.value!,
|
||||
gameVersion,
|
||||
signal,
|
||||
)
|
||||
if (result.addon_changes.length === 0 && !result.has_unknown_content) return null
|
||||
return {
|
||||
diffs: result.addon_changes.map((diff) => ({
|
||||
type: diff.type,
|
||||
projectName: diff.project?.title ?? undefined,
|
||||
fileName: diff.file_name ?? undefined,
|
||||
currentVersionName: diff.current_version?.version_number ?? undefined,
|
||||
newVersionName: diff.new_version?.version_number ?? undefined,
|
||||
})),
|
||||
newGameVersion: result.new_game_version,
|
||||
newLoaderVersion: result.new_loader_version,
|
||||
hasUnknownContent: result.has_unknown_content,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
watch(
|
||||
() => server.value?.status,
|
||||
(newStatus, oldStatus) => {
|
||||
debug('status watcher:', oldStatus, '->', newStatus, {
|
||||
'server.loader': server.value?.loader,
|
||||
'server.mc_version': server.value?.mc_version,
|
||||
'server.loader_version': server.value?.loader_version,
|
||||
})
|
||||
if (oldStatus === 'installing' && newStatus === 'available') {
|
||||
debug('status installing->available, resetting editing refs')
|
||||
editingPlatform.value = server.value?.loader?.toLowerCase() ?? 'vanilla'
|
||||
editingGameVersion.value = server.value?.mc_version ?? ''
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function onReinstall(event?: unknown) {
|
||||
installationSettingsLayout.value?.cancelEditing()
|
||||
modrinthServersConsole.clear()
|
||||
queryClient.removeQueries({ queryKey: ['servers', 'ws-state', serverId] })
|
||||
emit('reinstall', event)
|
||||
serverSettings.closeModal?.()
|
||||
}
|
||||
|
||||
function onBrowseModpacks() {
|
||||
debug('onBrowseModpacks: navigating to modpack discovery')
|
||||
serverSettings.browseModpacks({
|
||||
serverId,
|
||||
worldId: worldId.value,
|
||||
from: 'reset-server',
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmResetToOnboarding() {
|
||||
if (!worldId.value) return
|
||||
|
||||
try {
|
||||
isResettingToOnboarding.value = true
|
||||
await client.archon.servers_v1.resetToOnboarding(serverId, worldId.value)
|
||||
server.value.flows = { intro: true }
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'v1', 'detail', serverId] }),
|
||||
])
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(messages.resetToOnboardingSuccessTitle),
|
||||
text: formatMessage(messages.resetToOnboardingSuccessDescription),
|
||||
})
|
||||
serverSettings.closeModal?.()
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.failedToResetToOnboarding),
|
||||
})
|
||||
} finally {
|
||||
isResettingToOnboarding.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
411
packages/ui/src/layouts/shared/server-settings/pages/network.vue
Normal file
411
packages/ui/src/layouts/shared/server-settings/pages/network.vue
Normal file
@@ -0,0 +1,411 @@
|
||||
<template>
|
||||
<div>
|
||||
<Teleport to="body">
|
||||
<div class="relative z-[100]">
|
||||
<NewModal ref="editAllocationModal" header="Edit allocation" width="550px">
|
||||
<form class="flex w-full flex-col gap-2" @submit.prevent="editAllocation">
|
||||
<label for="edit-allocation-name" class="font-semibold text-contrast"> Name </label>
|
||||
<StyledInput
|
||||
id="edit-allocation-name"
|
||||
ref="editAllocationInput"
|
||||
v-model="editAllocationName"
|
||||
wrapper-class="w-full"
|
||||
:maxlength="32"
|
||||
placeholder="e.g. Secondary allocation"
|
||||
/>
|
||||
<div class="mb-1 mt-4 flex justify-end gap-2.5">
|
||||
<ButtonStyled>
|
||||
<button @click="editAllocationModal?.hide()">Cancel</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!editAllocationName || creatingAllocation" type="submit">
|
||||
<SaveIcon /> Update allocation
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
|
||||
<ConfirmModal
|
||||
ref="confirmDeleteModal"
|
||||
title="Deleting allocation"
|
||||
:description="`You are deleting the allocation ${allocationToDelete}. This cannot be reserved again. Are you sure you want to proceed?`"
|
||||
proceed-label="Delete"
|
||||
@proceed="confirmDeleteAllocation"
|
||||
/>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div class="relative w-full">
|
||||
<div
|
||||
v-if="allocationsError"
|
||||
class="flex w-full flex-col items-center justify-center gap-4 p-4"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
<div class="flex flex-col items-center text-center">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
|
||||
<IssuesIcon class="size-12 text-orange" />
|
||||
</div>
|
||||
<h1 class="m-0 mb-2 w-fit text-4xl font-semibold">Failed to load network settings</h1>
|
||||
</div>
|
||||
<p class="text-md text-secondary">
|
||||
We couldn't load your server's network settings. Here's what we know:
|
||||
<span class="break-all font-mono">{{
|
||||
allocationsError?.message ?? 'Unknown error'
|
||||
}}</span>
|
||||
</p>
|
||||
<ButtonStyled size="large" color="brand" @click="() => refetchAllocations()">
|
||||
<button class="mt-6 !w-full">Retry</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="data" class="flex h-full w-full flex-col justify-between gap-4">
|
||||
<div class="flex h-full flex-col gap-6">
|
||||
<!-- Allocations section -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">Allocations</span>
|
||||
|
||||
<div class="flex w-full flex-col items-center justify-start gap-2 sm:flex-row">
|
||||
<StyledInput
|
||||
v-model="createAllocationName"
|
||||
wrapper-class="grow max-w-[400px]"
|
||||
:maxlength="32"
|
||||
placeholder="e.g. Secondary allocation"
|
||||
/>
|
||||
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="!createAllocationName ? 'Enter a name to create an allocation' : ''"
|
||||
:disabled="!createAllocationName || creatingAllocation"
|
||||
@click="addNewAllocation"
|
||||
>
|
||||
<PlusIcon />
|
||||
<span>Create allocation</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<Table :columns="allocationColumns" :data="allocationRows" row-key="port">
|
||||
<template #cell-name="{ row }">
|
||||
<TagItem v-if="row.primary" class="!font-medium">Primary</TagItem>
|
||||
<span v-else class="font-semibold">{{ row.name }}</span>
|
||||
</template>
|
||||
<template #cell-port="{ row }">
|
||||
<span class="font-medium">{{ row.port }}</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<ButtonStyled icon-only type="transparent" circular>
|
||||
<button @click="copyText(`${serverIP}:${row.port}`)">
|
||||
<CopyIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template v-if="!row.primary">
|
||||
<ButtonStyled icon-only type="transparent" circular>
|
||||
<button @click="showEditAllocationModal(row.port)">
|
||||
<PencilIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled icon-only type="outlined" circular color="red">
|
||||
<button @click="showConfirmDeleteModal(row.port)">
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
<span>
|
||||
Create additional ports for internet-facing features like map viewers or voice chat
|
||||
mods.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- DNS records section -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label for="user-domain" class="flex flex-col gap-2">
|
||||
<span class="text-lg font-semibold text-contrast">DNS records</span>
|
||||
</label>
|
||||
<div class="flex w-full flex-col items-center justify-start gap-2 sm:flex-row">
|
||||
<StyledInput
|
||||
id="user-domain"
|
||||
v-model="userDomain"
|
||||
wrapper-class="grow max-w-[400px]"
|
||||
:maxlength="64"
|
||||
:placeholder="exampleDomain"
|
||||
/>
|
||||
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!w-full sm:!w-auto"
|
||||
:disabled="userDomain == ''"
|
||||
@click="exportDnsRecords"
|
||||
>
|
||||
<UploadIcon />
|
||||
<span>Export</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<Table :columns="dnsColumns" :data="dnsRecords">
|
||||
<template #cell-type="{ row }">
|
||||
<TagItem
|
||||
v-if="row.type === 'SRV'"
|
||||
class="border !border-solid border-purple bg-highlight-purple !font-medium"
|
||||
:style="`--_color: var(--color-purple)`"
|
||||
>
|
||||
{{ row.type }}
|
||||
</TagItem>
|
||||
<TagItem
|
||||
v-else
|
||||
class="border !border-solid border-blue bg-highlight-blue !font-medium"
|
||||
:style="`--_color: var(--color-blue)`"
|
||||
>
|
||||
{{ row.type }}
|
||||
</TagItem>
|
||||
</template>
|
||||
<template #cell-name="{ row }">
|
||||
<span
|
||||
class="block cursor-pointer truncate pr-8 font-semibold"
|
||||
@click="copyText(row.name)"
|
||||
>
|
||||
{{ row.name }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-content="{ row }">
|
||||
<span
|
||||
class="block cursor-pointer truncate pr-8 font-semibold"
|
||||
@click="copyText(row.content)"
|
||||
>
|
||||
{{ row.content }}
|
||||
</span>
|
||||
</template>
|
||||
</Table>
|
||||
|
||||
<span>
|
||||
Set up your personal domain to connect to your server via custom DNS records.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CopyIcon,
|
||||
IssuesIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
SaveIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import { ButtonStyled, ConfirmModal, NewModal, StyledInput, Table, TagItem } from '#ui/components'
|
||||
import type { TableColumn } from '#ui/components/base'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { server, serverId } = injectModrinthServerContext()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const data = server
|
||||
|
||||
const serverIP = ref(data?.value?.net?.ip ?? '')
|
||||
const serverPrimaryPort = ref(data?.value?.net?.port ?? 0)
|
||||
const userDomain = ref('')
|
||||
const exampleDomain = 'play.example.com'
|
||||
|
||||
const {
|
||||
data: allocationsData,
|
||||
error: allocationsError,
|
||||
refetch: refetchAllocations,
|
||||
} = useQuery({
|
||||
queryKey: ['servers', 'allocations', serverId] as const,
|
||||
queryFn: () => client.archon.servers_v0.getAllocations(serverId),
|
||||
})
|
||||
const allocations = allocationsData
|
||||
|
||||
const allocationColumns: TableColumn[] = [
|
||||
{ key: 'name', label: 'Name', width: '40%' },
|
||||
{ key: 'port', label: 'Port' },
|
||||
{ key: 'actions', label: 'Actions', width: '33%', align: 'right' },
|
||||
]
|
||||
|
||||
const allocationRows = computed(() => {
|
||||
const primary = {
|
||||
name: 'Primary allocation',
|
||||
port: serverPrimaryPort.value,
|
||||
primary: true,
|
||||
}
|
||||
const extra = (allocations.value ?? []).map((a) => ({
|
||||
name: a.name,
|
||||
port: a.port,
|
||||
primary: false,
|
||||
}))
|
||||
return [primary, ...extra]
|
||||
})
|
||||
|
||||
const dnsColumns: TableColumn[] = [
|
||||
{ key: 'type', label: 'Type', width: '20%' },
|
||||
{ key: 'name', label: 'Name', width: '35%' },
|
||||
{ key: 'content', label: 'Content' },
|
||||
]
|
||||
|
||||
const editAllocationModal = ref<typeof NewModal>()
|
||||
const confirmDeleteModal = ref<typeof ConfirmModal>()
|
||||
const editAllocationInput = ref<HTMLInputElement | null>(null)
|
||||
const createAllocationName = ref('')
|
||||
const editAllocationName = ref('')
|
||||
const newAllocationPort = ref(0)
|
||||
const allocationToDelete = ref<number | null>(null)
|
||||
const creatingAllocation = ref(false)
|
||||
|
||||
const addNewAllocation = async () => {
|
||||
if (!createAllocationName.value) return
|
||||
creatingAllocation.value = true
|
||||
|
||||
try {
|
||||
await client.archon.servers_v0.reserveAllocation(serverId, createAllocationName.value)
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
||||
|
||||
createAllocationName.value = ''
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Allocation reserved',
|
||||
text: 'Your allocation has been reserved.',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to reserve new allocation:', error)
|
||||
} finally {
|
||||
creatingAllocation.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const showEditAllocationModal = (port: number) => {
|
||||
newAllocationPort.value = port
|
||||
editAllocationName.value = allocations.value?.find((a) => a.port === port)?.name ?? ''
|
||||
editAllocationModal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
editAllocationInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const showConfirmDeleteModal = (port: number) => {
|
||||
allocationToDelete.value = port
|
||||
confirmDeleteModal.value?.show()
|
||||
}
|
||||
|
||||
const confirmDeleteAllocation = async () => {
|
||||
if (allocationToDelete.value === null) return
|
||||
|
||||
await client.archon.servers_v0.deleteAllocation(serverId, allocationToDelete.value)
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Allocation removed',
|
||||
text: 'Your allocation has been removed.',
|
||||
})
|
||||
|
||||
allocationToDelete.value = null
|
||||
}
|
||||
|
||||
const editAllocation = async () => {
|
||||
if (!editAllocationName.value) return
|
||||
creatingAllocation.value = true
|
||||
|
||||
try {
|
||||
await client.archon.servers_v0.updateAllocation(
|
||||
serverId,
|
||||
newAllocationPort.value,
|
||||
editAllocationName.value,
|
||||
)
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'allocations', serverId] })
|
||||
|
||||
editAllocationModal.value?.hide()
|
||||
editAllocationName.value = ''
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Allocation updated',
|
||||
text: 'Your allocation has been updated.',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to reserve new allocation:', error)
|
||||
} finally {
|
||||
creatingAllocation.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const dnsRecords = computed(() => {
|
||||
const domain = userDomain.value === '' ? exampleDomain : userDomain.value
|
||||
return [
|
||||
{
|
||||
type: 'A',
|
||||
name: `${domain}`,
|
||||
content: data.value?.net?.ip ?? '',
|
||||
},
|
||||
{
|
||||
type: 'SRV',
|
||||
name: `_minecraft._tcp.${domain}`,
|
||||
content: `0 10 ${data.value?.net?.port} ${domain}`,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
type DnsRecord = {
|
||||
type: string
|
||||
name: string
|
||||
content: string
|
||||
}
|
||||
|
||||
const exportDnsRecords = () => {
|
||||
const records = dnsRecords.value.reduce(
|
||||
(acc, record) => {
|
||||
const type = record.type
|
||||
if (!acc[type]) {
|
||||
acc[type] = []
|
||||
}
|
||||
acc[type].push(record)
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, DnsRecord[]>,
|
||||
)
|
||||
|
||||
const text = Object.entries(records)
|
||||
.map(([type, records]) => {
|
||||
return `; ${type} Records\n${records.map((record) => `${record.name}.\t1\tIN\t${record.type} ${record.content}${record.type === 'SRV' ? '.' : ''}`).join('\n')}\n`
|
||||
})
|
||||
.join('\n')
|
||||
const blob = new Blob([text], { type: 'text/plain' })
|
||||
const a = document.createElement('a')
|
||||
a.href = window.URL.createObjectURL(blob)
|
||||
a.download = `${userDomain.value}.txt`
|
||||
a.click()
|
||||
a.remove()
|
||||
}
|
||||
|
||||
const copyText = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Text copied',
|
||||
text: `${text} has been copied to your clipboard`,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,560 @@
|
||||
<template>
|
||||
<div class="relative h-screen w-full select-none max-h-[min(70vh,750px)]">
|
||||
<div v-if="propsData" class="flex h-full w-full flex-col justify-between gap-4">
|
||||
<Admonition
|
||||
v-if="hasNoProperties"
|
||||
type="warning"
|
||||
body="Some expected properties are missing from your server.properties - this usually means the server hasn't completed its first startup yet."
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="m-0">
|
||||
Edit the Minecraft server properties file here, or use the
|
||||
<AutoLink
|
||||
class="goto-link !inline-block"
|
||||
:to="filesTabLink"
|
||||
@click="onFilesTabLinkClick"
|
||||
>
|
||||
Files tab
|
||||
</AutoLink>
|
||||
to edit the full file. If you're unsure about a setting, the
|
||||
<AutoLink
|
||||
class="goto-link !inline-block"
|
||||
to="https://minecraft.wiki/w/Server.properties"
|
||||
target="_blank"
|
||||
>
|
||||
Minecraft Wiki
|
||||
</AutoLink>
|
||||
has more details.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full text-sm">
|
||||
<label for="search-server-properties" class="sr-only"> Search server properties </label>
|
||||
<StyledInput
|
||||
id="search-server-properties"
|
||||
v-model="searchInput"
|
||||
wrapper-class="w-full"
|
||||
type="search"
|
||||
:icon="SearchIcon"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
placeholder="Search server properties..."
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 pb-2">
|
||||
<div class="flex flex-col gap-6">
|
||||
<!-- Basic Properties -->
|
||||
<!-- [&:not(:has(*:not(:empty)))]:hidden is to hide parent if all children are empty -->
|
||||
<div
|
||||
class="rounded-2xl border border-solid border-surface-5 p-4 pb-2 [&:not(:has(*:not(:empty)))]:hidden"
|
||||
>
|
||||
<div class="flex w-full flex-col gap-1.5">
|
||||
<div v-if="isPropertyVisible('gamemode')" class="flex flex-col gap-2.5 my-1">
|
||||
<span class="font-semibold text-contrast">Gamemode</span>
|
||||
<Chips
|
||||
v-model="combinedGamemode"
|
||||
:items="gamemodeItems"
|
||||
:format-label="capitalize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="combinedGamemode !== 'hardcore' && isPropertyVisible('difficulty')"
|
||||
class="flex flex-col gap-2.5 my-1"
|
||||
>
|
||||
<span class="font-semibold text-contrast">Difficulty</span>
|
||||
<Chips
|
||||
v-model="selectedDifficulty"
|
||||
:items="difficultyItems"
|
||||
:format-label="capitalize"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isPropertyVisible('max_players')" class="flex flex-col gap-2.5 my-1">
|
||||
<span class="font-semibold text-contrast">Max players</span>
|
||||
<StyledInput
|
||||
id="server-property-max-players"
|
||||
:model-value="liveProperties.max_players"
|
||||
type="number"
|
||||
placeholder="20"
|
||||
wrapper-class="w-full max-w-[450px]"
|
||||
@update:model-value="liveProperties.max_players = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="isPropertyVisible('motd')" class="flex flex-col gap-2.5 my-1">
|
||||
<span class="font-semibold text-contrast">MOTD</span>
|
||||
<StyledInput
|
||||
id="server-property-motd"
|
||||
v-model="liveProperties.motd"
|
||||
placeholder="A Minecraft Server"
|
||||
wrapper-class="w-full max-w-[450px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isPropertyVisible('allow_flight')"
|
||||
class="flex flex-row items-center justify-between gap-4 h-10"
|
||||
>
|
||||
<span class="font-semibold text-contrast">Allow flight</span>
|
||||
<Toggle
|
||||
id="server-property-allow-flight"
|
||||
:model-value="liveProperties.allow_flight === 'true'"
|
||||
@update:model-value="liveProperties.allow_flight = $event ? 'true' : 'false'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isPropertyVisible('allow_cheats')"
|
||||
class="flex flex-row items-center justify-between gap-4 h-10"
|
||||
>
|
||||
<span class="font-semibold text-contrast">Allow cheats</span>
|
||||
<Toggle
|
||||
id="server-property-allow-cheats"
|
||||
:model-value="liveProperties.allow_cheats === 'true'"
|
||||
@update:model-value="liveProperties.allow_cheats = $event ? 'true' : 'false'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isPropertyVisible('white_list')"
|
||||
class="flex flex-row items-center justify-between gap-4 h-10"
|
||||
>
|
||||
<span class="font-semibold text-contrast">Enable whitelist</span>
|
||||
<Toggle id="server-property-whitelist" v-model="whitelistEnabled" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isPropertyVisible('spawn_protection')"
|
||||
class="flex flex-row items-center justify-between gap-4 h-10"
|
||||
>
|
||||
<span class="font-semibold text-contrast">Enable spawn protection</span>
|
||||
<Toggle
|
||||
id="server-property-spawn-protection-toggle"
|
||||
v-model="spawnProtectionEnabled"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="spawnProtectionEnabled && isPropertyVisible('spawn_protection')"
|
||||
class="flex items-center justify-between h-10"
|
||||
>
|
||||
<span class="font-semibold text-contrast">Protection radius</span>
|
||||
<StyledInput
|
||||
id="server-property-spawn-protection-radius"
|
||||
:model-value="liveProperties.spawn_protection"
|
||||
type="number"
|
||||
wrapper-class="w-full sm:w-[100px]"
|
||||
input-class="text-right"
|
||||
@update:model-value="liveProperties.spawn_protection = String($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Advanced Properties -->
|
||||
<Accordion
|
||||
v-if="hasVisibleAdvancedProperties"
|
||||
overflow-visible
|
||||
:force-open="isSearchActive"
|
||||
button-class="flex w-full flex-col gap-2 bg-transparent m-0 p-0 border-none"
|
||||
>
|
||||
<template #title>
|
||||
<span class="text-lg font-semibold text-contrast">Advanced properties</span>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-6 pt-4">
|
||||
<template v-for="group in advancedGroupedProperties" :key="group.label">
|
||||
<div v-if="hasVisibleProperties(group)" class="flex flex-col gap-2.5">
|
||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||
{{ group.label }}
|
||||
</h3>
|
||||
<div
|
||||
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 p-4"
|
||||
>
|
||||
<template v-for="key in group.properties" :key="key">
|
||||
<div
|
||||
v-if="isPropertyVisible(key)"
|
||||
class="flex flex-row flex-wrap items-center justify-between h-10"
|
||||
>
|
||||
<span :id="`property-label-${key}`" class="font-semibold text-contrast">
|
||||
{{ formatPropertyName(key) }}
|
||||
</span>
|
||||
|
||||
<div
|
||||
v-if="getPropertyDef(key).type === 'toggle'"
|
||||
class="flex w-full justify-end sm:w-[320px]"
|
||||
>
|
||||
<Toggle
|
||||
:id="`server-property-${key}`"
|
||||
:model-value="liveProperties[key] === 'true'"
|
||||
:aria-labelledby="`property-label-${key}`"
|
||||
@update:model-value="liveProperties[key] = $event ? 'true' : 'false'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="getPropertyDef(key).type === 'number'"
|
||||
class="w-full sm:w-[320px]"
|
||||
>
|
||||
<StyledInput
|
||||
:id="`server-property-${key}`"
|
||||
:model-value="liveProperties[key]"
|
||||
type="number"
|
||||
placeholder="Type here..."
|
||||
wrapper-class="w-full"
|
||||
:aria-labelledby="`property-label-${key}`"
|
||||
@update:model-value="liveProperties[key] = String($event)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex w-full justify-end sm:w-[320px]">
|
||||
<StyledInput
|
||||
:id="`server-property-${key}`"
|
||||
v-model="liveProperties[key]"
|
||||
placeholder="Type here..."
|
||||
wrapper-class="w-full"
|
||||
:aria-labelledby="`property-label-${key}`"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div>
|
||||
All other properties can be edited in server.properties via the
|
||||
<AutoLink
|
||||
class="goto-link !inline-block"
|
||||
:to="filesTabLink"
|
||||
@click="onFilesTabLinkClick"
|
||||
>
|
||||
Files tab </AutoLink
|
||||
>.
|
||||
</div>
|
||||
</div>
|
||||
</Accordion>
|
||||
|
||||
<div
|
||||
v-if="hasNoResults"
|
||||
class="flex flex-col items-center gap-2 py-8 text-center text-secondary"
|
||||
>
|
||||
<SearchIcon class="size-10" />
|
||||
<span class="text-lg font-semibold text-contrast">No properties found</span>
|
||||
<span>No properties match "{{ searchInput }}".</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex h-full w-full items-center justify-center">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
</div>
|
||||
|
||||
<SaveBanner
|
||||
:is-visible="hasUnsavedChanges || isUpdating"
|
||||
:server-id="serverId"
|
||||
:is-updating="isUpdating || busyReasons.length > 0"
|
||||
restart
|
||||
:save="
|
||||
async () => {
|
||||
await saveProperties()
|
||||
}
|
||||
"
|
||||
:reset="resetProperties"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { SearchIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { Accordion, Admonition, AutoLink, Chips, StyledInput, Toggle } from '#ui/components'
|
||||
import SaveBanner from '#ui/components/servers/SaveBanner.vue'
|
||||
import { injectServerSettings } from '#ui/layouts/shared/server-settings'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
} from '#ui/providers'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
const filesTabLink = computed(
|
||||
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`,
|
||||
)
|
||||
const serverSettings = injectServerSettings(null)
|
||||
|
||||
const searchInput = ref('')
|
||||
|
||||
function onFilesTabLinkClick() {
|
||||
serverSettings?.closeModal?.()
|
||||
}
|
||||
|
||||
type PropertyDef = { type: 'toggle' } | { type: 'number' } | { type: 'text' }
|
||||
|
||||
const KNOWN_PROPERTIES: Record<string, PropertyDef> = {
|
||||
allow_cheats: { type: 'toggle' },
|
||||
allow_flight: { type: 'toggle' },
|
||||
difficulty: { type: 'text' },
|
||||
enforce_whitelist: { type: 'toggle' },
|
||||
force_gamemode: { type: 'toggle' },
|
||||
gamemode: { type: 'text' },
|
||||
generate_structures: { type: 'toggle' },
|
||||
generator_settings: { type: 'text' },
|
||||
hardcore: { type: 'toggle' },
|
||||
level_seed: { type: 'text' },
|
||||
level_type: { type: 'text' },
|
||||
max_players: { type: 'number' },
|
||||
max_tick_time: { type: 'number' },
|
||||
motd: { type: 'text' },
|
||||
pause_when_empty_seconds: { type: 'number' },
|
||||
player_idle_timeout: { type: 'number' },
|
||||
require_resource_pack: { type: 'toggle' },
|
||||
resource_pack: { type: 'text' },
|
||||
resource_pack_id: { type: 'text' },
|
||||
resource_pack_sha1: { type: 'text' },
|
||||
simulation_distance: { type: 'number' },
|
||||
spawn_protection: { type: 'number' },
|
||||
sync_chunk_writes: { type: 'toggle' },
|
||||
view_distance: { type: 'number' },
|
||||
white_list: { type: 'toggle' },
|
||||
}
|
||||
|
||||
function getPropertyDef(key: string): PropertyDef {
|
||||
return KNOWN_PROPERTIES[key] ?? { type: 'text' }
|
||||
}
|
||||
|
||||
const ADVANCED_GROUPS = [
|
||||
{
|
||||
label: 'Performance',
|
||||
keys: [
|
||||
'view_distance',
|
||||
'simulation_distance',
|
||||
'sync_chunk_writes',
|
||||
'max_tick_time',
|
||||
'player_idle_timeout',
|
||||
'pause_when_empty_seconds',
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Resource Pack',
|
||||
keys: ['resource_pack', 'resource_pack_id', 'resource_pack_sha1', 'require_resource_pack'],
|
||||
},
|
||||
]
|
||||
|
||||
type CombinedGamemode = 'survival' | 'creative' | 'hardcore'
|
||||
const gamemodeItems: CombinedGamemode[] = ['survival', 'creative', 'hardcore']
|
||||
const difficultyItems = ['peaceful', 'easy', 'normal', 'hard']
|
||||
|
||||
function capitalize(str: string): string {
|
||||
return str.charAt(0).toUpperCase() + str.slice(1)
|
||||
}
|
||||
|
||||
const queryKey = computed(() => ['servers', 'properties', 'v1', serverId, worldId.value])
|
||||
|
||||
const { data: propsData } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => client.archon.properties_v1.getProperties(serverId, worldId.value!),
|
||||
enabled: computed(() => worldId.value !== null),
|
||||
})
|
||||
|
||||
function flattenProperties(data: Archon.Content.v1.PropertiesFields): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
if (data.known) {
|
||||
for (const [key, value] of Object.entries(data.known)) {
|
||||
if (value != null) result[key] = value
|
||||
}
|
||||
}
|
||||
if (data.custom) {
|
||||
for (const [key, value] of Object.entries(data.custom)) {
|
||||
if (value != null) result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const liveProperties = ref<Record<string, string>>({})
|
||||
const originalProperties = ref<Record<string, string>>({})
|
||||
let previousSpawnProtection = '16'
|
||||
|
||||
function syncFormFromData() {
|
||||
if (!propsData.value) return
|
||||
const flat = flattenProperties(propsData.value)
|
||||
liveProperties.value = { ...flat }
|
||||
originalProperties.value = { ...flat }
|
||||
const sp = flat.spawn_protection
|
||||
if (sp && sp !== '0') {
|
||||
previousSpawnProtection = sp
|
||||
}
|
||||
}
|
||||
|
||||
const hasNoProperties = computed(() => Object.keys(liveProperties.value).length === 0)
|
||||
|
||||
const hasUnsavedChanges = computed(() =>
|
||||
Object.keys(liveProperties.value).some(
|
||||
(key) => liveProperties.value[key] !== originalProperties.value[key],
|
||||
),
|
||||
)
|
||||
|
||||
watch(
|
||||
propsData,
|
||||
(newData) => {
|
||||
if (newData && !hasUnsavedChanges.value) {
|
||||
syncFormFromData()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(powerState, () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
})
|
||||
|
||||
const combinedGamemode = computed<CombinedGamemode>({
|
||||
get() {
|
||||
if (liveProperties.value.hardcore === 'true') return 'hardcore'
|
||||
if (liveProperties.value.gamemode === 'creative') return 'creative'
|
||||
return 'survival'
|
||||
},
|
||||
set(value) {
|
||||
if (value === 'hardcore') {
|
||||
liveProperties.value.gamemode = 'survival'
|
||||
liveProperties.value.hardcore = 'true'
|
||||
liveProperties.value.difficulty = 'hard'
|
||||
} else {
|
||||
liveProperties.value.gamemode = value
|
||||
liveProperties.value.hardcore = 'false'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const selectedDifficulty = computed({
|
||||
get: () => liveProperties.value.difficulty ?? 'normal',
|
||||
set: (v: string) => {
|
||||
liveProperties.value.difficulty = v
|
||||
},
|
||||
})
|
||||
|
||||
const whitelistEnabled = computed({
|
||||
get: () => liveProperties.value.white_list === 'true',
|
||||
set: (v: boolean) => {
|
||||
liveProperties.value.white_list = v ? 'true' : 'false'
|
||||
liveProperties.value.enforce_whitelist = v ? 'true' : 'false'
|
||||
},
|
||||
})
|
||||
|
||||
const spawnProtectionEnabled = computed({
|
||||
get: () => {
|
||||
const val = liveProperties.value.spawn_protection
|
||||
return val !== undefined && val !== '0'
|
||||
},
|
||||
set: (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
liveProperties.value.spawn_protection = previousSpawnProtection || '16'
|
||||
} else {
|
||||
previousSpawnProtection = liveProperties.value.spawn_protection || '16'
|
||||
liveProperties.value.spawn_protection = '0'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function buildPatch(): Archon.Content.v1.PatchPropertiesFields {
|
||||
const known: Record<string, string> = {}
|
||||
const custom: Record<string, string> = {}
|
||||
|
||||
for (const key of Object.keys(liveProperties.value)) {
|
||||
if (liveProperties.value[key] === originalProperties.value[key]) continue
|
||||
if (key in KNOWN_PROPERTIES) {
|
||||
known[key] = liveProperties.value[key]
|
||||
} else {
|
||||
custom[key] = liveProperties.value[key]
|
||||
}
|
||||
}
|
||||
|
||||
const patch: Archon.Content.v1.PatchPropertiesFields = {}
|
||||
if (Object.keys(known).length > 0) {
|
||||
patch.known = known as Archon.Content.v1.KnownPropertiesFields
|
||||
}
|
||||
if (Object.keys(custom).length > 0) {
|
||||
patch.custom = custom
|
||||
}
|
||||
return patch
|
||||
}
|
||||
|
||||
const { mutateAsync: saveProperties, isPending: isUpdating } = useMutation({
|
||||
mutationFn: () =>
|
||||
client.archon.properties_v1.patchProperties(serverId, worldId.value!, buildPatch()),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKey.value })
|
||||
syncFormFromData()
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: 'Server properties updated',
|
||||
text: 'Your server properties were successfully changed.',
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: 'Failed to update server properties',
|
||||
text: error instanceof Error ? error.message : 'An error occurred.',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function resetProperties() {
|
||||
syncFormFromData()
|
||||
}
|
||||
|
||||
const advancedGroupedProperties = computed(() =>
|
||||
ADVANCED_GROUPS.map((group) => ({
|
||||
label: group.label,
|
||||
properties: group.keys.filter((key) => key in liveProperties.value),
|
||||
})).filter((g) => g.properties.length > 0),
|
||||
)
|
||||
|
||||
const fuse = computed(() => {
|
||||
const entries = Object.entries(liveProperties.value).map(([key, value]) => ({
|
||||
key,
|
||||
value: String(value),
|
||||
}))
|
||||
return new Fuse(entries, { keys: ['key', 'value'], threshold: 0.2 })
|
||||
})
|
||||
|
||||
const filteredProperties = computed(() => {
|
||||
if (!searchInput.value?.trim()) return liveProperties.value
|
||||
const results = fuse.value.search(searchInput.value)
|
||||
return Object.fromEntries(results.map(({ item }) => [item.key, liveProperties.value[item.key]]))
|
||||
})
|
||||
|
||||
const isSearchActive = computed(() => !!searchInput.value?.trim())
|
||||
const hasNoResults = computed(
|
||||
() => isSearchActive.value && Object.keys(filteredProperties.value).length === 0,
|
||||
)
|
||||
|
||||
function isPropertyVisible(key: string): boolean {
|
||||
if (!isSearchActive.value) return true
|
||||
return key in filteredProperties.value
|
||||
}
|
||||
|
||||
function hasVisibleProperties(group: { properties: string[] }): boolean {
|
||||
return group.properties.some((key) => isPropertyVisible(key))
|
||||
}
|
||||
|
||||
const hasVisibleAdvancedProperties = computed(() =>
|
||||
advancedGroupedProperties.value.some((group) => hasVisibleProperties(group)),
|
||||
)
|
||||
|
||||
function formatPropertyName(name: string): string {
|
||||
return name
|
||||
.split('_')
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1 @@
|
||||
export * from './server-settings'
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
export interface ServerSettingsBrowseModpacksArgs {
|
||||
serverId: string
|
||||
worldId: string | null
|
||||
from: 'reset-server'
|
||||
}
|
||||
|
||||
export interface ServerSettingsContext {
|
||||
isApp: Ref<boolean>
|
||||
currentUserId: Ref<string | null>
|
||||
currentUserRole: Ref<string | null>
|
||||
browseModpacks: (args: ServerSettingsBrowseModpacksArgs) => void | Promise<void>
|
||||
closeModal?: () => void
|
||||
}
|
||||
|
||||
export const [injectServerSettings, provideServerSettings] = createContext<ServerSettingsContext>(
|
||||
'ServerSettings',
|
||||
'serverSettingsContext',
|
||||
)
|
||||
82
packages/ui/src/layouts/shared/server-settings/tabs.ts
Normal file
82
packages/ui/src/layouts/shared/server-settings/tabs.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import {
|
||||
CardIcon,
|
||||
ListIcon,
|
||||
ModrinthIcon,
|
||||
SettingsIcon,
|
||||
TextQuoteIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
export type ServerSettingsTabId =
|
||||
| 'general'
|
||||
| 'installation'
|
||||
| 'network'
|
||||
| 'properties'
|
||||
| 'advanced'
|
||||
| 'billing'
|
||||
| 'admin-billing'
|
||||
|
||||
export interface ServerSettingsTabContext {
|
||||
serverId: string
|
||||
ownerId: string
|
||||
serverStatus?: Archon.Servers.v0.Status | null
|
||||
isOwner: boolean
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface ServerSettingsTabDefinition {
|
||||
id: ServerSettingsTabId
|
||||
label: string
|
||||
icon: Component
|
||||
href?: (ctx: ServerSettingsTabContext) => string
|
||||
external?: boolean
|
||||
shown?: (ctx: ServerSettingsTabContext) => boolean
|
||||
}
|
||||
|
||||
export const serverSettingsTabDefinitions: ServerSettingsTabDefinition[] = [
|
||||
{
|
||||
id: 'general',
|
||||
label: 'General',
|
||||
icon: SettingsIcon,
|
||||
},
|
||||
{
|
||||
id: 'installation',
|
||||
label: 'Installation',
|
||||
icon: WrenchIcon,
|
||||
},
|
||||
{
|
||||
id: 'network',
|
||||
label: 'Network',
|
||||
icon: VersionIcon,
|
||||
},
|
||||
{
|
||||
id: 'properties',
|
||||
label: 'Properties',
|
||||
icon: ListIcon,
|
||||
shown: ({ serverStatus }) => serverStatus !== 'installing',
|
||||
},
|
||||
{
|
||||
id: 'advanced',
|
||||
label: 'Advanced',
|
||||
icon: TextQuoteIcon,
|
||||
},
|
||||
{
|
||||
id: 'billing',
|
||||
label: 'Billing',
|
||||
icon: CardIcon,
|
||||
href: ({ serverId }) => `/settings/billing#server-${serverId}`,
|
||||
external: true,
|
||||
shown: ({ isOwner }) => isOwner,
|
||||
},
|
||||
{
|
||||
id: 'admin-billing',
|
||||
label: 'Admin Billing',
|
||||
icon: ModrinthIcon,
|
||||
href: ({ ownerId }) => `/admin/billing/${ownerId}`,
|
||||
external: true,
|
||||
shown: ({ isAdmin }) => isAdmin,
|
||||
},
|
||||
]
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mx-auto flex w-fit flex-col items-start gap-4 mt-6 max-w-[500px]">
|
||||
<div class="mx-auto flex w-fit flex-col items-start gap-4 mt-16 max-w-[500px]">
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<h2 class="m-0 text-2xl font-semibold text-contrast">Welcome to Modrinth</h2>
|
||||
<h2 class="m-0 text-2xl font-semibold text-contrast">Welcome to Modrinth Hosting</h2>
|
||||
<p class="m-0 text-base text-secondary">
|
||||
Your server is ready. Here's what you need to do to start playing!
|
||||
</p>
|
||||
@@ -95,6 +95,19 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
browseModpacks?: (args: {
|
||||
serverId: string
|
||||
worldId: string | null
|
||||
from: 'onboarding'
|
||||
}) => void | Promise<void>
|
||||
}>(),
|
||||
{
|
||||
browseModpacks: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const modalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
|
||||
|
||||
const uploading = ref(false)
|
||||
@@ -109,6 +122,15 @@ const openModal = () => modalRef.value?.show()
|
||||
onBeforeUnmount(() => modalRef.value?.hide())
|
||||
|
||||
function onBrowseModpacks() {
|
||||
if (props.browseModpacks) {
|
||||
props.browseModpacks({
|
||||
serverId,
|
||||
worldId: worldId.value,
|
||||
from: 'onboarding',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: '/discover/modpacks',
|
||||
query: { sid: serverId, from: 'onboarding', wid: worldId.value },
|
||||
@@ -152,7 +174,7 @@ async function finalizeSetup() {
|
||||
client.archon.servers_v1.endIntro(serverId).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
})
|
||||
await router.push(`/hosting/manage/${serverId}/content`)
|
||||
await router.push(`/hosting/manage/${serverId}/`)
|
||||
}
|
||||
|
||||
/** Map UI loader names to API Modloader values */
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-stats
|
||||
style="font-variant-numeric: tabular-nums"
|
||||
class="flex select-none flex-col items-center gap-4 md:flex-row"
|
||||
:class="{ 'pointer-events-none': loading }"
|
||||
:aria-hidden="loading"
|
||||
>
|
||||
<component
|
||||
:is="metric.link ? RouterLink : 'div'"
|
||||
v-for="(metric, index) in metrics"
|
||||
:key="index"
|
||||
:to="metric.link && !loading ? metric.link : undefined"
|
||||
class="relative isolate min-h-[145px] w-full overflow-hidden rounded-[20px] bg-surface-3 p-5"
|
||||
:class="
|
||||
metric.link && !loading
|
||||
? 'cursor-pointer transition-transform duration-100 hover:brightness-125 active:scale-95'
|
||||
: ''
|
||||
"
|
||||
>
|
||||
<div class="relative z-10 flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="stat-drop-shadow flex items-center gap-2 font-medium text-lg text-primary">
|
||||
{{ metric.title }}
|
||||
</span>
|
||||
<span class="relative">
|
||||
<component :is="metric.icon" class="stat-drop-shadow relative z-10 size-8" />
|
||||
<!-- <div
|
||||
class="absolute -right-4 -top-4 -z-10 size-14 rounded-full bg-surface-3 opacity-50 blur-lg"
|
||||
/> -->
|
||||
</span>
|
||||
</div>
|
||||
<span class="stat-drop-shadow text-4xl font-bold text-contrast">
|
||||
{{ metric.value }}
|
||||
</span>
|
||||
<!-- <div
|
||||
class="absolute -left-8 -top-4 -z-10 h-28 w-56 rounded-full bg-surface-3 opacity-50 blur-lg"
|
||||
/> -->
|
||||
</div>
|
||||
|
||||
<div v-if="metric.showGraph" class="chart-space absolute bottom-0 left-0 right-0">
|
||||
<VueApexCharts
|
||||
v-if="!loading && metric.chartOptions"
|
||||
type="area"
|
||||
height="142"
|
||||
:options="metric.chartOptions"
|
||||
:series="metric.series!"
|
||||
class="chart"
|
||||
:class="chartsReady.has(index) ? 'opacity-100' : 'opacity-0'"
|
||||
/>
|
||||
</div>
|
||||
</component>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CpuIcon, DatabaseIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import type { Stats } from '@modrinth/utils'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, defineAsyncComponent, ref, shallowRef, watch } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
|
||||
import { injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
const VueApexCharts = defineAsyncComponent(() => import('vue3-apexcharts'))
|
||||
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data?: Stats
|
||||
loading?: boolean
|
||||
showMemoryAsBytes?: boolean
|
||||
}>(),
|
||||
{
|
||||
data: undefined,
|
||||
loading: false,
|
||||
showMemoryAsBytes: false,
|
||||
},
|
||||
)
|
||||
|
||||
const chartsReady = ref(new Set<number>())
|
||||
const userPreferences = useStorage(`pyro-server-${serverId || 'unknown'}-preferences`, {
|
||||
ramAsNumber: false,
|
||||
})
|
||||
|
||||
const stats = shallowRef(
|
||||
props.data?.current || {
|
||||
cpu_percent: 0,
|
||||
ram_usage_bytes: 0,
|
||||
ram_total_bytes: 1,
|
||||
storage_usage_bytes: 0,
|
||||
},
|
||||
)
|
||||
|
||||
const GRAPH_SIZE = 10
|
||||
|
||||
const padGraph = (data: number[]) => {
|
||||
const capped = data.map((v) => Math.min(v, 100))
|
||||
if (capped.length >= GRAPH_SIZE) return capped.slice(-GRAPH_SIZE)
|
||||
return [...Array(GRAPH_SIZE - capped.length).fill(0), ...capped]
|
||||
}
|
||||
|
||||
const cpuData = computed(() => padGraph(props.data?.graph.cpu ?? []))
|
||||
const ramData = computed(() => padGraph(props.data?.graph.ram ?? []))
|
||||
|
||||
const cpuPercent = computed(() => stats.value.cpu_percent)
|
||||
const ramPercent = computed(() => (stats.value.ram_usage_bytes / stats.value.ram_total_bytes) * 100)
|
||||
|
||||
const cpuWarning = computed(() => cpuPercent.value >= 90)
|
||||
const ramWarning = computed(() => ramPercent.value >= 90)
|
||||
|
||||
const cpuDataMax = 104
|
||||
const ramDataMax = 104
|
||||
|
||||
const onChartReady = (index: number) => {
|
||||
chartsReady.value.add(index)
|
||||
}
|
||||
|
||||
const buildChartOptions = (warning: boolean, index: number, dataMax: number) => ({
|
||||
chart: {
|
||||
type: 'area' as const,
|
||||
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' as const, width: 3 },
|
||||
fill: {
|
||||
type: 'gradient' as const,
|
||||
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' as const,
|
||||
tickAmount: GRAPH_SIZE,
|
||||
},
|
||||
yaxis: { show: false, min: 0, max: dataMax, forceNiceScale: false },
|
||||
colors: [warning ? 'var(--color-orange)' : 'var(--color-brand)'],
|
||||
dataLabels: { enabled: false },
|
||||
})
|
||||
|
||||
const cpuChartOptions = computed(() => buildChartOptions(cpuWarning.value, 0, cpuDataMax))
|
||||
const ramChartOptions = computed(() => buildChartOptions(ramWarning.value, 1, ramDataMax))
|
||||
|
||||
const cpuSeries = computed(() => [{ name: 'CPU', data: cpuData.value }])
|
||||
const ramSeries = computed(() => [{ name: 'Memory', data: ramData.value }])
|
||||
|
||||
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 metrics = computed(() => {
|
||||
const storageMetric = {
|
||||
title: 'Storage',
|
||||
value: props.loading ? '0 B' : formatBytes(stats.value.storage_usage_bytes),
|
||||
icon: FolderOpenIcon,
|
||||
showGraph: false,
|
||||
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
|
||||
series: null as { name: string; data: number[] }[] | null,
|
||||
link: `/hosting/manage/${encodeURIComponent(serverId)}/files`,
|
||||
}
|
||||
|
||||
if (props.loading) {
|
||||
return [
|
||||
{
|
||||
title: 'CPU',
|
||||
value: '0.00%',
|
||||
icon: CpuIcon,
|
||||
showGraph: true,
|
||||
chartOptions: cpuChartOptions.value,
|
||||
series: cpuSeries.value,
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
title: 'Memory',
|
||||
value: '0.00%',
|
||||
icon: DatabaseIcon,
|
||||
showGraph: true,
|
||||
chartOptions: ramChartOptions.value,
|
||||
series: ramSeries.value,
|
||||
link: null,
|
||||
},
|
||||
storageMetric,
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
title: 'CPU',
|
||||
value: `${cpuPercent.value.toFixed(2)}%`,
|
||||
icon: CpuIcon,
|
||||
showGraph: true,
|
||||
chartOptions: cpuChartOptions.value,
|
||||
series: cpuSeries.value,
|
||||
link: null,
|
||||
},
|
||||
{
|
||||
title: 'Memory',
|
||||
value:
|
||||
props.showMemoryAsBytes || userPreferences.value.ramAsNumber
|
||||
? formatBytes(stats.value.ram_usage_bytes)
|
||||
: `${ramPercent.value.toFixed(2)}%`,
|
||||
icon: DatabaseIcon,
|
||||
showGraph: true,
|
||||
chartOptions: ramChartOptions.value,
|
||||
series: ramSeries.value,
|
||||
link: null,
|
||||
},
|
||||
storageMetric,
|
||||
]
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.data?.current,
|
||||
(newStats) => {
|
||||
if (newStats) {
|
||||
stats.value = newStats
|
||||
}
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stat-drop-shadow {
|
||||
filter: drop-shadow(0 4px 6px var(--surface-3));
|
||||
}
|
||||
|
||||
.chart-space {
|
||||
height: 142px;
|
||||
width: calc(100% + 40px);
|
||||
margin-left: -20px;
|
||||
margin-right: -20px;
|
||||
}
|
||||
|
||||
.chart {
|
||||
width: 100% !important;
|
||||
height: 142px !important;
|
||||
transition: opacity 0.3s ease-out;
|
||||
box-shadow:
|
||||
0 1px 2px 0 rgba(0, 0, 0, 0.3),
|
||||
0 1px 3px 0 rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.chart :deep(svg) {
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
injectServerSettingsModal,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
@@ -31,6 +32,8 @@ import type {
|
||||
ContentModpackCardVersion,
|
||||
} from '../../../shared/content-tab/types'
|
||||
|
||||
type AddonWithUiState = Archon.Content.v1.Addon & { installing?: boolean }
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -95,6 +98,7 @@ const leaveMessages = defineMessages({
|
||||
const client = injectModrinthClient()
|
||||
const { server, worldId, busyReasons, isSyncingContent } = injectModrinthServerContext()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -397,10 +401,35 @@ if (typeof window !== 'undefined') {
|
||||
|
||||
const updatingProject = ref<ContentItem | null>(null)
|
||||
const updatingModpack = ref(false)
|
||||
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
|
||||
const loadingVersions = ref(false)
|
||||
const loadingChangelog = ref(false)
|
||||
|
||||
const updatingProjectId = computed(() => updatingProject.value?.project?.id ?? null)
|
||||
|
||||
const projectVersionsQuery = useQuery({
|
||||
queryKey: computed(() => ['labrinth', 'versions', 'v2', updatingProjectId.value]),
|
||||
queryFn: () =>
|
||||
client.labrinth.versions_v2.getProjectVersions(updatingProjectId.value!, {
|
||||
include_changelog: false,
|
||||
}),
|
||||
enabled: computed(() => !!updatingProjectId.value && !updatingModpack.value),
|
||||
})
|
||||
|
||||
const updatingProjectVersions = computed(() => {
|
||||
const source = updatingModpack.value
|
||||
? modpackVersionsQuery.data.value
|
||||
: projectVersionsQuery.data.value
|
||||
if (!source) return []
|
||||
return [...source].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
})
|
||||
|
||||
const loadingVersions = computed(() =>
|
||||
updatingModpack.value
|
||||
? modpackVersionsQuery.isLoading.value
|
||||
: projectVersionsQuery.isLoading.value,
|
||||
)
|
||||
|
||||
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
|
||||
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
|
||||
const isModpackUpdateDowngrade = ref(false)
|
||||
@@ -409,6 +438,16 @@ const currentGameVersion = computed(() => contentQuery.data.value?.game_version
|
||||
const currentLoader = computed(() => contentQuery.data.value?.modloader ?? '')
|
||||
|
||||
function handleBrowseContent() {
|
||||
const contentType = type.value
|
||||
if (browseServerContent && ['mod', 'plugin', 'datapack'].includes(contentType)) {
|
||||
browseServerContent({
|
||||
serverId,
|
||||
worldId: worldId.value,
|
||||
type: contentType as 'mod' | 'plugin' | 'datapack',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
router.push({
|
||||
path: `/discover/${type.value}s`,
|
||||
query: { sid: serverId, wid: worldId.value },
|
||||
@@ -472,7 +511,7 @@ function handleUploadFiles() {
|
||||
input.click()
|
||||
}
|
||||
|
||||
function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
|
||||
function addonToContentItem(addon: AddonWithUiState): ContentItem {
|
||||
return {
|
||||
project: {
|
||||
id: addon.project_id ?? addon.filename,
|
||||
@@ -503,6 +542,7 @@ function addonToContentItem(addon: Archon.Content.v1.Addon): ContentItem {
|
||||
environment: addon.version?.environment ?? undefined,
|
||||
pack_client_retained: addon.pack_client_retained,
|
||||
pack_client_depends: addon.pack_client_depends,
|
||||
installing: addon.installing,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,31 +660,11 @@ async function handleUpdateItem(id: string) {
|
||||
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = item
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
loadingChangelog.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(item.update_version_id ?? undefined)
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(item.project.id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToLoadVersions),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSwitchVersion(item: ContentItem) {
|
||||
@@ -652,31 +672,11 @@ async function handleSwitchVersion(item: ContentItem) {
|
||||
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = item
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
loadingChangelog.value = false
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(item.version.id, { switchMode: true })
|
||||
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(item.project.id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToLoadVersions),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModpackUpdate() {
|
||||
@@ -687,41 +687,19 @@ async function handleModpackUpdate() {
|
||||
updatingProject.value = null
|
||||
loadingChangelog.value = false
|
||||
|
||||
const cached = modpackVersionsQuery.data.value
|
||||
if (cached) {
|
||||
const sorted = [...cached].sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = sorted
|
||||
loadingVersions.value = false
|
||||
} else {
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = true
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
contentUpdaterModal.value?.show(mp.has_update ?? undefined)
|
||||
}
|
||||
|
||||
if (!cached) {
|
||||
try {
|
||||
const versions = await client.labrinth.versions_v2.getProjectVersions(mp.spec.project_id, {
|
||||
include_changelog: false,
|
||||
})
|
||||
versions.sort(
|
||||
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
|
||||
)
|
||||
updatingProjectVersions.value = versions
|
||||
} catch (err) {
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToLoadVersions),
|
||||
text: err instanceof Error ? err.message : undefined,
|
||||
})
|
||||
} finally {
|
||||
loadingVersions.value = false
|
||||
}
|
||||
}
|
||||
function spliceVersionInCache(fullVersion: Labrinth.Versions.v2.Version) {
|
||||
const projectId = updatingModpack.value ? modpackProjectId.value : updatingProjectId.value
|
||||
if (!projectId) return
|
||||
const key = ['labrinth', 'versions', 'v2', projectId]
|
||||
queryClient.setQueryData(key, (old: Labrinth.Versions.v2.Version[] | undefined) => {
|
||||
if (!old) return old
|
||||
return old.map((v) => (v.id === fullVersion.id ? fullVersion : v))
|
||||
})
|
||||
}
|
||||
|
||||
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
@@ -729,12 +707,7 @@ async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
|
||||
loadingChangelog.value = true
|
||||
try {
|
||||
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
|
||||
if (index !== -1) {
|
||||
const newVersions = [...updatingProjectVersions.value]
|
||||
newVersions[index] = fullVersion
|
||||
updatingProjectVersions.value = newVersions
|
||||
}
|
||||
spliceVersionInCache(fullVersion)
|
||||
} catch {
|
||||
// Silently fail on changelog fetch
|
||||
} finally {
|
||||
@@ -746,12 +719,7 @@ async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
|
||||
if (version.changelog) return
|
||||
try {
|
||||
const fullVersion = await client.labrinth.versions_v2.getVersion(version.id)
|
||||
const index = updatingProjectVersions.value.findIndex((v) => v.id === version.id)
|
||||
if (index !== -1) {
|
||||
const newVersions = [...updatingProjectVersions.value]
|
||||
newVersions[index] = fullVersion
|
||||
updatingProjectVersions.value = newVersions
|
||||
}
|
||||
spliceVersionInCache(fullVersion)
|
||||
} catch {
|
||||
// Silently fail on hover prefetch
|
||||
}
|
||||
@@ -760,8 +728,6 @@ async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
|
||||
function resetUpdateState() {
|
||||
updatingModpack.value = false
|
||||
updatingProject.value = null
|
||||
updatingProjectVersions.value = []
|
||||
loadingVersions.value = false
|
||||
loadingChangelog.value = false
|
||||
}
|
||||
|
||||
@@ -786,7 +752,23 @@ function handleModalUpdate(selectedVersion: Labrinth.Versions.v2.Version, event?
|
||||
performUpdate(selectedVersion)
|
||||
}
|
||||
|
||||
function setAddonInstalling(filename: string, installing: boolean) {
|
||||
queryClient.setQueryData(queryKey.value, (oldData: Archon.Content.v1.Addons | undefined) => {
|
||||
if (!oldData) return oldData
|
||||
return {
|
||||
...oldData,
|
||||
addons: (oldData.addons ?? []).map((a) =>
|
||||
a.filename === filename ? { ...a, installing } : a,
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
const item = updatingProject.value
|
||||
if (item) {
|
||||
setAddonInstalling(item.file_name, true)
|
||||
}
|
||||
try {
|
||||
if (updatingModpack.value) {
|
||||
const mp = contentQuery.data.value?.modpack
|
||||
@@ -800,8 +782,8 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
},
|
||||
soft_override: true,
|
||||
})
|
||||
} else if (updatingProject.value) {
|
||||
const addon = addonLookup.value.get(updatingProject.value.file_name)
|
||||
} else if (item) {
|
||||
const addon = addonLookup.value.get(item.file_name)
|
||||
if (addon) {
|
||||
await client.archon.content_v1.updateAddon(serverId, worldId.value!, {
|
||||
filename: addon.filename,
|
||||
@@ -811,6 +793,9 @@ async function performUpdate(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
}
|
||||
await contentQuery.refetch()
|
||||
} catch (err) {
|
||||
if (item) {
|
||||
setAddonInstalling(item.file_name, false)
|
||||
}
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToUpdate),
|
||||
@@ -894,7 +879,7 @@ provideContentManager({
|
||||
updateModpack: handleModpackUpdate,
|
||||
viewModpackContent: handleViewModpackContent,
|
||||
unlinkModpack: handleModpackUnlink,
|
||||
openSettings: () => router.push(`/hosting/manage/${serverId}/options/loader`),
|
||||
openSettings: () => openServerSettings({ tabId: 'installation' }),
|
||||
switchVersion: handleSwitchVersion,
|
||||
getOverflowOptions,
|
||||
mapToTableItem: (item) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon, Kyros } from '@modrinth/api-client'
|
||||
import type { Kyros } from '@modrinth/api-client'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
@@ -14,12 +14,7 @@ import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import FilePageLayout from '../../../shared/files-tab/layout.vue'
|
||||
import { provideFileManager } from '../../../shared/files-tab/providers/file-manager'
|
||||
import type {
|
||||
EditingFile,
|
||||
FileItem,
|
||||
FileOperation,
|
||||
UploadState,
|
||||
} from '../../../shared/files-tab/types'
|
||||
import type { EditingFile, FileItem } from '../../../shared/files-tab/types'
|
||||
|
||||
const props = defineProps<{
|
||||
showDebugInfo?: boolean
|
||||
@@ -28,7 +23,7 @@ const props = defineProps<{
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const serverContext = injectModrinthServerContext()
|
||||
const { serverId, fsOps, fsQueuedOps, busyReasons } = serverContext
|
||||
const { serverId, fsOps, busyReasons, uploadState, cancelUpload: cancelUploadRef } = serverContext
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -350,43 +345,6 @@ async function downloadFile(path: string, fileName: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Operations tracking
|
||||
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
|
||||
const localQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
|
||||
const dismissedOpIds = ref<Set<string>>(new Set())
|
||||
|
||||
const activeOps = computed<FileOperation[]>(() => [
|
||||
...localQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
||||
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
||||
...(fsOps.value.filter((op) => !op.id || !dismissedOpIds.value.has(op.id)) as FileOperation[]),
|
||||
])
|
||||
|
||||
async function dismissOperation(opId: string, action: 'dismiss' | 'cancel') {
|
||||
if (action === 'dismiss') {
|
||||
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
|
||||
}
|
||||
try {
|
||||
await client.kyros.files_v0.modifyOperation(opId, action)
|
||||
} catch (error) {
|
||||
if (action === 'dismiss') return
|
||||
console.error(`Failed to ${action} operation:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => fsOps.value,
|
||||
(newOps) => {
|
||||
for (const op of newOps) {
|
||||
if (op.state === 'done' && op.id && !dismissedOpIds.value.has(op.id)) {
|
||||
setTimeout(() => {
|
||||
dismissOperation(op.id, 'dismiss')
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => fsOps.value,
|
||||
() => {
|
||||
@@ -396,7 +354,6 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
initializeFileEdit()
|
||||
localQueuedOps.value = []
|
||||
})
|
||||
|
||||
// Restart
|
||||
@@ -404,17 +361,6 @@ async function restartServer() {
|
||||
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||
}
|
||||
|
||||
// Upload state
|
||||
const uploadState = ref<UploadState>({
|
||||
isUploading: false,
|
||||
currentFileName: null,
|
||||
currentFileProgress: 0,
|
||||
uploadedBytes: 0,
|
||||
totalBytes: 0,
|
||||
completedFiles: 0,
|
||||
totalFiles: 0,
|
||||
})
|
||||
|
||||
let activeUploadCancel: (() => void) | null = null
|
||||
|
||||
async function uploadFiles(files: File[]) {
|
||||
@@ -430,6 +376,7 @@ async function uploadFiles(files: File[]) {
|
||||
completedFiles: 0,
|
||||
totalFiles: files.length,
|
||||
}
|
||||
cancelUploadRef.value = () => activeUploadCancel?.()
|
||||
|
||||
let completedBytes = 0
|
||||
|
||||
@@ -464,6 +411,7 @@ async function uploadFiles(files: File[]) {
|
||||
}
|
||||
|
||||
activeUploadCancel = null
|
||||
cancelUploadRef.value = null
|
||||
refreshList()
|
||||
uploadState.value = {
|
||||
isUploading: false,
|
||||
@@ -515,8 +463,6 @@ provideFileManager({
|
||||
busyTooltip,
|
||||
busyWarning,
|
||||
extractFile,
|
||||
activeOperations: activeOps,
|
||||
dismissOperation,
|
||||
prefetchDirectory,
|
||||
prefetchFile,
|
||||
showInstallFromUrl: true,
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
<template>
|
||||
<div
|
||||
data-pyro-server-list-root
|
||||
class="experimental-styles-within relative mx-auto mb-6 flex min-h-screen w-full max-w-[1280px] flex-col px-6"
|
||||
class="experimental-styles-within relative mx-auto mb-6 flex w-full flex-col p-6"
|
||||
:class="serverList.length ? 'min-h-screen' : 'min-h-[calc(100vh-4.5rem)]'"
|
||||
>
|
||||
<ServersUpgradeModalWrapper
|
||||
v-if="isNuxt"
|
||||
ref="upgradeModal"
|
||||
:stripe-publishable-key
|
||||
:site-url
|
||||
:products
|
||||
<ServersGuestPlanModal
|
||||
ref="guestPlanModal"
|
||||
:available-products="pyroProducts"
|
||||
:currency="selectedCurrency"
|
||||
:logged-in="loggedIn"
|
||||
@continue="handleGuestPlanContinue"
|
||||
/>
|
||||
<ModrinthServersPurchaseModal
|
||||
v-if="customer && paymentMethods && regions"
|
||||
ref="purchaseModal"
|
||||
:publishable-key="props.stripePublishableKey"
|
||||
:initiate-payment="
|
||||
async (body) => await client.labrinth.billing_internal.initiatePayment(body)
|
||||
"
|
||||
:available-products="pyroProducts"
|
||||
:on-error="handleError"
|
||||
:customer="customer"
|
||||
:payment-methods="paymentMethods"
|
||||
:currency="selectedCurrency"
|
||||
:pings="regionPings"
|
||||
:regions="regions"
|
||||
:refresh-payment-methods="fetchPaymentData"
|
||||
:fetch-stock="fetchStock"
|
||||
:affiliate-code="affiliateCode"
|
||||
plan-stage
|
||||
@purchase-success="handlePurchaseSuccess"
|
||||
@hide="clearPurchaseIntent"
|
||||
/>
|
||||
<ResubscribeModal ref="resubscribeModal" @resubscribe="handleResubscribeConfirm" />
|
||||
|
||||
<div
|
||||
v-if="hasError || fetchError"
|
||||
v-if="hasError"
|
||||
class="mx-auto flex h-full min-h-[calc(100vh-4rem)] flex-col items-center justify-center gap-4 text-left"
|
||||
>
|
||||
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
|
||||
@@ -21,28 +44,22 @@
|
||||
<div class="grid place-content-center rounded-full bg-bg-blue p-4">
|
||||
<HammerIcon class="size-12 text-blue" />
|
||||
</div>
|
||||
<h1 class="m-0 w-fit text-3xl font-bold">Servers could not be loaded</h1>
|
||||
<h1 class="m-0 w-fit text-3xl font-bold">{{ formatMessage(messages.errorTitle) }}</h1>
|
||||
</div>
|
||||
<p class="text-lg text-secondary">We may have temporary issues with our servers.</p>
|
||||
<p class="text-lg text-secondary">{{ formatMessage(messages.errorDescription) }}</p>
|
||||
<ul class="m-0 list-disc space-y-4 p-0 pl-4 text-left text-sm leading-[170%]">
|
||||
<li>{{ formatMessage(messages.errorAlertNotice) }}</li>
|
||||
<li>
|
||||
Our systems automatically alert our team when there's an issue. We are already working
|
||||
on getting them back online.
|
||||
</li>
|
||||
<li>
|
||||
If you recently purchased your Modrinth Hosting server, it is currently in a queue and
|
||||
will appear here as soon as it's ready. <br />
|
||||
<span class="font-medium text-contrast"
|
||||
>Do not attempt to purchase a new server.</span
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
If you require personalized support regarding the status of your server, please
|
||||
contact Modrinth Support.
|
||||
<IntlFormatted :message-id="messages.errorQueueNotice">
|
||||
<template #warning="{ children }">
|
||||
<span class="font-medium text-contrast"><component :is="() => children" /></span>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</li>
|
||||
<li>{{ formatMessage(messages.errorSupportNotice) }}</li>
|
||||
|
||||
<li v-if="fetchError" class="text-red">
|
||||
<p>Error details:</p>
|
||||
<p>{{ formatMessage(messages.errorDetails) }}</p>
|
||||
<CopyCode
|
||||
:text="(fetchError as ModrinthServersFetchError).message || 'Unknown error'"
|
||||
:copyable="false"
|
||||
@@ -53,21 +70,25 @@
|
||||
</ul>
|
||||
</div>
|
||||
<ButtonStyled size="large" type="standard" color="brand">
|
||||
<AutoLink class="mt-6 !w-full" to="https://support.modrinth.com"
|
||||
>Contact Modrinth Support</AutoLink
|
||||
>
|
||||
<AutoLink class="mt-6 !w-full" to="https://support.modrinth.com">{{
|
||||
formatMessage(messages.contactSupportButton)
|
||||
}}</AutoLink>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled size="large" @click="() => router.go(0)">
|
||||
<button class="mt-3 !w-full">Reload</button>
|
||||
<button class="mt-3 !w-full">{{ formatMessage(messages.reloadButton) }}</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Transition v-else name="fade" mode="out-in">
|
||||
<div v-if="isLoading && !serverResponse" key="loading" class="flex flex-col gap-4 py-8">
|
||||
<div
|
||||
v-if="(isLoading || !authReady) && !serverResponse"
|
||||
key="loading"
|
||||
class="flex flex-col gap-4 py-8"
|
||||
>
|
||||
<div class="mb-4 text-center">
|
||||
<LoaderCircleIcon class="mx-auto size-8 animate-spin text-contrast" />
|
||||
<p class="m-0 mt-2 text-secondary">Loading your servers...</p>
|
||||
<p class="m-0 mt-2 text-secondary">{{ formatMessage(messages.loadingServers) }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-for="i in 3"
|
||||
@@ -85,27 +106,23 @@
|
||||
<div
|
||||
v-else-if="serverList.length === 0 && !isPollingForNewServers"
|
||||
key="empty"
|
||||
class="flex h-full flex-col items-center justify-center gap-8"
|
||||
class="flex h-full flex-col items-center justify-center gap-8 grow max-h-[1100px]"
|
||||
>
|
||||
<img
|
||||
src="https://cdn.modrinth.com/servers/excitement.webp"
|
||||
alt=""
|
||||
class="max-w-[360px]"
|
||||
style="
|
||||
mask-image: radial-gradient(97% 77% at 50% 25%, #d9d9d9 0, hsla(0, 0%, 45%, 0) 100%);
|
||||
"
|
||||
<ServerListEmpty
|
||||
:logged-in="loggedIn"
|
||||
@click-new-server="openPurchaseModal"
|
||||
@click-sign-in="handleSignIn"
|
||||
/>
|
||||
<h1 class="m-0 text-contrast">You don't have any servers yet!</h1>
|
||||
<p class="m-0">Modrinth Hosting is a new way to play modded Minecraft with your friends.</p>
|
||||
<ButtonStyled size="large" type="standard" color="brand">
|
||||
<AutoLink to="/servers#plan">Create a server</AutoLink>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else key="list">
|
||||
<div class="relative flex h-fit w-full flex-col items-center justify-between md:flex-row">
|
||||
<h1 class="w-full text-4xl font-bold text-contrast">Servers</h1>
|
||||
<div class="mb-4 flex w-full flex-row items-center justify-end gap-2 md:mb-0 md:gap-4">
|
||||
<div
|
||||
class="relative flex h-fit w-full flex-col mb-4 items-center justify-between md:flex-row"
|
||||
>
|
||||
<h1 class="w-full text-2xl m-0 font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.serversTitle) }}
|
||||
</h1>
|
||||
<div class="flex w-full flex-row items-center justify-end gap-2 md:mb-0">
|
||||
<StyledInput
|
||||
id="search"
|
||||
v-model="searchInput"
|
||||
@@ -113,14 +130,16 @@
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
placeholder="Search servers..."
|
||||
:placeholder="
|
||||
formatMessage(messages.searchPlaceholder, { count: filteredData.length })
|
||||
"
|
||||
wrapper-class="w-full md:w-72"
|
||||
/>
|
||||
<ButtonStyled v-if="isNuxt" type="standard">
|
||||
<AutoLink :to="{ path: '/servers', hash: '#plan' }">
|
||||
<ButtonStyled type="standard" color="brand">
|
||||
<button @click="openPurchaseModal">
|
||||
<PlusIcon />
|
||||
New server
|
||||
</AutoLink>
|
||||
{{ formatMessage(messages.newServerButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
@@ -134,11 +153,11 @@
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div
|
||||
v-if="isPollingForNewServers"
|
||||
v-if="showPollingForNewServers"
|
||||
class="bg-brand/10 my-4 flex items-center justify-center gap-2 rounded-full px-4 py-2 text-sm text-brand"
|
||||
>
|
||||
<LoaderCircleIcon class="size-4 animate-spin" />
|
||||
<span>Checking for new servers...</span>
|
||||
<span>{{ formatMessage(messages.checkingForNewServers) }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -146,64 +165,347 @@
|
||||
v-if="filteredData.length > 0 || isPollingForNewServers"
|
||||
name="list"
|
||||
tag="ul"
|
||||
class="m-0 flex flex-col gap-4 p-0"
|
||||
class="m-0 flex flex-col gap-3 p-0"
|
||||
>
|
||||
<MedalServerListing
|
||||
v-for="server in filteredData.filter((s) => s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
@upgrade="openUpgradeModal(server.server_id)"
|
||||
@upgrade="openPurchaseModal"
|
||||
/>
|
||||
<ServerListing
|
||||
v-for="server in filteredData.filter((s) => !s.is_medal)"
|
||||
:key="server.server_id"
|
||||
v-bind="server"
|
||||
:cancellation-date="serverBillingMap.get(server.server_id)?.cancellationDate"
|
||||
:is-provisioning="serverBillingMap.get(server.server_id)?.isProvisioning"
|
||||
:on-resubscribe="serverBillingMap.get(server.server_id)?.onResubscribe"
|
||||
:on-download-backup="serverBillingMap.get(server.server_id)?.onDownloadBackup"
|
||||
/>
|
||||
</TransitionGroup>
|
||||
<div v-else class="flex h-full items-center justify-center">
|
||||
<div v-else-if="isLoading" class="flex h-full items-center justify-center">
|
||||
<p class="text-contrast"><LoaderCircleIcon class="size-5 animate-spin" /></p>
|
||||
</div>
|
||||
<div v-else>{{ formatMessage(messages.noServersFound) }}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { type Archon, type Labrinth, NuxtModrinthClient } from '@modrinth/api-client'
|
||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { HammerIcon, LoaderCircleIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
|
||||
import { AutoLink, ButtonStyled, CopyCode, injectModrinthClient, StyledInput } from '@modrinth/ui'
|
||||
import {
|
||||
AutoLink,
|
||||
ButtonStyled,
|
||||
CopyCode,
|
||||
defineMessages,
|
||||
injectAuth,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
ModrinthServersPurchaseModal,
|
||||
ResubscribeModal,
|
||||
ServerListEmpty,
|
||||
ServersGuestPlanModal,
|
||||
StyledInput,
|
||||
useServerBackupDownload,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { ModrinthServersFetchError } from '@modrinth/utils'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useIntervalFn } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { ComponentPublicInstance } from 'vue'
|
||||
import type Stripe from 'stripe'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ServersUpgradeModalWrapper from '#ui/components/billing/ServersUpgradeModalWrapper.vue'
|
||||
import MedalServerListing from '#ui/components/servers/marketing/MedalServerListing.vue'
|
||||
import ServerListing from '#ui/components/servers/ServerListing.vue'
|
||||
import { createHostingPurchaseIntentContext, provideHostingPurchaseIntent } from '#ui/providers'
|
||||
|
||||
defineProps<{
|
||||
stripePublishableKey?: string
|
||||
const props = defineProps<{
|
||||
stripePublishableKey: string
|
||||
siteUrl?: string
|
||||
products?: Labrinth.Billing.Internal.Product[]
|
||||
products: Labrinth.Billing.Internal.Product[]
|
||||
}>()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const auth = injectAuth()
|
||||
const client = injectModrinthClient()
|
||||
const loggedIn = computed(() => !!auth.user.value)
|
||||
const authReady = computed(() => auth.isReady?.value ?? true)
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||
const messages = defineMessages({
|
||||
errorTitle: { id: 'servers.manage.error.title', defaultMessage: 'Servers could not be loaded' },
|
||||
errorDescription: {
|
||||
id: 'servers.manage.error.description',
|
||||
defaultMessage: 'We may have temporary issues with our servers.',
|
||||
},
|
||||
errorAlertNotice: {
|
||||
id: 'servers.manage.error.alert-notice',
|
||||
defaultMessage:
|
||||
"Our systems automatically alert our team when there's an issue. We are already working on getting them back online.",
|
||||
},
|
||||
errorQueueNotice: {
|
||||
id: 'servers.manage.error.queue-notice',
|
||||
defaultMessage:
|
||||
"If you recently purchased your Modrinth Hosting server, it is currently in a queue and will appear here as soon as it's ready. <warning>Do not attempt to purchase a new server.</warning>",
|
||||
},
|
||||
errorSupportNotice: {
|
||||
id: 'servers.manage.error.support-notice',
|
||||
defaultMessage:
|
||||
'If you require personalized support regarding the status of your server, please contact Modrinth Support.',
|
||||
},
|
||||
errorDetails: { id: 'servers.manage.error.details', defaultMessage: 'Error details:' },
|
||||
contactSupportButton: {
|
||||
id: 'servers.manage.contact-support-button',
|
||||
defaultMessage: 'Contact Modrinth Support',
|
||||
},
|
||||
reloadButton: { id: 'servers.manage.reload-button', defaultMessage: 'Reload' },
|
||||
loadingServers: {
|
||||
id: 'servers.manage.loading-servers',
|
||||
defaultMessage: 'Loading your servers...',
|
||||
},
|
||||
serversTitle: { id: 'servers.manage.servers-title', defaultMessage: 'Modrinth Hosting' },
|
||||
searchPlaceholder: {
|
||||
id: 'servers.manage.search-placeholder',
|
||||
defaultMessage: 'Search {count} {count, plural, one {server} other {servers}}...',
|
||||
},
|
||||
newServerButton: { id: 'servers.manage.new-server-button', defaultMessage: 'New server' },
|
||||
checkingForNewServers: {
|
||||
id: 'servers.manage.checking-for-new-servers',
|
||||
defaultMessage: 'Checking for new servers...',
|
||||
},
|
||||
noServersFound: { id: 'servers.manage.no-servers-found', defaultMessage: 'No servers found.' },
|
||||
handleErrorTitle: {
|
||||
id: 'servers.manage.handle-error.title',
|
||||
defaultMessage: 'An error occurred',
|
||||
},
|
||||
purchaseUnavailableTitle: {
|
||||
id: 'servers.manage.purchase-unavailable.title',
|
||||
defaultMessage: 'Purchase unavailable',
|
||||
},
|
||||
purchaseUnavailableText: {
|
||||
id: 'servers.manage.purchase-unavailable.text',
|
||||
defaultMessage:
|
||||
'Payment information is still loading. Opening checkout as soon as it is ready.',
|
||||
},
|
||||
resubscribeSubmittedTitle: {
|
||||
id: 'servers.manage.resubscribe-submitted.title',
|
||||
defaultMessage: 'Resubscription request submitted',
|
||||
},
|
||||
resubscribeSubmittedText: {
|
||||
id: 'servers.manage.resubscribe-submitted.text',
|
||||
defaultMessage:
|
||||
'If the server is currently cancelled, it may take up to 10 minutes for another charge attempt to be made.',
|
||||
},
|
||||
resubscribeSuccessTitle: {
|
||||
id: 'servers.manage.resubscribe-success.title',
|
||||
defaultMessage: 'Success',
|
||||
},
|
||||
resubscribeSuccessText: {
|
||||
id: 'servers.manage.resubscribe-success.text',
|
||||
defaultMessage: 'Server subscription resubscribed successfully',
|
||||
},
|
||||
resubscribeErrorTitle: {
|
||||
id: 'servers.manage.resubscribe-error.title',
|
||||
defaultMessage: 'Error resubscribing',
|
||||
},
|
||||
resubscribeErrorText: {
|
||||
id: 'servers.manage.resubscribe-error.text',
|
||||
defaultMessage: 'An error occurred while resubscribing to your Modrinth server.',
|
||||
},
|
||||
})
|
||||
|
||||
const hasError = ref(false)
|
||||
const isPollingForNewServers = ref(false)
|
||||
const showPollingForNewServers = ref(false)
|
||||
let pollingShowTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
watch(isPollingForNewServers, (polling) => {
|
||||
clearTimeout(pollingShowTimeout)
|
||||
if (polling) {
|
||||
pollingShowTimeout = setTimeout(() => {
|
||||
showPollingForNewServers.value = isPollingForNewServers.value
|
||||
}, 1500)
|
||||
} else {
|
||||
showPollingForNewServers.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const pollingState = ref({
|
||||
enabled: false,
|
||||
count: 0,
|
||||
initialServers: [] as Archon.Servers.v0.Server[],
|
||||
initialServerIds: new Set<string>(),
|
||||
})
|
||||
|
||||
function startNewServerPolling(initialServers: Archon.Servers.v0.Server[]) {
|
||||
if (pollingState.value.enabled) return
|
||||
isPollingForNewServers.value = true
|
||||
pollingState.value = {
|
||||
enabled: true,
|
||||
count: 0,
|
||||
initialServerIds: new Set(initialServers.map((s) => s.server_id)),
|
||||
}
|
||||
}
|
||||
|
||||
const guestPlanModal = ref<InstanceType<typeof ServersGuestPlanModal> | null>(null)
|
||||
const purchaseModal = ref<InstanceType<typeof ModrinthServersPurchaseModal> | null>(null)
|
||||
const resubscribeModal = ref<InstanceType<typeof ResubscribeModal> | null>(null)
|
||||
const affiliateCode = ref<string | null>(null)
|
||||
const selectedCurrency = ref<string>('USD')
|
||||
const regionPings = ref<
|
||||
{
|
||||
region: string
|
||||
ping: number
|
||||
}[]
|
||||
>([])
|
||||
|
||||
const pyroProducts = computed(() => {
|
||||
return [...props.products]
|
||||
.filter((p) => p?.metadata?.type === 'pyro' || p?.metadata?.type === 'medal')
|
||||
.sort((a, b) => {
|
||||
const aRam =
|
||||
a?.metadata?.type === 'pyro' || a?.metadata?.type === 'medal' ? a.metadata.ram : 0
|
||||
const bRam =
|
||||
b?.metadata?.type === 'pyro' || b?.metadata?.type === 'medal' ? b.metadata.ram : 0
|
||||
return aRam - bRam
|
||||
})
|
||||
})
|
||||
|
||||
const {
|
||||
data: customer,
|
||||
refetch: refetchCustomer,
|
||||
isLoading: customerLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['billing', 'customer'],
|
||||
queryFn: () => client.labrinth.billing_internal.getCustomer() as Promise<Stripe.Customer>,
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
const {
|
||||
data: paymentMethods,
|
||||
refetch: refetchPaymentMethods,
|
||||
isLoading: paymentMethodsLoading,
|
||||
} = useQuery({
|
||||
queryKey: ['billing', 'payment-methods'],
|
||||
queryFn: () =>
|
||||
client.labrinth.billing_internal.getPaymentMethods() as Promise<Stripe.PaymentMethod[]>,
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
const { data: regions, isLoading: regionsLoading } = useQuery({
|
||||
queryKey: ['servers', 'regions'],
|
||||
queryFn: () => client.archon.servers_v1.getRegions(),
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
watch(
|
||||
regions,
|
||||
(newRegions) => {
|
||||
regionPings.value = []
|
||||
if (newRegions) {
|
||||
newRegions.forEach((region) => {
|
||||
runPingTest(region)
|
||||
})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function fetchPaymentData() {
|
||||
await Promise.all([refetchCustomer(), refetchPaymentMethods()])
|
||||
}
|
||||
|
||||
async function fetchStock(
|
||||
region: Archon.Servers.v1.Region,
|
||||
request: Archon.Servers.v0.StockRequest,
|
||||
): Promise<number> {
|
||||
const result = await client.archon.servers_v0.checkStock(region.shortcode, request)
|
||||
return result.available
|
||||
}
|
||||
|
||||
const PING_COUNT = 20
|
||||
const PING_INTERVAL = 200
|
||||
const MAX_PING_TIME = 1000
|
||||
|
||||
function runPingTest(region: Archon.Servers.v1.Region, index = 1) {
|
||||
if (index > 10) {
|
||||
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping: -1,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const wsUrl = `wss://${region.shortcode}${index}.${region.zone}/pingtest`
|
||||
try {
|
||||
const socket = new WebSocket(wsUrl)
|
||||
const pings: number[] = []
|
||||
let finalized = false
|
||||
|
||||
const finalize = (ping: number) => {
|
||||
if (finalized) return
|
||||
finalized = true
|
||||
clearTimeout(connectTimeout)
|
||||
regionPings.value = regionPings.value.filter((entry) => entry.region !== region.shortcode)
|
||||
regionPings.value.push({
|
||||
region: region.shortcode,
|
||||
ping,
|
||||
})
|
||||
socket.close()
|
||||
}
|
||||
|
||||
const retryNext = () => {
|
||||
if (finalized) return
|
||||
finalized = true
|
||||
clearTimeout(connectTimeout)
|
||||
socket.close()
|
||||
runPingTest(region, index + 1)
|
||||
}
|
||||
|
||||
// Prevent hangs where the socket never opens or errors.
|
||||
const connectTimeout = setTimeout(() => {
|
||||
retryNext()
|
||||
}, 3000)
|
||||
|
||||
socket.onopen = () => {
|
||||
clearTimeout(connectTimeout)
|
||||
|
||||
for (let i = 0; i < PING_COUNT; i++) {
|
||||
setTimeout(() => {
|
||||
socket.send(String(performance.now()))
|
||||
}, i * PING_INTERVAL)
|
||||
}
|
||||
setTimeout(
|
||||
() => {
|
||||
const median =
|
||||
pings.length > 0
|
||||
? Math.round([...pings].sort((a, b) => a - b)[Math.floor(pings.length / 2)])
|
||||
: -1
|
||||
finalize(median)
|
||||
},
|
||||
PING_COUNT * PING_INTERVAL + MAX_PING_TIME,
|
||||
)
|
||||
}
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
const start = Number(event.data)
|
||||
pings.push(performance.now() - start)
|
||||
}
|
||||
|
||||
socket.onerror = () => {
|
||||
retryNext()
|
||||
}
|
||||
} catch {
|
||||
runPingTest(region, index + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
data: serverResponse,
|
||||
error: fetchError,
|
||||
@@ -211,7 +513,7 @@ const {
|
||||
} = useQuery({
|
||||
queryKey: ['servers'],
|
||||
queryFn: async () => {
|
||||
const response = await client.archon.servers_v0.list()
|
||||
const response = await client.archon.servers_v0.list({ limit: 100 })
|
||||
|
||||
// Fetch subscriptions for medal servers
|
||||
const hasMedalServers = response.servers.some((s) => s.is_medal)
|
||||
@@ -232,9 +534,13 @@ const {
|
||||
// Check if new servers appeared (stop polling)
|
||||
if (pollingState.value.enabled) {
|
||||
pollingState.value.count++
|
||||
if (response.servers.length !== pollingState.value.initialServers.length) {
|
||||
const hasNewServer = response.servers.some(
|
||||
(s) => !pollingState.value.initialServerIds.has(s.server_id),
|
||||
)
|
||||
if (hasNewServer) {
|
||||
pollingState.value.enabled = false
|
||||
isPollingForNewServers.value = false
|
||||
|
||||
router.replace({ query: {} })
|
||||
} else if (pollingState.value.count >= 5) {
|
||||
pollingState.value.enabled = false
|
||||
@@ -245,14 +551,13 @@ const {
|
||||
return response
|
||||
},
|
||||
refetchInterval: computed(() => (pollingState.value.enabled ? 5000 : false)),
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
watch([fetchError, serverResponse], ([error, response]) => {
|
||||
hasError.value = !!error || !response
|
||||
})
|
||||
const hasError = computed(() => loggedIn.value && !!fetchError.value)
|
||||
|
||||
const serverList = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
if (!serverResponse.value) return []
|
||||
if (!loggedIn.value || !serverResponse.value) return []
|
||||
return serverResponse.value.servers
|
||||
})
|
||||
|
||||
@@ -267,19 +572,48 @@ const fuse = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
function introToTop(array: Archon.Servers.v0.Server[]): Archon.Servers.v0.Server[] {
|
||||
function isSetToCancel(server: Archon.Servers.v0.Server): boolean {
|
||||
return (
|
||||
server.status !== 'suspended' &&
|
||||
Boolean(serverBillingMap.value.get(server.server_id)?.cancellationDate)
|
||||
)
|
||||
}
|
||||
|
||||
function getStatusPriority(server: Archon.Servers.v0.Server): number {
|
||||
if (server.status === 'suspended') return 2
|
||||
if (isSetToCancel(server)) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function sortServers(array: Archon.Servers.v0.Server[]): Archon.Servers.v0.Server[] {
|
||||
return array.slice().sort((a, b) => {
|
||||
return Number(b.flows?.intro) - Number(a.flows?.intro)
|
||||
const priorityDiff = getStatusPriority(a) - getStatusPriority(b)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
|
||||
const introDiff = Number(b.flows?.intro) - Number(a.flows?.intro)
|
||||
if (introDiff !== 0) return introDiff
|
||||
|
||||
return (a.name || '').localeCompare(b.name || '')
|
||||
})
|
||||
}
|
||||
|
||||
// files expire 30 days after cancellation
|
||||
function filesExpired(server: Archon.Servers.v0.Server): boolean {
|
||||
if (server.status !== 'suspended' || server.suspension_reason !== 'cancelled') return false
|
||||
const cancellationDate = serverBillingMap.value.get(server.server_id)?.cancellationDate
|
||||
if (!cancellationDate) return false
|
||||
const cancellation = new Date(cancellationDate)
|
||||
const thirtyDaysLater = new Date(cancellation.getTime() + 30 * 24 * 60 * 60 * 1000)
|
||||
return new Date() > thirtyDaysLater
|
||||
}
|
||||
|
||||
const filteredData = computed<Archon.Servers.v0.Server[]>(() => {
|
||||
if (!searchInput.value.trim()) {
|
||||
return introToTop(serverList.value)
|
||||
}
|
||||
return fuse.value
|
||||
? introToTop(fuse.value.search(searchInput.value).map((result) => result.item))
|
||||
: []
|
||||
const base = !searchInput.value.trim()
|
||||
? sortServers(serverList.value)
|
||||
: fuse.value
|
||||
? sortServers(fuse.value.search(searchInput.value).map((result) => result.item))
|
||||
: []
|
||||
return base.filter((server) => !filesExpired(server))
|
||||
})
|
||||
|
||||
// Start polling only after initial data is available so the baseline is correct
|
||||
@@ -290,23 +624,307 @@ watch(serverResponse, (response) => {
|
||||
!pollingState.value.enabled &&
|
||||
pollingState.value.count === 0
|
||||
) {
|
||||
isPollingForNewServers.value = true
|
||||
pollingState.value = {
|
||||
enabled: true,
|
||||
count: 0,
|
||||
initialServers: [...response.servers],
|
||||
}
|
||||
startNewServerPolling(response.servers)
|
||||
}
|
||||
})
|
||||
|
||||
type ServersUpgradeModalWrapperRef = ComponentPublicInstance<{
|
||||
open: (id: string) => void | Promise<void>
|
||||
}>
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
const { getLatestBackupDownload } = useServerBackupDownload()
|
||||
|
||||
const upgradeModal = ref<ServersUpgradeModalWrapperRef | null>(null)
|
||||
function openUpgradeModal(serverId: string) {
|
||||
upgradeModal.value?.open(serverId)
|
||||
function handlePurchaseSuccess() {
|
||||
startNewServerPolling(serverResponse.value?.servers ?? [])
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'v1'] }),
|
||||
])
|
||||
}
|
||||
|
||||
watch(
|
||||
() => auth.user.value,
|
||||
(user, previousUser) => {
|
||||
if (user || !previousUser) return
|
||||
isPollingForNewServers.value = false
|
||||
pollingState.value = {
|
||||
enabled: false,
|
||||
count: 0,
|
||||
initialServerIds: new Set(),
|
||||
}
|
||||
void Promise.all([
|
||||
queryClient.resetQueries({ queryKey: ['billing'] }),
|
||||
queryClient.resetQueries({ queryKey: ['servers'] }),
|
||||
])
|
||||
},
|
||||
)
|
||||
|
||||
const canOpenPurchaseModal = computed(() => {
|
||||
return (
|
||||
Boolean(props.stripePublishableKey) &&
|
||||
Boolean(customer.value) &&
|
||||
paymentMethods.value !== undefined &&
|
||||
Boolean(regions.value) &&
|
||||
!customerLoading.value &&
|
||||
!paymentMethodsLoading.value &&
|
||||
!regionsLoading.value
|
||||
)
|
||||
})
|
||||
|
||||
function handleError(err: unknown) {
|
||||
const error = err as Error & { data?: { description?: string } }
|
||||
addNotification({
|
||||
title: formatMessage(messages.handleErrorTitle),
|
||||
type: 'error',
|
||||
text: error?.message ?? error?.data?.description ?? String(err),
|
||||
})
|
||||
}
|
||||
|
||||
function handleSignIn() {
|
||||
void auth.requestSignIn('/hosting/manage')
|
||||
}
|
||||
|
||||
const hostingPurchaseIntent = createHostingPurchaseIntentContext({
|
||||
authRequestSignIn: auth.requestSignIn,
|
||||
signInRedirectPath: '/hosting/manage',
|
||||
intentSource: 'hosting-manage',
|
||||
loggedIn,
|
||||
availableProducts: pyroProducts,
|
||||
canOpenCheckout: canOpenPurchaseModal,
|
||||
guestPlanModal,
|
||||
checkoutModal: purchaseModal,
|
||||
onCheckoutPending: () => {
|
||||
addNotification({
|
||||
title: formatMessage(messages.purchaseUnavailableTitle),
|
||||
text: formatMessage(messages.purchaseUnavailableText),
|
||||
type: 'info',
|
||||
})
|
||||
},
|
||||
})
|
||||
provideHostingPurchaseIntent(hostingPurchaseIntent)
|
||||
|
||||
const { openPurchaseModal, handleGuestPlanContinue, clearPurchaseIntent } = hostingPurchaseIntent
|
||||
|
||||
const { data: subscriptions } = useQuery({
|
||||
queryKey: ['billing', 'subscriptions'],
|
||||
queryFn: () => client.labrinth.billing_internal.getSubscriptions(),
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
const { data: charges } = useQuery({
|
||||
queryKey: ['billing', 'payments'],
|
||||
queryFn: () => client.labrinth.billing_internal.getPayments(),
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
const CHARGE_POLL_INTERVAL_MS = 20_000
|
||||
|
||||
const hasProvisioningSubscription = computed(() => {
|
||||
if (!subscriptions.value || !charges.value) return false
|
||||
return subscriptions.value
|
||||
.filter((s) => s?.metadata?.type === 'pyro')
|
||||
.some((sub) => {
|
||||
if (sub.status !== 'unprovisioned') return false
|
||||
const charge = charges.value?.find((c) => c.subscription_id === sub.id)
|
||||
return charge?.status === 'processing' || charge?.status === 'open'
|
||||
})
|
||||
})
|
||||
|
||||
const { pause: pauseChargePoll, resume: resumeChargePoll } = useIntervalFn(
|
||||
() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'payments'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['billing', 'subscriptions'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] })
|
||||
},
|
||||
CHARGE_POLL_INTERVAL_MS,
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
watch(
|
||||
hasProvisioningSubscription,
|
||||
(isProvisioning) => {
|
||||
if (isProvisioning) {
|
||||
resumeChargePoll()
|
||||
} else {
|
||||
pauseChargePoll()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const { data: serverFullList } = useQuery({
|
||||
queryKey: ['servers', 'v1'],
|
||||
queryFn: () => client.archon.servers_v1.list(),
|
||||
enabled: loggedIn,
|
||||
})
|
||||
|
||||
type ServerBillingInfo = {
|
||||
cancellationDate?: string | null
|
||||
isProvisioning?: boolean
|
||||
onResubscribe?: () => void
|
||||
onDownloadBackup?: (() => void) | null
|
||||
}
|
||||
|
||||
type ResubscribeRequest = {
|
||||
subscriptionId: string
|
||||
wasSuspended: boolean
|
||||
}
|
||||
|
||||
function getProductFromPriceId(priceId: string | null | undefined) {
|
||||
if (!priceId) return null
|
||||
|
||||
return (
|
||||
pyroProducts.value.find((product) => product.prices.some((price) => price.id === priceId)) ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
function getPlanName(product: Labrinth.Billing.Internal.Product | null): string {
|
||||
if (!product) return 'Medium plan'
|
||||
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return 'Medium plan'
|
||||
|
||||
switch (product.metadata.ram) {
|
||||
case 4096:
|
||||
return 'Small plan'
|
||||
case 6144:
|
||||
return 'Medium plan'
|
||||
case 8192:
|
||||
return 'Large plan'
|
||||
default:
|
||||
return 'Custom plan'
|
||||
}
|
||||
}
|
||||
|
||||
function getRamGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
|
||||
if (!product) return undefined
|
||||
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
|
||||
|
||||
return product.metadata.ram / 1024
|
||||
}
|
||||
|
||||
function getStorageGb(product: Labrinth.Billing.Internal.Product | null): number | undefined {
|
||||
if (!product) return undefined
|
||||
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
|
||||
|
||||
return product.metadata.storage / 1024
|
||||
}
|
||||
|
||||
function getSharedCpus(product: Labrinth.Billing.Internal.Product | null): number | undefined {
|
||||
if (!product) return undefined
|
||||
if (product.metadata.type !== 'pyro' && product.metadata.type !== 'medal') return undefined
|
||||
|
||||
return product.metadata.cpu / 2
|
||||
}
|
||||
|
||||
function getRecurringPrice(
|
||||
product: Labrinth.Billing.Internal.Product | null,
|
||||
interval: Labrinth.Billing.Internal.PriceDuration,
|
||||
preferredCurrency?: string,
|
||||
): { amount: number; currencyCode: string } | null {
|
||||
if (!product) return null
|
||||
|
||||
const recurringPrices = product.prices.filter((price) => price.prices.type === 'recurring')
|
||||
const preferredPrice = preferredCurrency
|
||||
? recurringPrices.find((price) => price.currency_code === preferredCurrency)
|
||||
: undefined
|
||||
const usdPrice = recurringPrices.find((price) => price.currency_code === 'USD')
|
||||
const selectedPrice = preferredPrice ?? usdPrice ?? recurringPrices[0]
|
||||
|
||||
if (!selectedPrice || selectedPrice.prices.type !== 'recurring') return null
|
||||
|
||||
return {
|
||||
amount: selectedPrice.prices.intervals[interval],
|
||||
currencyCode: selectedPrice.currency_code,
|
||||
}
|
||||
}
|
||||
|
||||
function openResubscribeModal(
|
||||
serverId: string,
|
||||
subscription: Labrinth.Billing.Internal.UserSubscription,
|
||||
charge?: Labrinth.Billing.Internal.Charge | null,
|
||||
) {
|
||||
const displayInterval = charge?.subscription_interval ?? subscription.interval
|
||||
const displayPriceId = charge?.price_id ?? subscription.price_id
|
||||
const product = getProductFromPriceId(displayPriceId)
|
||||
const fallbackPrice = getRecurringPrice(product, displayInterval, charge?.currency_code)
|
||||
|
||||
resubscribeModal.value?.show({
|
||||
subscriptionId: subscription.id,
|
||||
wasSuspended: !!charge?.due && dayjs(charge.due).isBefore(dayjs()),
|
||||
serverName:
|
||||
serverList.value.find((server) => server.server_id === serverId)?.name ?? 'this server',
|
||||
planName: getPlanName(product),
|
||||
ramGb: getRamGb(product),
|
||||
storageGb: getStorageGb(product),
|
||||
sharedCpus: getSharedCpus(product),
|
||||
priceCents: charge?.amount ?? fallbackPrice?.amount,
|
||||
currencyCode: charge?.currency_code ?? fallbackPrice?.currencyCode,
|
||||
interval: displayInterval,
|
||||
nextChargeDate: charge?.due,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleResubscribeConfirm({ subscriptionId, wasSuspended }: ResubscribeRequest) {
|
||||
try {
|
||||
await client.labrinth.billing_internal.editSubscription(subscriptionId, {
|
||||
cancelled: false,
|
||||
})
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['billing'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['servers'] }),
|
||||
])
|
||||
if (wasSuspended) {
|
||||
addNotification({
|
||||
title: formatMessage(messages.resubscribeSubmittedTitle),
|
||||
text: formatMessage(messages.resubscribeSubmittedText),
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
addNotification({
|
||||
title: formatMessage(messages.resubscribeSuccessTitle),
|
||||
text: formatMessage(messages.resubscribeSuccessText),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
addNotification({
|
||||
title: formatMessage(messages.resubscribeErrorTitle),
|
||||
text: formatMessage(messages.resubscribeErrorText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const serverBillingMap = computed(() => {
|
||||
const map = new Map<string, ServerBillingInfo>()
|
||||
if (!subscriptions.value || !charges.value) return map
|
||||
|
||||
const pyroSubs = subscriptions.value.filter((s) => s?.metadata?.type === 'pyro')
|
||||
for (const sub of pyroSubs) {
|
||||
const serverId = (sub.metadata as { id?: string })?.id
|
||||
if (!serverId) continue
|
||||
|
||||
const charge = charges.value.find(
|
||||
(c) => c.subscription_id === sub.id && c.status !== 'succeeded',
|
||||
)
|
||||
|
||||
const info: ServerBillingInfo = {
|
||||
isProvisioning:
|
||||
sub.status === 'unprovisioned' &&
|
||||
(charge?.status === 'processing' || charge?.status === 'open'),
|
||||
}
|
||||
|
||||
info.onDownloadBackup = getLatestBackupDownload(serverId, serverFullList.value)
|
||||
|
||||
if (charge?.status === 'cancelled') {
|
||||
info.cancellationDate = charge.due
|
||||
|
||||
info.onResubscribe = () => openResubscribeModal(serverId, sub, charge)
|
||||
}
|
||||
|
||||
map.set(serverId, info)
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
123
packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
Normal file
123
packages/ui/src/layouts/wrapped/hosting/manage/overview.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div class="relative flex select-none flex-col gap-6" data-pyro-server-manager-root>
|
||||
<div class="flex flex-col-reverse gap-6 md:flex-col">
|
||||
<ServerManageStats
|
||||
:data="!isWsAuthIncorrect ? stats : undefined"
|
||||
:loading="isWsAuthIncorrect"
|
||||
/>
|
||||
|
||||
<div class="flex min-h-[700px] flex-col gap-4">
|
||||
<span class="text-2xl font-semibold text-contrast">Console</span>
|
||||
|
||||
<ConsolePageLayout />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isWsAuthIncorrect"
|
||||
class="absolute inset-0 flex flex-col items-center justify-center bg-bg"
|
||||
>
|
||||
<h2>Could not connect to the server.</h2>
|
||||
<p>
|
||||
An error occurred while attempting to connect to your server. Please try refreshing the
|
||||
page. (WebSocket Authentication Failed)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Mclogs } from '@modrinth/api-client'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useModrinthServersConsole } from '#ui/composables'
|
||||
import { ConsolePageLayout, provideConsoleManager } from '#ui/layouts/shared/console'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
import ServerManageStats from './components/ServerManageStats.vue'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const {
|
||||
server: _serverData,
|
||||
serverId,
|
||||
isConnected,
|
||||
isWsAuthIncorrect,
|
||||
stats,
|
||||
powerState: serverPowerState,
|
||||
powerStateDetails: _powerStateDetails,
|
||||
} = injectModrinthServerContext()
|
||||
const modrinthServersConsole = useModrinthServersConsole()
|
||||
|
||||
const crashAnalysis = ref<Mclogs.Insights.v1.InsightsResponse | null>(null)
|
||||
const DISMISS_DURATION_MS = 30 * 60 * 1000
|
||||
const dismissedUntil = useStorage(`modrinth-crash-dismissed-${serverId}`, 0)
|
||||
|
||||
const isDismissed = () => Date.now() < dismissedUntil.value
|
||||
|
||||
const inspectError = async () => {
|
||||
if (isDismissed()) return
|
||||
|
||||
try {
|
||||
const blob = await client.kyros.files_v0.downloadFile('/logs/latest.log')
|
||||
const log = await blob.text()
|
||||
if (!log) return
|
||||
|
||||
const data = await client.mclogs.insights_v1.analyse(log)
|
||||
if (data.analysis?.problems?.length) {
|
||||
crashAnalysis.value = data
|
||||
} else {
|
||||
crashAnalysis.value = null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze logs:', error)
|
||||
crashAnalysis.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const dismissCrash = () => {
|
||||
dismissedUntil.value = Date.now() + DISMISS_DURATION_MS
|
||||
crashAnalysis.value = null
|
||||
}
|
||||
|
||||
provideConsoleManager({
|
||||
logLines: modrinthServersConsole.output,
|
||||
sendCommand: (cmd: string) => {
|
||||
try {
|
||||
client.archon.sockets.send(serverId, { event: 'command', cmd })
|
||||
} catch (error) {
|
||||
console.error('Error sending command:', error)
|
||||
}
|
||||
},
|
||||
showCommandInput: true,
|
||||
disableCommandInput: computed(() => serverPowerState.value !== 'running'),
|
||||
loading: computed(() => !isConnected.value || isWsAuthIncorrect.value),
|
||||
onClear: async () => {
|
||||
modrinthServersConsole.clear()
|
||||
try {
|
||||
await client.kyros.logs_v1.clear()
|
||||
} catch (error) {
|
||||
console.error('Failed to clear server logs:', error)
|
||||
}
|
||||
},
|
||||
shareDisabled: computed(() => !isConnected.value),
|
||||
emptyStateType: 'server',
|
||||
crashAnalysis,
|
||||
onDismissCrash: dismissCrash,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => serverPowerState.value,
|
||||
(newVal) => {
|
||||
if (newVal === 'crashed') {
|
||||
void inspectError()
|
||||
} else {
|
||||
crashAnalysis.value = null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
if (serverPowerState.value === 'crashed') {
|
||||
void inspectError()
|
||||
}
|
||||
</script>
|
||||
1580
packages/ui/src/layouts/wrapped/hosting/manage/root.vue
Normal file
1580
packages/ui/src/layouts/wrapped/hosting/manage/root.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,3 +3,5 @@ export { default as ServersManageBackupsPage } from './hosting/manage/backups.vu
|
||||
export { default as ServersManageContentPage } from './hosting/manage/content.vue'
|
||||
export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
|
||||
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
|
||||
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
|
||||
export { default as ServersManageRootLayout } from './hosting/manage/root.vue'
|
||||
|
||||
Reference in New Issue
Block a user