mirror of
https://github.com/modrinth/code.git
synced 2026-08-03 06:35:53 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b4f913605 | ||
|
|
8e61f042b7 | ||
|
|
8301620b76 | ||
|
|
3a69e8818a | ||
|
|
8944437f1b | ||
|
|
65e250f7f2 | ||
|
|
5e34efea22 | ||
|
|
e36f8a42f5 | ||
|
|
c5f1fa1154 | ||
|
|
443e17f9d5 | ||
|
|
7622abe59b | ||
|
|
76036602a3 | ||
|
|
e2ac1274ac | ||
|
|
ceef2811f4 | ||
|
|
739a1baef5 | ||
|
|
1e1a75ab90 | ||
|
|
b6f25e9ed4 | ||
|
|
ef4c2e3161 | ||
|
|
7911ce8e4a | ||
|
|
fcaaf534a8 | ||
|
|
3cd18b7205 |
@@ -134,6 +134,7 @@ const route = useRoute()
|
|||||||
const APP_LEFT_NAV_WIDTH = '4rem'
|
const APP_LEFT_NAV_WIDTH = '4rem'
|
||||||
const APP_SIDEBAR_WIDTH = 300
|
const APP_SIDEBAR_WIDTH = 300
|
||||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||||
|
const ROUTE_SUSPENSE_TIMEOUT_MS = 60_000
|
||||||
const credentials = ref()
|
const credentials = ref()
|
||||||
const sidebarToggled = ref(true)
|
const sidebarToggled = ref(true)
|
||||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||||
@@ -143,6 +144,22 @@ const forceSidebar = computed(
|
|||||||
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
||||||
)
|
)
|
||||||
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
const sidebarVisible = computed(() => sidebarToggled.value || forceSidebar.value)
|
||||||
|
const keepAliveRouteComponents = computed(() => [
|
||||||
|
...new Set(
|
||||||
|
router
|
||||||
|
.getRoutes()
|
||||||
|
.map((route) => route.meta.keepAliveComponent)
|
||||||
|
.filter((name) => typeof name === 'string'),
|
||||||
|
),
|
||||||
|
])
|
||||||
|
|
||||||
|
function getRouteViewKey(viewRoute) {
|
||||||
|
const keepAliveKey = viewRoute.meta.keepAliveKey
|
||||||
|
if (typeof keepAliveKey === 'function') return keepAliveKey(viewRoute)
|
||||||
|
if (typeof keepAliveKey === 'string') return keepAliveKey
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
const hostingRouteActive = computed(() => route.path.startsWith('/hosting'))
|
||||||
const hostingIntercomIdentityKey = computed(() => {
|
const hostingIntercomIdentityKey = computed(() => {
|
||||||
const rawServerId = route.params.id
|
const rawServerId = route.params.id
|
||||||
@@ -482,6 +499,11 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
router.beforeEach(async (to) => {
|
||||||
|
const redirect = await resolveLegacyServerInstanceTabRedirect(to)
|
||||||
|
if (redirect) return redirect
|
||||||
|
})
|
||||||
|
|
||||||
router.beforeEach(() => {
|
router.beforeEach(() => {
|
||||||
suspensePending = false
|
suspensePending = false
|
||||||
if (routerToken) loading.end(routerToken)
|
if (routerToken) loading.end(routerToken)
|
||||||
@@ -513,6 +535,50 @@ function onSuspensePending() {
|
|||||||
suspenseToken = loading.begin()
|
suspenseToken = loading.begin()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resolveLegacyServerInstanceTabRedirect(to) {
|
||||||
|
if (!['ServerManageContent', 'ServerManageFiles', 'ServerManageBackups'].includes(to.name)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverId = getRouteParam(to.params.id)
|
||||||
|
if (!serverId) return null
|
||||||
|
|
||||||
|
const tabPath =
|
||||||
|
to.name === 'ServerManageFiles' ? '/files' : to.name === 'ServerManageBackups' ? '/backups' : ''
|
||||||
|
const instancesPath = `/hosting/manage/${encodeURIComponent(serverId)}/instances`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const serverFull = await tauriApiClient.archon.servers_v1.get(serverId)
|
||||||
|
const world = serverFull.worlds.find((item) => item.is_active) ?? serverFull.worlds[0]
|
||||||
|
if (world) {
|
||||||
|
return {
|
||||||
|
path: `${instancesPath}/${encodeURIComponent(world.id)}${tabPath}`,
|
||||||
|
query: to.query,
|
||||||
|
hash: to.hash,
|
||||||
|
replace: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
path: instancesPath,
|
||||||
|
query: to.query,
|
||||||
|
hash: to.hash,
|
||||||
|
replace: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: instancesPath,
|
||||||
|
query: to.query,
|
||||||
|
hash: to.hash,
|
||||||
|
replace: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRouteParam(param) {
|
||||||
|
return Array.isArray(param) ? param[0] : param
|
||||||
|
}
|
||||||
|
|
||||||
function onSuspenseResolve() {
|
function onSuspenseResolve() {
|
||||||
if (suspenseToken) {
|
if (suspenseToken) {
|
||||||
loading.end(suspenseToken)
|
loading.end(suspenseToken)
|
||||||
@@ -1446,11 +1512,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
|||||||
>
|
>
|
||||||
{{ formatMessage(messages.authUnreachableBody) }}
|
{{ formatMessage(messages.authUnreachableBody) }}
|
||||||
</Admonition>
|
</Admonition>
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||||
<template v-if="Component">
|
<template v-if="Component">
|
||||||
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
|
<KeepAlive :include="keepAliveRouteComponents" :max="3">
|
||||||
<component :is="Component"></component>
|
<Suspense
|
||||||
</Suspense>
|
:timeout="ROUTE_SUSPENSE_TIMEOUT_MS"
|
||||||
|
@pending="onSuspensePending"
|
||||||
|
@resolve="onSuspenseResolve"
|
||||||
|
>
|
||||||
|
<component :is="Component" :key="getRouteViewKey(viewRoute)"></component>
|
||||||
|
</Suspense>
|
||||||
|
</KeepAlive>
|
||||||
</template>
|
</template>
|
||||||
</RouterView>
|
</RouterView>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import type { Labrinth } from '@modrinth/api-client'
|
|||||||
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets'
|
import { CheckIcon, PlayIcon, PlusIcon, StopCircleIcon } from '@modrinth/assets'
|
||||||
import type { CardAction } from '@modrinth/ui'
|
import type { CardAction } from '@modrinth/ui'
|
||||||
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
|
import { commonMessages, defineMessages, useDebugLogger, useVIntl } from '@modrinth/ui'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import type { ComputedRef, Ref } from 'vue'
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
import { onUnmounted, ref, shallowRef } from 'vue'
|
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||||
import type { Router } from 'vue-router'
|
import type { Router } from 'vue-router'
|
||||||
|
|
||||||
|
import {
|
||||||
|
fetchCachedServerStatus,
|
||||||
|
getFreshCachedServerStatus,
|
||||||
|
} from '@/composables/instances/use-server-status-query'
|
||||||
import { process_listener } from '@/helpers/events'
|
import { process_listener } from '@/helpers/events'
|
||||||
import { get_by_profile_path } from '@/helpers/process'
|
import { get_by_profile_path } from '@/helpers/process'
|
||||||
import { kill, list as listInstances } from '@/helpers/profile.js'
|
import { kill, list as listInstances } from '@/helpers/profile.js'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import { add_server_to_profile, getServerLatency } from '@/helpers/worlds'
|
import { add_server_to_profile } from '@/helpers/worlds'
|
||||||
import { getServerAddress } from '@/store/install.js'
|
import { getServerAddress } from '@/store/install.js'
|
||||||
|
|
||||||
interface BrowseServerInstance {
|
interface BrowseServerInstance {
|
||||||
@@ -65,14 +70,13 @@ const messages = defineMessages({
|
|||||||
|
|
||||||
export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const debugLog = useDebugLogger('BrowseServer')
|
const debugLog = useDebugLogger('BrowseServer')
|
||||||
const serverPings = shallowRef<Record<string, number | undefined>>({})
|
const serverPings = shallowRef<Record<string, number | undefined>>({})
|
||||||
const serverPingCache = new Map<string, number | undefined>()
|
|
||||||
const pendingServerPings = new Map<string, Promise<number | undefined>>()
|
|
||||||
const runningServerProjects = ref<Record<string, string>>({})
|
const runningServerProjects = ref<Record<string, string>>({})
|
||||||
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
|
const lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
|
||||||
const contextMenuRef = ref<ContextMenuHandle | null>(null)
|
const contextMenuRef = ref<ContextMenuHandle | null>(null)
|
||||||
let serverPingCacheActive = true
|
let serverPingsActive = true
|
||||||
let unlistenProcesses: (() => void) | null = null
|
let unlistenProcesses: (() => void) | null = null
|
||||||
|
|
||||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||||
@@ -145,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
|||||||
})
|
})
|
||||||
const nextPings = { ...serverPings.value }
|
const nextPings = { ...serverPings.value }
|
||||||
for (const { hit, address } of pingsToFetch) {
|
for (const { hit, address } of pingsToFetch) {
|
||||||
if (serverPingCache.has(address)) {
|
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
|
||||||
nextPings[hit.project_id] = serverPingCache.get(address)
|
if (cachedStatus) {
|
||||||
|
nextPings[hit.project_id] = cachedStatus.ping
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
serverPings.value = nextPings
|
serverPings.value = nextPings
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
pingsToFetch.map(async ({ hit, address }) => {
|
pingsToFetch.map(async ({ hit, address }) => {
|
||||||
if (serverPingCache.has(address)) return
|
if (getFreshCachedServerStatus(queryClient, address)) return
|
||||||
|
|
||||||
let pending = pendingServerPings.get(address)
|
try {
|
||||||
if (!pending) {
|
const status = await fetchCachedServerStatus(queryClient, address)
|
||||||
pending = getServerLatency(address)
|
if (!serverPingsActive) return
|
||||||
.then((latency) => {
|
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
|
||||||
if (serverPingCacheActive) serverPingCache.set(address, latency)
|
} catch (error) {
|
||||||
return latency
|
console.error(`Failed to ping server ${address}:`, error)
|
||||||
})
|
if (!serverPingsActive) return
|
||||||
.catch((error) => {
|
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
|
||||||
console.error(`Failed to ping server ${address}:`, error)
|
|
||||||
if (serverPingCacheActive) serverPingCache.set(address, undefined)
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
pendingServerPings.delete(address)
|
|
||||||
})
|
|
||||||
pendingServerPings.set(address, pending)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const latency = await pending
|
|
||||||
if (!serverPingCacheActive) return
|
|
||||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -307,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
|||||||
.catch(options.handleError)
|
.catch(options.handleError)
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
serverPingCacheActive = false
|
serverPingsActive = false
|
||||||
unlistenProcesses?.()
|
unlistenProcesses?.()
|
||||||
serverPingCache.clear()
|
|
||||||
pendingServerPings.clear()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { QueryClient } from '@tanstack/vue-query'
|
||||||
|
|
||||||
|
import {
|
||||||
|
get_server_status,
|
||||||
|
normalizeServerAddress,
|
||||||
|
type ProtocolVersion,
|
||||||
|
type ServerStatus,
|
||||||
|
} from '@/helpers/worlds'
|
||||||
|
|
||||||
|
export const SERVER_STATUS_CACHE_MS = 10 * 60 * 1000
|
||||||
|
|
||||||
|
function getProtocolVersionKey(protocolVersion: ProtocolVersion | null) {
|
||||||
|
if (!protocolVersion) return 'default'
|
||||||
|
return `${protocolVersion.version}:${protocolVersion.legacy ? 'legacy' : 'modern'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerStatusQueryKey(
|
||||||
|
address: string,
|
||||||
|
protocolVersion: ProtocolVersion | null = null,
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
'minecraft-server-status',
|
||||||
|
normalizeServerAddress(address) || address.trim().toLowerCase(),
|
||||||
|
getProtocolVersionKey(protocolVersion),
|
||||||
|
] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFreshCachedServerStatus(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
address: string,
|
||||||
|
protocolVersion: ProtocolVersion | null = null,
|
||||||
|
) {
|
||||||
|
const queryKey = getServerStatusQueryKey(address, protocolVersion)
|
||||||
|
const updatedAt = queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
|
||||||
|
if (!updatedAt || Date.now() - updatedAt >= SERVER_STATUS_CACHE_MS) return undefined
|
||||||
|
return queryClient.getQueryData<ServerStatus>(queryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchCachedServerStatus(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
address: string,
|
||||||
|
protocolVersion: ProtocolVersion | null = null,
|
||||||
|
) {
|
||||||
|
return await queryClient.fetchQuery({
|
||||||
|
queryKey: getServerStatusQueryKey(address, protocolVersion),
|
||||||
|
queryFn: () => get_server_status(address, protocolVersion),
|
||||||
|
staleTime: SERVER_STATUS_CACHE_MS,
|
||||||
|
gcTime: SERVER_STATUS_CACHE_MS,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -143,6 +143,9 @@
|
|||||||
"app.browse.server.installing": {
|
"app.browse.server.installing": {
|
||||||
"message": "Installing"
|
"message": "Installing"
|
||||||
},
|
},
|
||||||
|
"app.browse.server.world-fallback-name": {
|
||||||
|
"message": "Instance"
|
||||||
|
},
|
||||||
"app.creation-modal.installing-modpack.description": {
|
"app.creation-modal.installing-modpack.description": {
|
||||||
"message": "{fileName}"
|
"message": "{fileName}"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
|
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
|
||||||
import {
|
import {
|
||||||
|
BrowseInstallHeader,
|
||||||
BrowsePageLayout,
|
BrowsePageLayout,
|
||||||
BrowseSidebar,
|
BrowseSidebar,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
@@ -22,8 +23,9 @@ import {
|
|||||||
preferencesDiffer,
|
preferencesDiffer,
|
||||||
provideBrowseManager,
|
provideBrowseManager,
|
||||||
requestInstall,
|
requestInstall,
|
||||||
|
SelectedProjectsFloatingBar,
|
||||||
useBrowseSearch,
|
useBrowseSearch,
|
||||||
useDebugLogger,
|
useStickyObserver,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
@@ -56,18 +58,25 @@ import {
|
|||||||
} from '@/providers/setup/server-install-content'
|
} from '@/providers/setup/server-install-content'
|
||||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'Browse',
|
||||||
|
})
|
||||||
|
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
|
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
|
||||||
injectServerInstall()
|
injectServerInstall()
|
||||||
const { install: installVersion } = injectContentInstall()
|
const { install: installVersion } = injectContentInstall()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const debugLog = useDebugLogger('Browse')
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
|
||||||
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
|
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
|
||||||
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
|
const serverInstallContent = createServerInstallContent({
|
||||||
|
serverSetupModalRef,
|
||||||
|
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/browse/'),
|
||||||
|
})
|
||||||
provideServerInstallContent(serverInstallContent)
|
provideServerInstallContent(serverInstallContent)
|
||||||
const {
|
const {
|
||||||
serverIdQuery,
|
serverIdQuery,
|
||||||
@@ -77,6 +86,7 @@ const {
|
|||||||
isSetupServerContext,
|
isSetupServerContext,
|
||||||
effectiveServerWorldId,
|
effectiveServerWorldId,
|
||||||
serverContextServerData,
|
serverContextServerData,
|
||||||
|
serverContextWorldName,
|
||||||
serverContentProjectIds,
|
serverContentProjectIds,
|
||||||
queuedServerInstallProjectIds,
|
queuedServerInstallProjectIds,
|
||||||
queuedServerInstallCount,
|
queuedServerInstallCount,
|
||||||
@@ -103,8 +113,6 @@ const {
|
|||||||
handleServerModpackFlowCreate,
|
handleServerModpackFlowCreate,
|
||||||
markServerProjectInstalled,
|
markServerProjectInstalled,
|
||||||
} = serverInstallContent
|
} = serverInstallContent
|
||||||
|
|
||||||
debugLog('fetching tags (categories, loaders, gameVersions)')
|
|
||||||
const [categories, loaders, availableGameVersions] = await Promise.all([
|
const [categories, loaders, availableGameVersions] = await Promise.all([
|
||||||
get_categories()
|
get_categories()
|
||||||
.catch(handleError)
|
.catch(handleError)
|
||||||
@@ -182,22 +190,10 @@ watchServerContextChanges()
|
|||||||
await initInstanceContext()
|
await initInstanceContext()
|
||||||
|
|
||||||
async function initInstanceContext() {
|
async function initInstanceContext() {
|
||||||
debugLog('initInstanceContext', {
|
|
||||||
queryI: route.query.i,
|
|
||||||
queryAi: route.query.ai,
|
|
||||||
querySid: route.query.sid,
|
|
||||||
queryWid: route.query.wid,
|
|
||||||
queryFrom: route.query.from,
|
|
||||||
})
|
|
||||||
await initServerContext()
|
await initServerContext()
|
||||||
|
|
||||||
if (route.query.i) {
|
if (route.query.i) {
|
||||||
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
|
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
|
||||||
debugLog('instance loaded', {
|
|
||||||
name: instance.value?.name,
|
|
||||||
loader: instance.value?.loader,
|
|
||||||
gameVersion: instance.value?.game_version,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (route.query.from === 'worlds') {
|
if (route.query.from === 'worlds') {
|
||||||
get_profile_worlds(route.query.i as string)
|
get_profile_worlds(route.query.i as string)
|
||||||
@@ -205,34 +201,29 @@ async function initInstanceContext() {
|
|||||||
const serverProjectIds = worlds
|
const serverProjectIds = worlds
|
||||||
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
|
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
|
||||||
.map((w) => (w as { project_id: string }).project_id)
|
.map((w) => (w as { project_id: string }).project_id)
|
||||||
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
|
|
||||||
installedProjectIds.value = serverProjectIds
|
installedProjectIds.value = serverProjectIds
|
||||||
})
|
})
|
||||||
.catch(handleError)
|
.catch(handleError)
|
||||||
} else {
|
} else {
|
||||||
getInstalledProjectIds(route.query.i as string)
|
getInstalledProjectIds(route.query.i as string)
|
||||||
.then((ids) => {
|
.then((ids) => {
|
||||||
debugLog('installedProjectIds loaded', { count: ids?.length })
|
|
||||||
installedProjectIds.value = ids
|
installedProjectIds.value = ids
|
||||||
})
|
})
|
||||||
.catch(handleError)
|
.catch(handleError)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (instance.value?.linked_data?.project_id) {
|
if (instance.value?.linked_data?.project_id) {
|
||||||
debugLog('checking linked project for server status', instance.value.linked_data.project_id)
|
|
||||||
const projectV3 = await get_project_v3(
|
const projectV3 = await get_project_v3(
|
||||||
instance.value.linked_data.project_id,
|
instance.value.linked_data.project_id,
|
||||||
'must_revalidate',
|
'must_revalidate',
|
||||||
).catch(handleError)
|
).catch(handleError)
|
||||||
if (projectV3?.minecraft_server != null) {
|
if (projectV3?.minecraft_server != null) {
|
||||||
debugLog('instance is a server instance')
|
|
||||||
isServerInstance.value = true
|
isServerInstance.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (route.query.ai && !(route.params.projectType === 'modpack')) {
|
if (route.query.ai && !(route.params.projectType === 'modpack')) {
|
||||||
debugLog('setting instanceHideInstalled from query', route.query.ai)
|
|
||||||
instanceHideInstalled.value = route.query.ai === 'true'
|
instanceHideInstalled.value = route.query.ai === 'true'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,7 +274,7 @@ function syncHiddenServerContentProjectIds() {
|
|||||||
watch(
|
watch(
|
||||||
serverContentProjectIds,
|
serverContentProjectIds,
|
||||||
() => {
|
() => {
|
||||||
if (!hiddenServerContentProjectIdsInitialized.value) {
|
if (!hiddenServerContentProjectIdsInitialized.value || serverHideInstalled.value) {
|
||||||
syncHiddenServerContentProjectIds()
|
syncHiddenServerContentProjectIds()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -356,11 +347,9 @@ const {
|
|||||||
|
|
||||||
const offline = ref(!navigator.onLine)
|
const offline = ref(!navigator.onLine)
|
||||||
window.addEventListener('offline', () => {
|
window.addEventListener('offline', () => {
|
||||||
debugLog('went offline')
|
|
||||||
offline.value = true
|
offline.value = true
|
||||||
})
|
})
|
||||||
window.addEventListener('online', () => {
|
window.addEventListener('online', () => {
|
||||||
debugLog('went online')
|
|
||||||
offline.value = false
|
offline.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -405,6 +394,10 @@ const messages = defineMessages({
|
|||||||
id: 'app.browse.back-to-instance',
|
id: 'app.browse.back-to-instance',
|
||||||
defaultMessage: 'Back to instance',
|
defaultMessage: 'Back to instance',
|
||||||
},
|
},
|
||||||
|
worldFallbackName: {
|
||||||
|
id: 'app.browse.server.world-fallback-name',
|
||||||
|
defaultMessage: 'Instance',
|
||||||
|
},
|
||||||
serverInstanceContentWarning: {
|
serverInstanceContentWarning: {
|
||||||
id: 'app.browse.server-instance-content-warning',
|
id: 'app.browse.server-instance-content-warning',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
@@ -464,6 +457,9 @@ const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
|
|||||||
watch(
|
watch(
|
||||||
() => route.params.projectType as ProjectType,
|
() => route.params.projectType as ProjectType,
|
||||||
async (newType) => {
|
async (newType) => {
|
||||||
|
if (!browseRouteActive.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
if (isSetupServerContext.value) {
|
if (isSetupServerContext.value) {
|
||||||
enforceSetupModpackRoute(newType)
|
enforceSetupModpackRoute(newType)
|
||||||
if (newType !== 'modpack') return
|
if (newType !== 'modpack') return
|
||||||
@@ -471,11 +467,9 @@ watch(
|
|||||||
|
|
||||||
if (!newType || newType === projectType.value) return
|
if (!newType || newType === projectType.value) return
|
||||||
|
|
||||||
debugLog('projectType route param changed', { from: projectType.value, to: newType })
|
|
||||||
projectType.value = newType
|
projectType.value = newType
|
||||||
|
|
||||||
if (!route.query.i && instance.value) {
|
if (!route.query.i && instance.value) {
|
||||||
debugLog('instance context removed, resetting')
|
|
||||||
instance.value = null
|
instance.value = null
|
||||||
installedProjectIds.value = null
|
installedProjectIds.value = null
|
||||||
instanceHideInstalled.value = false
|
instanceHideInstalled.value = false
|
||||||
@@ -545,8 +539,9 @@ const selectableProjectTypes = computed(() => {
|
|||||||
const installContext = computed(() => {
|
const installContext = computed(() => {
|
||||||
if (isServerContext.value && serverContextServerData.value) {
|
if (isServerContext.value && serverContextServerData.value) {
|
||||||
return {
|
return {
|
||||||
name: serverContextServerData.value.name,
|
name: serverContextWorldName.value ?? formatMessage(messages.worldFallbackName),
|
||||||
loader: serverContextServerData.value.loader ?? '',
|
loader: serverContextServerData.value.loader ?? '',
|
||||||
|
loaderVersion: serverContextServerData.value.loader_version ?? '',
|
||||||
gameVersion: serverContextServerData.value.mc_version ?? '',
|
gameVersion: serverContextServerData.value.mc_version ?? '',
|
||||||
serverId: serverIdQuery.value,
|
serverId: serverIdQuery.value,
|
||||||
upstream: serverContextServerData.value.upstream,
|
upstream: serverContextServerData.value.upstream,
|
||||||
@@ -586,6 +581,12 @@ const installContext = computed(() => {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||||
|
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||||
|
stickyInstallHeaderRef,
|
||||||
|
'BrowseInstallHeader',
|
||||||
|
)
|
||||||
|
|
||||||
const installingProjectIds = ref<Set<string>>(new Set())
|
const installingProjectIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
function setProjectInstalling(projectId: string, installing: boolean) {
|
function setProjectInstalling(projectId: string, installing: boolean) {
|
||||||
@@ -682,11 +683,10 @@ function getCardActions(
|
|||||||
installed?: boolean
|
installed?: boolean
|
||||||
installing?: boolean
|
installing?: boolean
|
||||||
}
|
}
|
||||||
const isInstalled =
|
const isInstalled = isServerContext.value
|
||||||
projectResult.installed ||
|
? serverContentProjectIds.value.has(projectResult.project_id || '') ||
|
||||||
allInstalledIds.value.has(projectResult.project_id || '') ||
|
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
|
||||||
serverContentProjectIds.value.has(projectResult.project_id || '') ||
|
: projectResult.installed || allInstalledIds.value.has(projectResult.project_id || '')
|
||||||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
|
|
||||||
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
|
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -838,7 +838,6 @@ function onSearchResultInstalled(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function search(requestParams: string) {
|
async function search(requestParams: string) {
|
||||||
debugLog('searching v3', requestParams)
|
|
||||||
const isServer = projectType.value === 'server'
|
const isServer = projectType.value === 'server'
|
||||||
|
|
||||||
const rawResults = await queryClient.fetchQuery({
|
const rawResults = await queryClient.fetchQuery({
|
||||||
@@ -920,6 +919,7 @@ const lockedFilterMessages = computed(() => ({
|
|||||||
const searchState = useBrowseSearch({
|
const searchState = useBrowseSearch({
|
||||||
projectType,
|
projectType,
|
||||||
tags,
|
tags,
|
||||||
|
active: browseRouteActive,
|
||||||
providedFilters: combinedProvidedFilters,
|
providedFilters: combinedProvidedFilters,
|
||||||
search,
|
search,
|
||||||
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
|
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
|
||||||
@@ -967,7 +967,12 @@ if (instance.value?.game_version) {
|
|||||||
await searchState.refreshSearch()
|
await searchState.refreshSearch()
|
||||||
|
|
||||||
function getProjectBrowseQuery() {
|
function getProjectBrowseQuery() {
|
||||||
if (!installContext.value) return undefined
|
if (!browseRouteActive.value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
if (!installContext.value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...route.query,
|
...route.query,
|
||||||
b: route.fullPath,
|
b: route.fullPath,
|
||||||
@@ -1031,6 +1036,17 @@ provideBrowseManager({
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-3 p-6">
|
<div class="flex flex-col gap-3 p-6">
|
||||||
|
<div
|
||||||
|
v-if="installContext"
|
||||||
|
ref="stickyInstallHeaderRef"
|
||||||
|
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid border-divider bg-surface-1 px-6 pt-6"
|
||||||
|
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
||||||
|
>
|
||||||
|
<BrowseInstallHeader bottom-padding />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SelectedProjectsFloatingBar v-if="installContext" />
|
||||||
|
|
||||||
<BrowsePageLayout>
|
<BrowsePageLayout>
|
||||||
<template #after>
|
<template #after>
|
||||||
<ContextMenu ref="contextMenuRef" @option-clicked="handleOptionsClick">
|
<ContextMenu ref="contextMenuRef" @option-clicked="handleOptionsClick">
|
||||||
@@ -1057,7 +1073,8 @@ provideBrowseManager({
|
|||||||
@browse-modpacks="() => {}"
|
@browse-modpacks="() => {}"
|
||||||
@create="handleServerModpackFlowCreate"
|
@create="handleServerModpackFlowCreate"
|
||||||
/>
|
/>
|
||||||
<Teleport to="#sidebar-teleport-target">
|
|
||||||
|
<Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
|
||||||
<BrowseSidebar />
|
<BrowseSidebar />
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const queryClient = useQueryClient()
|
|||||||
if (worldId.value) {
|
if (worldId.value) {
|
||||||
try {
|
try {
|
||||||
await queryClient.ensureQueryData({
|
await queryClient.ensureQueryData({
|
||||||
queryKey: ['backups', 'list', serverId],
|
queryKey: ['backups', 'list', serverId, worldId.value],
|
||||||
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const queryClient = useQueryClient()
|
|||||||
if (worldId.value) {
|
if (worldId.value) {
|
||||||
try {
|
try {
|
||||||
await queryClient.ensureQueryData({
|
await queryClient.ensureQueryData({
|
||||||
queryKey: ['content', 'list', 'v1', serverId],
|
queryKey: ['content', 'list', 'v1', serverId, worldId.value],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
:server-id="serverId"
|
:server-id="serverId"
|
||||||
:reload-page="() => router.go(0)"
|
:reload-page="() => router.go(0)"
|
||||||
:resolve-viewer="resolveViewer"
|
:resolve-viewer="resolveViewer"
|
||||||
:show-copy-id-action="themeStore.devMode"
|
|
||||||
:auth-user="authUser"
|
:auth-user="authUser"
|
||||||
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
|
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
|
||||||
:navigate-to-servers="() => router.push('/hosting/manage')"
|
:navigate-to-servers="() => router.push('/hosting/manage')"
|
||||||
@@ -26,7 +25,7 @@
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template #default="{ onReinstall, onReinstallFailed }">
|
<template #default="{ onReinstall, onReinstallFailed }">
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }" :route="managedRoute">
|
||||||
<template v-if="Component">
|
<template v-if="Component">
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<component
|
<component
|
||||||
@@ -47,26 +46,37 @@ import type { Archon, Labrinth } from '@modrinth/api-client'
|
|||||||
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
|
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import { computed, watch } from 'vue'
|
import { computed, ref, shallowRef, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { get_user } from '@/helpers/cache'
|
import { get_user } from '@/helpers/cache'
|
||||||
import { get as getCreds } from '@/helpers/mr_auth'
|
import { get as getCreds } from '@/helpers/mr_auth'
|
||||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||||
import { useTheming } from '@/store/theme'
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = injectAuth()
|
const auth = injectAuth()
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const themeStore = useTheming()
|
|
||||||
const breadcrumbs = useBreadcrumbs()
|
const breadcrumbs = useBreadcrumbs()
|
||||||
|
|
||||||
const serverId = computed(() => {
|
const managedRoute = shallowRef(router.currentRoute.value)
|
||||||
const rawId = route.params.id
|
const serverId = ref(getRouteParam(managedRoute.value.params.id) ?? '')
|
||||||
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
|
|
||||||
})
|
watch(
|
||||||
|
router.currentRoute,
|
||||||
|
(nextRoute) => {
|
||||||
|
if (!nextRoute.path.startsWith('/hosting/manage/')) return
|
||||||
|
managedRoute.value = nextRoute
|
||||||
|
serverId.value = getRouteParam(nextRoute.params.id) ?? ''
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function getRouteParam(param: string | string[] | undefined): string | null {
|
||||||
|
if (Array.isArray(param)) return param[0] ?? null
|
||||||
|
return param ?? null
|
||||||
|
}
|
||||||
|
|
||||||
if (serverId.value) {
|
if (serverId.value) {
|
||||||
try {
|
try {
|
||||||
@@ -93,7 +103,7 @@ watch(
|
|||||||
breadcrumbs.setName('Server', server.name)
|
breadcrumbs.setName('Server', server.name)
|
||||||
breadcrumbs.setContext({
|
breadcrumbs.setContext({
|
||||||
name: server.name,
|
name: server.name,
|
||||||
link: `/hosting/manage/${serverId.value}/content`,
|
link: `/hosting/manage/${serverId.value}/instances`,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<template>
|
||||||
|
<ServersManageInstanceRootLayout>
|
||||||
|
<RouterView v-slot="{ Component }">
|
||||||
|
<template v-if="Component">
|
||||||
|
<Suspense>
|
||||||
|
<component :is="Component" />
|
||||||
|
</Suspense>
|
||||||
|
</template>
|
||||||
|
</RouterView>
|
||||||
|
</ServersManageInstanceRootLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ServersManageInstanceRootLayout } from '@modrinth/ui'
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ServersManageInstancesPage } from '@modrinth/ui'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ServersManageInstancesPage />
|
||||||
|
</template>
|
||||||
@@ -2,6 +2,8 @@ import Backups from './Backups.vue'
|
|||||||
import Content from './Content.vue'
|
import Content from './Content.vue'
|
||||||
import Files from './Files.vue'
|
import Files from './Files.vue'
|
||||||
import Index from './Index.vue'
|
import Index from './Index.vue'
|
||||||
|
import Instance from './Instance.vue'
|
||||||
|
import Instances from './Instances.vue'
|
||||||
import Overview from './Overview.vue'
|
import Overview from './Overview.vue'
|
||||||
|
|
||||||
export { Backups, Content, Files, Index, Overview }
|
export { Backups, Content, Files, Index, Instance, Instances, Overview }
|
||||||
|
|||||||
@@ -13,200 +13,57 @@
|
|||||||
@unlinked="fetchInstance"
|
@unlinked="fetchInstance"
|
||||||
/>
|
/>
|
||||||
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
|
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
|
||||||
<ContentPageHeader>
|
<PageHeader
|
||||||
<template #icon>
|
:header="instance.name"
|
||||||
<Avatar
|
:leading="instanceHeaderLeading"
|
||||||
:src="icon ? icon : undefined"
|
:metadata="instanceHeaderMetadata"
|
||||||
:alt="instance.name"
|
:actions="instanceHeaderActions"
|
||||||
size="64px"
|
>
|
||||||
:tint-by="instance.path"
|
<template #metadata-server-details>
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
<template #title>
|
|
||||||
{{ instance.name }}
|
|
||||||
</template>
|
|
||||||
<template #stats>
|
|
||||||
<div class="flex items-center flex-wrap gap-2">
|
<div class="flex items-center flex-wrap gap-2">
|
||||||
<template v-if="!isServerInstance">
|
<template v-if="loadingServerPing">
|
||||||
<div class="flex items-center gap-2 capitalize font-medium">
|
<ServerOnlinePlayers
|
||||||
{{ instance.loader }} {{ instance.game_version }}
|
v-if="playersOnline !== undefined"
|
||||||
</div>
|
:online="playersOnline"
|
||||||
|
:status-online="statusOnline"
|
||||||
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
hide-label
|
||||||
|
/>
|
||||||
<div class="flex items-center gap-2 font-medium">
|
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
|
||||||
<template v-if="timePlayed > 0">
|
|
||||||
{{ timePlayedHumanized }}
|
|
||||||
</template>
|
|
||||||
<template v-else> Never played </template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<template v-if="loadingServerPing">
|
|
||||||
<ServerOnlinePlayers
|
|
||||||
v-if="playersOnline !== undefined"
|
|
||||||
:online="playersOnline"
|
|
||||||
:status-online="statusOnline"
|
|
||||||
hide-label
|
|
||||||
/>
|
|
||||||
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
(playersOnline !== undefined || recentPlays !== undefined) &&
|
|
||||||
(minecraftServer?.region || ping)
|
|
||||||
"
|
|
||||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
|
||||||
></div>
|
|
||||||
<ServerPing v-if="ping" :ping="ping" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="minecraftServer?.region || ping"
|
v-if="
|
||||||
|
(playersOnline !== undefined || recentPlays !== undefined) &&
|
||||||
|
(minecraftServer?.region || ping)
|
||||||
|
"
|
||||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||||
></div>
|
></div>
|
||||||
|
<ServerPing v-if="ping" :ping="ping" />
|
||||||
<div
|
|
||||||
v-if="linkedProjectV3"
|
|
||||||
class="flex gap-1.5 items-center font-medium text-primary"
|
|
||||||
>
|
|
||||||
Linked to
|
|
||||||
<Avatar
|
|
||||||
:src="linkedProjectV3.icon_url"
|
|
||||||
:alt="linkedProjectV3.name"
|
|
||||||
:tint-by="instance.path"
|
|
||||||
size="24px"
|
|
||||||
/>
|
|
||||||
<router-link
|
|
||||||
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
|
||||||
class="hover:underline text-primary truncate"
|
|
||||||
>
|
|
||||||
{{ linkedProjectV3.name }}
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="
|
|
||||||
[
|
|
||||||
'installing',
|
|
||||||
'pack_installing',
|
|
||||||
'pack_installed',
|
|
||||||
'not_installed',
|
|
||||||
'minecraft_installing',
|
|
||||||
].includes(instance.install_stage)
|
|
||||||
"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<button disabled>Installing...</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="instance.install_stage !== 'installed'"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<button @click="repairInstance()">
|
|
||||||
<DownloadIcon />
|
|
||||||
Repair
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else-if="playing === true" color="red" size="large">
|
|
||||||
<button :disabled="stopping" @click="stopInstance('InstancePage')">
|
|
||||||
<StopCircleIcon />
|
|
||||||
{{ stopping ? 'Stopping...' : 'Stop' }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else-if="playing === false && loading === false && !isServerInstance"
|
|
||||||
color="brand"
|
|
||||||
size="large"
|
|
||||||
>
|
|
||||||
<button @click="startInstance('InstancePage')">
|
|
||||||
<PlayIcon />
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<div
|
|
||||||
v-else-if="playing === false && loading === false && isServerInstance"
|
|
||||||
class="joined-buttons"
|
|
||||||
>
|
|
||||||
<ButtonStyled color="brand" size="large">
|
|
||||||
<button @click="handlePlayServer()">
|
|
||||||
<PlayIcon />
|
|
||||||
Play
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled color="brand" size="large">
|
|
||||||
<OverflowMenu
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'join_server',
|
|
||||||
action: () => handlePlayServer(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'launch_instance',
|
|
||||||
action: () => startInstance('InstancePage'),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
<div class="w-0 text-xl relative top-0.5 right-2.5">
|
|
||||||
<DropdownIcon />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template #join_server>
|
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
|
||||||
<PlayIcon />
|
|
||||||
Join server
|
<div
|
||||||
</template>
|
v-if="minecraftServer?.region || ping"
|
||||||
<template #launch_instance>
|
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||||
<PlayIcon />
|
></div>
|
||||||
Launch instance
|
|
||||||
</template>
|
<div v-if="linkedProjectV3" class="flex gap-1.5 items-center font-medium text-primary">
|
||||||
</OverflowMenu>
|
Linked to
|
||||||
</ButtonStyled>
|
<Avatar
|
||||||
</div>
|
:src="linkedProjectV3.icon_url"
|
||||||
<ButtonStyled
|
:alt="linkedProjectV3.name"
|
||||||
v-else-if="loading === true && playing === false"
|
:tint-by="instance.path"
|
||||||
color="brand"
|
size="24px"
|
||||||
size="large"
|
/>
|
||||||
>
|
<router-link
|
||||||
<button disabled>Starting...</button>
|
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
||||||
</ButtonStyled>
|
class="hover:underline text-primary truncate"
|
||||||
<ButtonStyled circular size="large">
|
|
||||||
<button v-tooltip="'Instance settings'" @click="settingsModal?.show()">
|
|
||||||
<SettingsIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="transparent" circular size="large">
|
|
||||||
<OverflowMenu
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'open-folder',
|
|
||||||
action: () => {
|
|
||||||
if (instance) showProfileInFolder(instance.path)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'export-mrpack',
|
|
||||||
action: () => exportModal?.show(),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon />
|
{{ linkedProjectV3.name }}
|
||||||
<template #share-instance> <UserPlusIcon /> Share instance </template>
|
</router-link>
|
||||||
<template #host-a-server> <ServerIcon /> Create a server </template>
|
</div>
|
||||||
<template #open-folder> <FolderOpenIcon /> Open folder </template>
|
|
||||||
<template #export-mrpack> <PackageIcon /> Export modpack </template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ContentPageHeader>
|
</PageHeader>
|
||||||
</div>
|
</div>
|
||||||
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
|
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
|
||||||
<NavTabs :links="tabs" />
|
<NavTabs :links="tabs" />
|
||||||
@@ -270,50 +127,54 @@ import {
|
|||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
ClipboardCopyIcon,
|
ClipboardCopyIcon,
|
||||||
DownloadIcon,
|
DownloadIcon,
|
||||||
DropdownIcon,
|
|
||||||
EditIcon,
|
EditIcon,
|
||||||
ExternalIcon,
|
ExternalIcon,
|
||||||
EyeIcon,
|
EyeIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
|
TagCategoryGamepad2Icon as Gamepad2Icon,
|
||||||
GlobeIcon,
|
GlobeIcon,
|
||||||
HashIcon,
|
HashIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
PackageIcon,
|
PackageIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
ServerIcon,
|
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
StopCircleIcon,
|
StopCircleIcon,
|
||||||
TerminalSquareIcon,
|
TerminalSquareIcon,
|
||||||
|
TimerIcon,
|
||||||
UpdatedIcon,
|
UpdatedIcon,
|
||||||
UserPlusIcon,
|
|
||||||
XIcon,
|
XIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
ButtonStyled,
|
formatLoaderLabel,
|
||||||
ContentPageHeader,
|
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
NavTabs,
|
NavTabs,
|
||||||
OverflowMenu,
|
PageHeader,
|
||||||
|
LoaderIcon as ServerLoaderIcon,
|
||||||
ServerOnlinePlayers,
|
ServerOnlinePlayers,
|
||||||
ServerPing,
|
ServerPing,
|
||||||
ServerRecentPlays,
|
ServerRecentPlays,
|
||||||
ServerRegion,
|
ServerRegion,
|
||||||
useLoadingBarToken,
|
useLoadingBarToken,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import type { Loaders } from '@modrinth/utils'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import duration from 'dayjs/plugin/duration'
|
import duration from 'dayjs/plugin/duration'
|
||||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { onBeforeRouteUpdate, useRoute, useRouter, type LocationQuery } from 'vue-router'
|
||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||||
|
import {
|
||||||
|
fetchCachedServerStatus,
|
||||||
|
getFreshCachedServerStatus,
|
||||||
|
} from '@/composables/instances/use-server-status-query'
|
||||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||||
import { trackEvent } from '@/helpers/analytics'
|
import { trackEvent } from '@/helpers/analytics'
|
||||||
import { get_project_v3 } from '@/helpers/cache.js'
|
import { get_project_v3 } from '@/helpers/cache.js'
|
||||||
@@ -323,7 +184,7 @@ import { get_by_profile_path } from '@/helpers/process'
|
|||||||
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
|
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
|
||||||
import type { GameInstance } from '@/helpers/types'
|
import type { GameInstance } from '@/helpers/types'
|
||||||
import { showProfileInFolder } from '@/helpers/utils.js'
|
import { showProfileInFolder } from '@/helpers/utils.js'
|
||||||
import { get_server_status, refreshWorlds } from '@/helpers/worlds'
|
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
|
||||||
import { injectServerInstall } from '@/providers/server-install'
|
import { injectServerInstall } from '@/providers/server-install'
|
||||||
import { handleSevereError } from '@/store/error.js'
|
import { handleSevereError } from '@/store/error.js'
|
||||||
import { useBreadcrumbs } from '@/store/state'
|
import { useBreadcrumbs } from '@/store/state'
|
||||||
@@ -365,32 +226,79 @@ const selected = ref<unknown[]>([])
|
|||||||
|
|
||||||
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
|
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
|
||||||
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
|
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
|
||||||
const statusOnline = computed(() => !!javaServerPingData.value)
|
const liveServerStatusOnline = ref(false)
|
||||||
|
const statusOnline = computed(() => liveServerStatusOnline.value || !!javaServerPingData.value)
|
||||||
const recentPlays = computed(
|
const recentPlays = computed(
|
||||||
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
|
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
|
||||||
)
|
)
|
||||||
const playersOnline = ref<number | undefined>(undefined)
|
const playersOnline = ref<number | undefined>(undefined)
|
||||||
const ping = ref<number | undefined>(undefined)
|
const ping = ref<number | undefined>(undefined)
|
||||||
const loadingServerPing = ref(false)
|
const loadingServerPing = ref(false)
|
||||||
|
const activeProfilePath = ref<string>()
|
||||||
|
let fetchInstanceRequestId = 0
|
||||||
|
|
||||||
function isContentSubpageRoute(routeName = route.name) {
|
type InstanceRouteContext = {
|
||||||
|
name: unknown
|
||||||
|
path: string
|
||||||
|
query: LocationQuery
|
||||||
|
}
|
||||||
|
|
||||||
|
type InstancePageData = {
|
||||||
|
profilePath: string
|
||||||
|
instance?: GameInstance
|
||||||
|
linkedProjectV3?: Labrinth.Projects.v3.Project
|
||||||
|
isServerInstance: boolean
|
||||||
|
preloadedContent: InstanceContentData | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyServerStatus(status: ServerStatus) {
|
||||||
|
playersOnline.value = status.players?.online
|
||||||
|
ping.value = status.ping
|
||||||
|
liveServerStatusOnline.value = true
|
||||||
|
loadingServerPing.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function isContentSubpageRoute(routeName: unknown = route.name) {
|
||||||
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
|
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchInstance() {
|
function resetServerStatus() {
|
||||||
isServerInstance.value = false
|
|
||||||
linkedProjectV3.value = undefined
|
|
||||||
preloadedContent.value = null
|
|
||||||
ping.value = undefined
|
ping.value = undefined
|
||||||
playersOnline.value = undefined
|
playersOnline.value = undefined
|
||||||
|
liveServerStatusOnline.value = false
|
||||||
loadingServerPing.value = false
|
loadingServerPing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
const nextInstance = await get(route.params.id as string).catch(handleError)
|
function isCurrentInstanceRequest(requestId: number, profilePath: string) {
|
||||||
|
return (
|
||||||
|
requestId === fetchInstanceRequestId &&
|
||||||
|
route.path.startsWith('/instance') &&
|
||||||
|
route.params.id === profilePath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInstanceBreadcrumbs(nextInstance: GameInstance, routeContext: InstanceRouteContext) {
|
||||||
|
breadcrumbs.setName(
|
||||||
|
'Instance',
|
||||||
|
nextInstance.name.length > 40 ? nextInstance.name.substring(0, 40) + '...' : nextInstance.name,
|
||||||
|
)
|
||||||
|
breadcrumbs.setContext({
|
||||||
|
name: nextInstance.name,
|
||||||
|
link: routeContext.path,
|
||||||
|
query: routeContext.query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadInstancePageData(
|
||||||
|
profilePath: string,
|
||||||
|
routeName: unknown = route.name,
|
||||||
|
): Promise<InstancePageData> {
|
||||||
|
const nextInstance = await get(profilePath).catch(handleError)
|
||||||
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
||||||
let nextIsServerInstance = false
|
let nextIsServerInstance = false
|
||||||
|
|
||||||
const contentPreloadPromise =
|
const contentPreloadPromise =
|
||||||
nextInstance && isContentSubpageRoute()
|
nextInstance && isContentSubpageRoute(routeName)
|
||||||
? loadInstanceContentData(nextInstance.path, undefined, handleError)
|
? loadInstanceContentData(nextInstance.path, undefined, handleError)
|
||||||
: Promise.resolve(null)
|
: Promise.resolve(null)
|
||||||
|
|
||||||
@@ -411,59 +319,112 @@ async function fetchInstance() {
|
|||||||
|
|
||||||
const nextPreloadedContent = await contentPreloadPromise
|
const nextPreloadedContent = await contentPreloadPromise
|
||||||
|
|
||||||
instance.value = nextInstance ?? undefined
|
return {
|
||||||
linkedProjectV3.value = nextLinkedProjectV3
|
profilePath,
|
||||||
isServerInstance.value = nextIsServerInstance
|
instance: nextInstance ?? undefined,
|
||||||
preloadedContent.value = nextPreloadedContent
|
linkedProjectV3: nextLinkedProjectV3,
|
||||||
|
isServerInstance: nextIsServerInstance,
|
||||||
|
preloadedContent: nextPreloadedContent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fetchDeferredData()
|
function applyInstancePageData(data: InstancePageData, routeContext: InstanceRouteContext) {
|
||||||
|
activeProfilePath.value = data.profilePath
|
||||||
|
resetServerStatus()
|
||||||
|
playing.value = false
|
||||||
|
|
||||||
if (nextInstance) {
|
instance.value = data.instance
|
||||||
|
linkedProjectV3.value = data.linkedProjectV3
|
||||||
|
isServerInstance.value = data.isServerInstance
|
||||||
|
preloadedContent.value = data.preloadedContent
|
||||||
|
|
||||||
|
if (data.instance) {
|
||||||
|
setInstanceBreadcrumbs(data.instance, routeContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchDeferredData(data.profilePath)
|
||||||
|
|
||||||
|
if (data.instance) {
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ['worlds', nextInstance.path],
|
queryKey: ['worlds', data.instance.path],
|
||||||
queryFn: () => refreshWorlds(nextInstance.path),
|
queryFn: () => refreshWorlds(data.instance!.path),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchDeferredData() {
|
async function fetchInstance(profilePath = route.params.id as string) {
|
||||||
|
const requestId = ++fetchInstanceRequestId
|
||||||
|
const data = await loadInstancePageData(profilePath)
|
||||||
|
if (!isCurrentInstanceRequest(requestId, profilePath)) return
|
||||||
|
|
||||||
|
applyInstancePageData(data, {
|
||||||
|
name: route.name,
|
||||||
|
path: route.path,
|
||||||
|
query: route.query,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchDeferredData(profilePath: string) {
|
||||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||||
if (isServerInstance.value && serverAddress) {
|
if (isServerInstance.value && serverAddress) {
|
||||||
get_server_status(serverAddress)
|
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||||
|
if (cachedStatus) {
|
||||||
|
applyServerStatus(cachedStatus)
|
||||||
|
} else {
|
||||||
|
playersOnline.value = undefined
|
||||||
|
ping.value = undefined
|
||||||
|
loadingServerPing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCachedServerStatus(queryClient, serverAddress)
|
||||||
.then((status) => {
|
.then((status) => {
|
||||||
playersOnline.value = status.players?.online
|
if (
|
||||||
ping.value = status.ping
|
activeProfilePath.value !== profilePath ||
|
||||||
|
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
|
||||||
|
)
|
||||||
|
return
|
||||||
|
applyServerStatus(status)
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
|
if (activeProfilePath.value !== profilePath) return
|
||||||
loadingServerPing.value = true
|
loadingServerPing.value = true
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
loadingServerPing.value = true
|
loadingServerPing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePlayState()
|
updatePlayState(profilePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePlayState() {
|
async function updatePlayState(profilePath = route.params.id as string) {
|
||||||
if (!route.params.id) return
|
if (!profilePath) return
|
||||||
const runningProcesses = await get_by_profile_path(route.params.id as string).catch(handleError)
|
const runningProcesses = await get_by_profile_path(profilePath).catch(handleError)
|
||||||
|
if (activeProfilePath.value !== profilePath) return
|
||||||
|
|
||||||
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
|
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchInstance()
|
await fetchInstance(route.params.id as string)
|
||||||
watch(
|
|
||||||
() => route.params.id,
|
onBeforeRouteUpdate(async (to) => {
|
||||||
async () => {
|
if (!to.path.startsWith('/instance')) return
|
||||||
if (route.params.id && route.path.startsWith('/instance')) {
|
const profilePath = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id
|
||||||
await fetchInstance()
|
if (typeof profilePath !== 'string') return false
|
||||||
}
|
|
||||||
},
|
const requestId = ++fetchInstanceRequestId
|
||||||
)
|
const data = await loadInstancePageData(profilePath, to.name)
|
||||||
|
if (requestId !== fetchInstanceRequestId) return false
|
||||||
|
|
||||||
|
applyInstancePageData(data, {
|
||||||
|
name: to.name,
|
||||||
|
path: to.path,
|
||||||
|
query: to.query,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
|
const basePath = computed(() => `/instance/${encodeURIComponent(route.params.id as string)}`)
|
||||||
|
|
||||||
@@ -506,20 +467,6 @@ const tabs = computed(() => [
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
if (instance.value) {
|
|
||||||
breadcrumbs.setName(
|
|
||||||
'Instance',
|
|
||||||
instance.value.name.length > 40
|
|
||||||
? instance.value.name.substring(0, 40) + '...'
|
|
||||||
: instance.value.name,
|
|
||||||
)
|
|
||||||
breadcrumbs.setContext({
|
|
||||||
name: instance.value.name,
|
|
||||||
link: route.path,
|
|
||||||
query: route.query,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||||
|
|
||||||
const startInstance = async (context: string) => {
|
const startInstance = async (context: string) => {
|
||||||
@@ -679,6 +626,16 @@ const timePlayed = computed(() => {
|
|||||||
: 0
|
: 0
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const loaderDisplayName = computed(() =>
|
||||||
|
instance.value ? (formatLoaderLabel(instance.value.loader) as Loaders) : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const loaderLabel = computed(() =>
|
||||||
|
instance.value && loaderDisplayName.value
|
||||||
|
? [loaderDisplayName.value, instance.value.loader_version].filter(Boolean).join(' ')
|
||||||
|
: '',
|
||||||
|
)
|
||||||
|
|
||||||
const timePlayedHumanized = computed(() => {
|
const timePlayedHumanized = computed(() => {
|
||||||
const duration = dayjs.duration(timePlayed.value, 'seconds')
|
const duration = dayjs.duration(timePlayed.value, 'seconds')
|
||||||
const hours = Math.floor(duration.asHours())
|
const hours = Math.floor(duration.asHours())
|
||||||
@@ -695,6 +652,183 @@ const timePlayedHumanized = computed(() => {
|
|||||||
return seconds + ' second' + (seconds > 1 ? 's' : '')
|
return seconds + ' second' + (seconds > 1 ? 's' : '')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const playtimeLabel = computed(() =>
|
||||||
|
timePlayed.value > 0 ? timePlayedHumanized.value : 'Never played',
|
||||||
|
)
|
||||||
|
|
||||||
|
const instanceHeaderLeading = computed(() => ({
|
||||||
|
type: 'avatar' as const,
|
||||||
|
src: icon.value ? icon.value : undefined,
|
||||||
|
alt: instance.value?.name,
|
||||||
|
avatarSize: '64px',
|
||||||
|
tintBy: instance.value?.path,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const instanceHeaderMetadata = computed(() => {
|
||||||
|
if (!instance.value) return []
|
||||||
|
if (isServerInstance.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'server-details',
|
||||||
|
type: 'custom' as const,
|
||||||
|
class: 'contents',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'game-version',
|
||||||
|
label: instance.value.game_version,
|
||||||
|
icon: Gamepad2Icon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loader',
|
||||||
|
label: loaderLabel.value,
|
||||||
|
icon: ServerLoaderIcon,
|
||||||
|
iconProps: {
|
||||||
|
loader: loaderDisplayName.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'playtime',
|
||||||
|
label: playtimeLabel.value,
|
||||||
|
icon: TimerIcon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const installingStages = [
|
||||||
|
'installing',
|
||||||
|
'pack_installing',
|
||||||
|
'pack_installed',
|
||||||
|
'not_installed',
|
||||||
|
'minecraft_installing',
|
||||||
|
]
|
||||||
|
|
||||||
|
const primaryInstanceAction = computed(() => {
|
||||||
|
if (!instance.value) return null
|
||||||
|
|
||||||
|
if (installingStages.includes(instance.value.install_stage)) {
|
||||||
|
return {
|
||||||
|
id: 'installing',
|
||||||
|
label: 'Installing...',
|
||||||
|
color: 'brand' as const,
|
||||||
|
disabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (instance.value.install_stage !== 'installed') {
|
||||||
|
return {
|
||||||
|
id: 'repair',
|
||||||
|
label: 'Repair',
|
||||||
|
icon: DownloadIcon,
|
||||||
|
color: 'brand' as const,
|
||||||
|
onClick: () => {
|
||||||
|
void repairInstance()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing.value === true) {
|
||||||
|
return {
|
||||||
|
id: 'stop',
|
||||||
|
label: stopping.value ? 'Stopping...' : 'Stop',
|
||||||
|
icon: StopCircleIcon,
|
||||||
|
color: 'red' as const,
|
||||||
|
disabled: stopping.value,
|
||||||
|
onClick: () => {
|
||||||
|
void stopInstance('InstancePage')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing.value === false && loading.value === false && !isServerInstance.value) {
|
||||||
|
return {
|
||||||
|
id: 'play',
|
||||||
|
label: 'Play',
|
||||||
|
icon: PlayIcon,
|
||||||
|
color: 'brand' as const,
|
||||||
|
onClick: () => {
|
||||||
|
void startInstance('InstancePage')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (playing.value === false && loading.value === false && isServerInstance.value) {
|
||||||
|
return {
|
||||||
|
id: 'play',
|
||||||
|
label: 'Play',
|
||||||
|
color: 'brand' as const,
|
||||||
|
joinedActions: [
|
||||||
|
{
|
||||||
|
id: 'join_server',
|
||||||
|
label: 'Play',
|
||||||
|
icon: PlayIcon,
|
||||||
|
action: () => {
|
||||||
|
void handlePlayServer()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'launch_instance',
|
||||||
|
label: 'Launch instance',
|
||||||
|
icon: PlayIcon,
|
||||||
|
action: () => {
|
||||||
|
void startInstance('InstancePage')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading.value === true && playing.value === false) {
|
||||||
|
return {
|
||||||
|
id: 'starting',
|
||||||
|
label: 'Starting...',
|
||||||
|
color: 'brand' as const,
|
||||||
|
disabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const instanceHeaderActions = computed(() => [
|
||||||
|
...(primaryInstanceAction.value ? [primaryInstanceAction.value] : []),
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Instance settings',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: 'Instance settings',
|
||||||
|
onClick: () => settingsModal.value?.show(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'more',
|
||||||
|
label: 'More actions',
|
||||||
|
icon: MoreVerticalIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
type: 'transparent' as const,
|
||||||
|
tooltip: 'More actions',
|
||||||
|
menuActions: [
|
||||||
|
{
|
||||||
|
id: 'open-folder',
|
||||||
|
label: 'Open folder',
|
||||||
|
icon: FolderOpenIcon,
|
||||||
|
action: () => {
|
||||||
|
if (instance.value) void showProfileInFolder(instance.value.path)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'export-mrpack',
|
||||||
|
label: 'Export modpack',
|
||||||
|
icon: PackageIcon,
|
||||||
|
action: () => exportModal.value?.show(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
unlistenProcesses()
|
unlistenProcesses()
|
||||||
unlistenProfiles()
|
unlistenProfiles()
|
||||||
|
|||||||
@@ -47,9 +47,9 @@
|
|||||||
<div class="flex flex-col gap-4 p-6">
|
<div class="flex flex-col gap-4 p-6">
|
||||||
<div
|
<div
|
||||||
v-if="projectInstallContext"
|
v-if="projectInstallContext"
|
||||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
|
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid border-divider bg-surface-1 px-6 pt-6"
|
||||||
>
|
>
|
||||||
<BrowseInstallHeader :install-context="projectInstallContext" />
|
<BrowseInstallHeader :install-context="projectInstallContext" bottom-padding />
|
||||||
</div>
|
</div>
|
||||||
<InstanceIndicator v-if="instance && !projectInstallContext" :instance="instance" />
|
<InstanceIndicator v-if="instance && !projectInstallContext" :instance="instance" />
|
||||||
<template v-if="data">
|
<template v-if="data">
|
||||||
@@ -64,121 +64,9 @@
|
|||||||
:project="data"
|
:project="data"
|
||||||
:project-v3="projectV3"
|
:project-v3="projectV3"
|
||||||
:ping="serverPing"
|
:ping="serverPing"
|
||||||
|
:actions="projectHeaderActions"
|
||||||
@contextmenu.prevent.stop="handleRightClick"
|
@contextmenu.prevent.stop="handleRightClick"
|
||||||
>
|
/>
|
||||||
<template v-if="isServerProject" #actions>
|
|
||||||
<ButtonStyled v-if="serverPlaying" size="large" color="red">
|
|
||||||
<button @click="handleStopServer">
|
|
||||||
<StopCircleIcon />
|
|
||||||
{{ formatMessage(commonMessages.stopButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-else size="large" color="brand">
|
|
||||||
<button
|
|
||||||
:disabled="data && installingServerProjects.includes(data.id)"
|
|
||||||
@click="handleClickPlay"
|
|
||||||
>
|
|
||||||
<PlayIcon />
|
|
||||||
{{
|
|
||||||
data && installingServerProjects.includes(data.id)
|
|
||||||
? formatMessage(commonMessages.installingLabel)
|
|
||||||
: formatMessage(commonMessages.playButton)
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled size="large" circular>
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(commonMessages.addServerToInstanceButton)"
|
|
||||||
@click="handleAddServerToInstance"
|
|
||||||
>
|
|
||||||
<PlusIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled size="large" circular type="transparent">
|
|
||||||
<OverflowMenu
|
|
||||||
:tooltip="`More options`"
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'open-in-browser',
|
|
||||||
link: `https://modrinth.com/project/${data.slug}`,
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'report',
|
|
||||||
color: 'red',
|
|
||||||
hoverFilled: true,
|
|
||||||
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
aria-label="More options"
|
|
||||||
>
|
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
|
||||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
|
||||||
<template #report> <ReportIcon /> Report </template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
<template v-else #actions>
|
|
||||||
<ButtonStyled size="large" color="brand">
|
|
||||||
<button
|
|
||||||
v-tooltip="installButtonTooltip"
|
|
||||||
:disabled="installButtonDisabled"
|
|
||||||
@click="install(null)"
|
|
||||||
>
|
|
||||||
<SpinnerIcon
|
|
||||||
v-if="installButtonLoading && !installButtonInstalled"
|
|
||||||
class="animate-spin"
|
|
||||||
/>
|
|
||||||
<DownloadIcon v-else-if="!installButtonInstalled && !serverProjectSelected" />
|
|
||||||
<CheckIcon v-else />
|
|
||||||
{{ installButtonLabel }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled size="large" circular type="transparent">
|
|
||||||
<OverflowMenu
|
|
||||||
:tooltip="`More options`"
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'follow',
|
|
||||||
disabled: true,
|
|
||||||
tooltip: 'Coming soon',
|
|
||||||
action: () => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'save',
|
|
||||||
disabled: true,
|
|
||||||
tooltip: 'Coming soon',
|
|
||||||
action: () => {},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'open-in-browser',
|
|
||||||
link: `https://modrinth.com/${data.project_type}/${data.slug}`,
|
|
||||||
external: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'report',
|
|
||||||
color: 'red',
|
|
||||||
hoverFilled: true,
|
|
||||||
link: `https://modrinth.com/report?item=project&itemID=${data.id}`,
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
aria-label="More options"
|
|
||||||
>
|
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
|
||||||
<template #open-in-browser> <ExternalIcon /> Open in browser </template>
|
|
||||||
<template #follow> <HeartIcon /> Follow </template>
|
|
||||||
<template #save> <BookmarkIcon /> Save </template>
|
|
||||||
<template #report> <ReportIcon /> Report </template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</ProjectHeader>
|
|
||||||
<NavTabs
|
<NavTabs
|
||||||
:links="[
|
:links="[
|
||||||
{
|
{
|
||||||
@@ -266,14 +154,12 @@ import {
|
|||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
BrowseInstallHeader,
|
BrowseInstallHeader,
|
||||||
ButtonStyled,
|
|
||||||
commonMessages,
|
commonMessages,
|
||||||
CreationFlowModal,
|
CreationFlowModal,
|
||||||
defineMessages,
|
defineMessages,
|
||||||
getTargetInstallPreferences,
|
getTargetInstallPreferences,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
NavTabs,
|
NavTabs,
|
||||||
OverflowMenu,
|
|
||||||
ProjectBackgroundGradient,
|
ProjectBackgroundGradient,
|
||||||
ProjectHeader,
|
ProjectHeader,
|
||||||
ProjectSidebarCompatibility,
|
ProjectSidebarCompatibility,
|
||||||
@@ -286,6 +172,7 @@ import {
|
|||||||
SelectedProjectsFloatingBar,
|
SelectedProjectsFloatingBar,
|
||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
@@ -295,6 +182,10 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||||
|
import {
|
||||||
|
fetchCachedServerStatus,
|
||||||
|
getFreshCachedServerStatus,
|
||||||
|
} from '@/composables/instances/use-server-status-query'
|
||||||
import {
|
import {
|
||||||
get_organization,
|
get_organization,
|
||||||
get_project,
|
get_project,
|
||||||
@@ -313,7 +204,6 @@ import {
|
|||||||
list as listInstances,
|
list as listInstances,
|
||||||
} from '@/helpers/profile'
|
} from '@/helpers/profile'
|
||||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||||
import { getServerLatency } from '@/helpers/worlds'
|
|
||||||
import { injectContentInstall } from '@/providers/content-install'
|
import { injectContentInstall } from '@/providers/content-install'
|
||||||
import { injectServerInstall } from '@/providers/server-install'
|
import { injectServerInstall } from '@/providers/server-install'
|
||||||
import { createServerInstallContent } from '@/providers/setup/server-install-content'
|
import { createServerInstallContent } from '@/providers/setup/server-install-content'
|
||||||
@@ -327,6 +217,7 @@ const { handleError } = injectNotificationManager()
|
|||||||
const { install: installVersion } = injectContentInstall()
|
const { install: installVersion } = injectContentInstall()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const breadcrumbs = useBreadcrumbs()
|
const breadcrumbs = useBreadcrumbs()
|
||||||
const themeStore = useTheming()
|
const themeStore = useTheming()
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
@@ -340,6 +231,10 @@ const messages = defineMessages({
|
|||||||
id: 'app.project.install-context.install-content-to-instance',
|
id: 'app.project.install-context.install-content-to-instance',
|
||||||
defaultMessage: 'Install content to instance',
|
defaultMessage: 'Install content to instance',
|
||||||
},
|
},
|
||||||
|
worldFallbackName: {
|
||||||
|
id: 'app.project.install-context.world-fallback-name',
|
||||||
|
defaultMessage: 'Instance',
|
||||||
|
},
|
||||||
alreadyInstalled: {
|
alreadyInstalled: {
|
||||||
id: 'app.project.install-button.already-installed',
|
id: 'app.project.install-button.already-installed',
|
||||||
defaultMessage: 'This project is already installed',
|
defaultMessage: 'This project is already installed',
|
||||||
@@ -370,7 +265,10 @@ const serverStatusOnline = ref(false)
|
|||||||
const serverInstancePath = ref(null)
|
const serverInstancePath = ref(null)
|
||||||
const serverPlaying = ref(false)
|
const serverPlaying = ref(false)
|
||||||
const serverSetupModalRef = ref(null)
|
const serverSetupModalRef = ref(null)
|
||||||
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
|
const serverInstallContent = createServerInstallContent({
|
||||||
|
serverSetupModalRef,
|
||||||
|
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/project/'),
|
||||||
|
})
|
||||||
|
|
||||||
serverInstallContent.watchServerContextChanges()
|
serverInstallContent.watchServerContextChanges()
|
||||||
await serverInstallContent.initServerContext()
|
await serverInstallContent.initServerContext()
|
||||||
@@ -423,7 +321,9 @@ const projectInstallContext = computed(() => {
|
|||||||
const serverData = serverInstallContent.serverContextServerData.value
|
const serverData = serverInstallContent.serverContextServerData.value
|
||||||
if (serverData) {
|
if (serverData) {
|
||||||
return {
|
return {
|
||||||
name: serverData.name,
|
name:
|
||||||
|
serverInstallContent.serverContextWorldName.value ??
|
||||||
|
formatMessage(messages.worldFallbackName),
|
||||||
loader: serverData.loader ?? '',
|
loader: serverData.loader ?? '',
|
||||||
gameVersion: serverData.mc_version ?? '',
|
gameVersion: serverData.mc_version ?? '',
|
||||||
serverId: serverInstallContent.serverIdQuery.value,
|
serverId: serverInstallContent.serverIdQuery.value,
|
||||||
@@ -501,6 +401,131 @@ const installButtonTooltip = computed(() => {
|
|||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const projectHeaderActions = computed(() => {
|
||||||
|
if (!data.value) return []
|
||||||
|
|
||||||
|
if (isServerProject.value) {
|
||||||
|
return [
|
||||||
|
serverPlaying.value
|
||||||
|
? {
|
||||||
|
id: 'stop',
|
||||||
|
label: formatMessage(commonMessages.stopButton),
|
||||||
|
icon: StopCircleIcon,
|
||||||
|
color: 'red',
|
||||||
|
onClick: handleStopServer,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: 'play',
|
||||||
|
label:
|
||||||
|
data.value && installingServerProjects.value.includes(data.value.id)
|
||||||
|
? formatMessage(commonMessages.installingLabel)
|
||||||
|
: formatMessage(commonMessages.playButton),
|
||||||
|
icon: PlayIcon,
|
||||||
|
color: 'brand',
|
||||||
|
disabled: data.value && installingServerProjects.value.includes(data.value.id),
|
||||||
|
onClick: handleClickPlay,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'add-server-to-instance',
|
||||||
|
label: formatMessage(commonMessages.addServerToInstanceButton),
|
||||||
|
icon: PlusIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: formatMessage(commonMessages.addServerToInstanceButton),
|
||||||
|
onClick: handleAddServerToInstance,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'more',
|
||||||
|
label: 'More options',
|
||||||
|
icon: MoreVerticalIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
type: 'transparent',
|
||||||
|
tooltip: 'More options',
|
||||||
|
menuActions: [
|
||||||
|
{
|
||||||
|
id: 'open-in-browser',
|
||||||
|
label: 'Open in browser',
|
||||||
|
icon: ExternalIcon,
|
||||||
|
action: () => openUrl(`https://modrinth.com/project/${data.value.slug}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report',
|
||||||
|
label: 'Report',
|
||||||
|
icon: ReportIcon,
|
||||||
|
color: 'red',
|
||||||
|
action: () =>
|
||||||
|
openUrl(`https://modrinth.com/report?item=project&itemID=${data.value.id}`),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'install',
|
||||||
|
label: installButtonLabel.value,
|
||||||
|
icon:
|
||||||
|
installButtonLoading.value && !installButtonInstalled.value
|
||||||
|
? SpinnerIcon
|
||||||
|
: !installButtonInstalled.value && !serverProjectSelected.value
|
||||||
|
? DownloadIcon
|
||||||
|
: CheckIcon,
|
||||||
|
iconClass:
|
||||||
|
installButtonLoading.value && !installButtonInstalled.value ? 'animate-spin' : undefined,
|
||||||
|
color: 'brand',
|
||||||
|
tooltip: installButtonTooltip.value,
|
||||||
|
disabled: installButtonDisabled.value,
|
||||||
|
onClick: () => install(null),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'more',
|
||||||
|
label: 'More options',
|
||||||
|
icon: MoreVerticalIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
type: 'transparent',
|
||||||
|
tooltip: 'More options',
|
||||||
|
menuActions: [
|
||||||
|
{
|
||||||
|
id: 'follow',
|
||||||
|
label: 'Follow',
|
||||||
|
icon: HeartIcon,
|
||||||
|
disabled: true,
|
||||||
|
tooltip: 'Coming soon',
|
||||||
|
action: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'save',
|
||||||
|
label: 'Save',
|
||||||
|
icon: BookmarkIcon,
|
||||||
|
disabled: true,
|
||||||
|
tooltip: 'Coming soon',
|
||||||
|
action: () => {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-in-browser',
|
||||||
|
label: 'Open in browser',
|
||||||
|
icon: ExternalIcon,
|
||||||
|
action: () =>
|
||||||
|
openUrl(`https://modrinth.com/${data.value.project_type}/${data.value.slug}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report',
|
||||||
|
label: 'Report',
|
||||||
|
icon: ReportIcon,
|
||||||
|
color: 'red',
|
||||||
|
action: () => openUrl(`https://modrinth.com/report?item=project&itemID=${data.value.id}`),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const [allLoaders, allGameVersions] = await Promise.all([
|
const [allLoaders, allGameVersions] = await Promise.all([
|
||||||
get_loaders().catch(handleError).then(ref),
|
get_loaders().catch(handleError).then(ref),
|
||||||
get_game_versions().catch(handleError).then(ref),
|
get_game_versions().catch(handleError).then(ref),
|
||||||
@@ -587,10 +612,19 @@ async function fetchProjectData() {
|
|||||||
function fetchDeferredServerData(project) {
|
function fetchDeferredServerData(project) {
|
||||||
const serverAddress = projectV3.value?.minecraft_java_server?.address
|
const serverAddress = projectV3.value?.minecraft_java_server?.address
|
||||||
if (serverAddress) {
|
if (serverAddress) {
|
||||||
serverPing.value = undefined
|
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||||
getServerLatency(serverAddress)
|
if (cachedStatus) {
|
||||||
.then((latency) => {
|
serverPing.value = cachedStatus.ping
|
||||||
serverPing.value = latency
|
serverStatusOnline.value = true
|
||||||
|
} else {
|
||||||
|
serverPing.value = undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchCachedServerStatus(queryClient, serverAddress)
|
||||||
|
.then((status) => {
|
||||||
|
if (projectV3.value?.minecraft_java_server?.address !== serverAddress) return
|
||||||
|
serverPing.value = status.ping
|
||||||
|
serverStatusOnline.value = true
|
||||||
})
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(`Failed to ping server ${serverAddress}:`, error)
|
console.error(`Failed to ping server ${serverAddress}:`, error)
|
||||||
|
|||||||
@@ -18,7 +18,18 @@ import {
|
|||||||
writeStoredServerInstallQueue,
|
writeStoredServerInstallQueue,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { useQueryClient } from '@tanstack/vue-query'
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
import { computed, type ComputedRef, nextTick, type Ref, ref, watch } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
type ComputedRef,
|
||||||
|
nextTick,
|
||||||
|
onActivated,
|
||||||
|
onDeactivated,
|
||||||
|
type Ref,
|
||||||
|
ref,
|
||||||
|
shallowRef,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
type ServerFlowFrom = 'onboarding' | 'reset-server'
|
type ServerFlowFrom = 'onboarding' | 'reset-server'
|
||||||
@@ -53,6 +64,7 @@ export interface ServerInstallContentContext {
|
|||||||
isSetupServerContext: ComputedRef<boolean>
|
isSetupServerContext: ComputedRef<boolean>
|
||||||
effectiveServerWorldId: ComputedRef<string | null>
|
effectiveServerWorldId: ComputedRef<string | null>
|
||||||
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
|
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
|
||||||
|
serverContextWorldName: ComputedRef<string | null>
|
||||||
serverContentProjectIds: Ref<Set<string>>
|
serverContentProjectIds: Ref<Set<string>>
|
||||||
queuedServerInstallProjectIds: ComputedRef<Set<string>>
|
queuedServerInstallProjectIds: ComputedRef<Set<string>>
|
||||||
queuedServerInstallCount: ComputedRef<number>
|
queuedServerInstallCount: ComputedRef<number>
|
||||||
@@ -203,6 +215,7 @@ async function getQueuedInstallPlaceholders(
|
|||||||
|
|
||||||
export function createServerInstallContent(opts: {
|
export function createServerInstallContent(opts: {
|
||||||
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
|
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
|
||||||
|
isRouteInContext?: (route: RouteLocationNormalizedLoaded) => boolean
|
||||||
}) {
|
}) {
|
||||||
const { serverSetupModalRef } = opts
|
const { serverSetupModalRef } = opts
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -211,9 +224,22 @@ export function createServerInstallContent(opts: {
|
|||||||
const { handleError } = injectNotificationManager()
|
const { handleError } = injectNotificationManager()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const serverIdQuery = computed(() => readQueryString(route.query.sid))
|
const routeInContext = computed(() => opts.isRouteInContext?.(route) ?? true)
|
||||||
const worldIdQuery = computed(() => readQueryString(route.query.wid))
|
const contextQuery = shallowRef(route.query)
|
||||||
const browseFrom = computed(() => readQueryString(route.query.from))
|
|
||||||
|
watch(
|
||||||
|
[() => route.fullPath, routeInContext],
|
||||||
|
() => {
|
||||||
|
if (routeInContext.value) {
|
||||||
|
contextQuery.value = route.query
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const serverIdQuery = computed(() => readQueryString(contextQuery.value.sid))
|
||||||
|
const worldIdQuery = computed(() => readQueryString(contextQuery.value.wid))
|
||||||
|
const browseFrom = computed(() => readQueryString(contextQuery.value.from))
|
||||||
const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
|
const serverFlowFrom = computed<ServerFlowFrom | null>(() =>
|
||||||
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
|
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
|
||||||
? browseFrom.value
|
? browseFrom.value
|
||||||
@@ -226,11 +252,13 @@ export function createServerInstallContent(opts: {
|
|||||||
|
|
||||||
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
|
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
|
||||||
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
|
const serverContextServerData = ref<Archon.Servers.v0.Server | null>(null)
|
||||||
|
const serverContextServerFull = ref<Archon.Servers.v1.ServerFull | null>(null)
|
||||||
const serverContentProjectIds = ref<Set<string>>(new Set())
|
const serverContentProjectIds = ref<Set<string>>(new Set())
|
||||||
const serverContentInstallKeys = ref<Set<string>>(new Set())
|
const serverContentInstallKeys = ref<Set<string>>(new Set())
|
||||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
|
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
|
||||||
new Map(),
|
new Map(),
|
||||||
)
|
)
|
||||||
|
const componentActive = ref(true)
|
||||||
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
||||||
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
||||||
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
|
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
|
||||||
@@ -243,6 +271,18 @@ export function createServerInstallContent(opts: {
|
|||||||
const isInstallingQueuedServerInstalls = ref(false)
|
const isInstallingQueuedServerInstalls = ref(false)
|
||||||
const queuedInstallProgress = ref({ completed: 0, total: 0 })
|
const queuedInstallProgress = ref({ completed: 0, total: 0 })
|
||||||
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
|
const effectiveServerWorldId = computed(() => worldIdQuery.value ?? serverContextWorldId.value)
|
||||||
|
const serverContextWorld = computed(() => {
|
||||||
|
const serverFull = serverContextServerFull.value
|
||||||
|
if (!serverFull) return null
|
||||||
|
|
||||||
|
const worldId = effectiveServerWorldId.value
|
||||||
|
if (worldId) {
|
||||||
|
return serverFull.worlds.find((world) => world.id === worldId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return serverFull.worlds.find((world) => world.is_active) ?? serverFull.worlds[0] ?? null
|
||||||
|
})
|
||||||
|
const serverContextWorldName = computed(() => serverContextWorld.value?.name ?? null)
|
||||||
const serverBackUrl = computed(() => {
|
const serverBackUrl = computed(() => {
|
||||||
const sid = serverIdQuery.value
|
const sid = serverIdQuery.value
|
||||||
if (!sid) return '/hosting/manage'
|
if (!sid) return '/hosting/manage'
|
||||||
@@ -252,7 +292,7 @@ export function createServerInstallContent(opts: {
|
|||||||
if (serverFlowFrom.value === 'reset-server') {
|
if (serverFlowFrom.value === 'reset-server') {
|
||||||
return `/hosting/manage/${sid}?openSettings=installation`
|
return `/hosting/manage/${sid}?openSettings=installation`
|
||||||
}
|
}
|
||||||
return `/hosting/manage/${sid}/content`
|
return getServerInstanceContentPath(sid, effectiveServerWorldId.value)
|
||||||
})
|
})
|
||||||
const serverBackLabel = computed(() => {
|
const serverBackLabel = computed(() => {
|
||||||
if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
|
if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
|
||||||
@@ -266,9 +306,27 @@ export function createServerInstallContent(opts: {
|
|||||||
return 'Installing content'
|
return 'Installing content'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onActivated(() => {
|
||||||
|
componentActive.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
componentActive.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
async function getServerContextServerFull(serverId: string) {
|
||||||
|
if (serverContextServerFull.value?.id === serverId) {
|
||||||
|
return serverContextServerFull.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = await client.archon.servers_v1.get(serverId)
|
||||||
|
serverContextServerFull.value = server
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveServerContextWorldId(serverId: string) {
|
async function resolveServerContextWorldId(serverId: string) {
|
||||||
try {
|
try {
|
||||||
const server = await client.archon.servers_v1.get(serverId)
|
const server = await getServerContextServerFull(serverId)
|
||||||
const activeWorld = server.worlds.find((world) => world.is_active)
|
const activeWorld = server.worlds.find((world) => world.is_active)
|
||||||
return activeWorld?.id ?? server.worlds[0]?.id ?? null
|
return activeWorld?.id ?? server.worlds[0]?.id ?? null
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -304,6 +362,11 @@ export function createServerInstallContent(opts: {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
handleError(err as Error)
|
handleError(err as Error)
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await getServerContextServerFull(sid)
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err as Error)
|
||||||
|
}
|
||||||
|
|
||||||
let resolvedWorldId = effectiveServerWorldId.value
|
let resolvedWorldId = effectiveServerWorldId.value
|
||||||
if (!resolvedWorldId) {
|
if (!resolvedWorldId) {
|
||||||
@@ -320,41 +383,79 @@ export function createServerInstallContent(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function watchServerContextChanges() {
|
function watchServerContextChanges() {
|
||||||
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
|
watch(
|
||||||
if (!sid) {
|
[componentActive, routeInContext, serverIdQuery, effectiveServerWorldId],
|
||||||
serverContextServerData.value = null
|
async ([active, inContext, sid, wid], [prevActive, prevInContext, prevSid, prevWid]) => {
|
||||||
serverContentProjectIds.value = new Set()
|
if (!active || !inContext) return
|
||||||
serverContentInstallKeys.value = new Set()
|
|
||||||
setQueuedServerInstallPlans(new Map())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sid !== prevSid) {
|
if (!sid) {
|
||||||
serverContentProjectIds.value = new Set()
|
serverContextServerData.value = null
|
||||||
serverContentInstallKeys.value = new Set()
|
serverContextServerFull.value = null
|
||||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
serverContentProjectIds.value = new Set()
|
||||||
try {
|
serverContentInstallKeys.value = new Set()
|
||||||
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
setQueuedServerInstallPlans(new Map())
|
||||||
} catch (err) {
|
return
|
||||||
handleError(err as Error)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (wid !== prevWid) {
|
const hasServerDataForRoute = serverContextServerData.value?.server_id === sid
|
||||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
const hasServerFullForRoute = serverContextServerFull.value?.id === sid
|
||||||
}
|
const didEnterContext = !prevActive || !prevInContext
|
||||||
|
const shouldReloadRouteContext =
|
||||||
|
didEnterContext ||
|
||||||
|
sid !== prevSid ||
|
||||||
|
wid !== prevWid ||
|
||||||
|
!hasServerDataForRoute ||
|
||||||
|
!hasServerFullForRoute
|
||||||
|
|
||||||
if (wid && (sid !== prevSid || wid !== prevWid)) {
|
if (!hasServerDataForRoute || !hasServerFullForRoute) {
|
||||||
await refreshServerInstalledContent(sid, wid)
|
serverContextWorldId.value = worldIdQuery.value
|
||||||
}
|
if (!hasServerDataForRoute) {
|
||||||
})
|
serverContextServerData.value = null
|
||||||
|
}
|
||||||
|
if (!hasServerFullForRoute) {
|
||||||
|
serverContextServerFull.value = null
|
||||||
|
}
|
||||||
|
serverContentProjectIds.value = new Set()
|
||||||
|
serverContentInstallKeys.value = new Set()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasServerDataForRoute) {
|
||||||
|
try {
|
||||||
|
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasServerFullForRoute) {
|
||||||
|
try {
|
||||||
|
const serverFull = await getServerContextServerFull(sid)
|
||||||
|
if (!worldIdQuery.value) {
|
||||||
|
const activeWorld = serverFull.worlds.find((world) => world.is_active)
|
||||||
|
serverContextWorldId.value = activeWorld?.id ?? serverFull.worlds[0]?.id ?? null
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
handleError(err as Error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldReloadRouteContext) {
|
||||||
|
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wid && shouldReloadRouteContext) {
|
||||||
|
await refreshServerInstalledContent(sid, wid)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
|
function enforceSetupModpackRoute(currentProjectType: string | undefined) {
|
||||||
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
|
if (!routeInContext.value || !isSetupServerContext.value || currentProjectType === 'modpack')
|
||||||
|
return
|
||||||
router.replace({
|
router.replace({
|
||||||
path: '/browse/modpack',
|
path: '/browse/modpack',
|
||||||
query: route.query,
|
query: contextQuery.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,7 +525,7 @@ export function createServerInstallContent(opts: {
|
|||||||
if (isInstallingQueuedServerInstalls.value) return false
|
if (isInstallingQueuedServerInstalls.value) return false
|
||||||
|
|
||||||
if (!serverId || !worldId) {
|
if (!serverId || !worldId) {
|
||||||
handleError(new Error('No server world is available for install.'))
|
handleError(new Error('No server instance is available for install.'))
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,7 +657,7 @@ export function createServerInstallContent(opts: {
|
|||||||
|
|
||||||
if (serverFlowFrom.value === 'onboarding') {
|
if (serverFlowFrom.value === 'onboarding') {
|
||||||
await client.archon.servers_v1.endIntro(sid)
|
await client.archon.servers_v1.endIntro(sid)
|
||||||
await router.push(`/hosting/manage/${sid}/content`)
|
await router.push(getServerInstanceContentPath(sid, wid))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -571,6 +672,11 @@ export function createServerInstallContent(opts: {
|
|||||||
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id])
|
serverContentProjectIds.value = new Set([...serverContentProjectIds.value, id])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getServerInstanceContentPath(serverId: string, worldId: string | null) {
|
||||||
|
const base = `/hosting/manage/${encodeURIComponent(serverId)}/instances`
|
||||||
|
return worldId ? `${base}/${encodeURIComponent(worldId)}` : base
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
serverIdQuery,
|
serverIdQuery,
|
||||||
worldIdQuery,
|
worldIdQuery,
|
||||||
@@ -581,6 +687,7 @@ export function createServerInstallContent(opts: {
|
|||||||
isSetupServerContext,
|
isSetupServerContext,
|
||||||
effectiveServerWorldId,
|
effectiveServerWorldId,
|
||||||
serverContextServerData,
|
serverContextServerData,
|
||||||
|
serverContextWorldName,
|
||||||
serverContentProjectIds,
|
serverContentProjectIds,
|
||||||
queuedServerInstallProjectIds,
|
queuedServerInstallProjectIds,
|
||||||
queuedServerInstallCount,
|
queuedServerInstallCount,
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import * as Instance from '@/pages/instance'
|
|||||||
import * as Library from '@/pages/library'
|
import * as Library from '@/pages/library'
|
||||||
import * as Project from '@/pages/project'
|
import * as Project from '@/pages/project'
|
||||||
|
|
||||||
|
function getQueryParam(value) {
|
||||||
|
if (Array.isArray(value)) return value[0] ?? ''
|
||||||
|
return value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configures application routing. Add page to pages/index and then add to route table here.
|
* Configures application routing. Add page to pages/index and then add to route table here.
|
||||||
*/
|
*/
|
||||||
@@ -57,6 +62,48 @@ export default new createRouter({
|
|||||||
breadcrumb: [{ name: '?Server' }],
|
breadcrumb: [{ name: '?Server' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'instances',
|
||||||
|
name: 'ServerManageInstances',
|
||||||
|
component: Hosting.Instances,
|
||||||
|
meta: {
|
||||||
|
breadcrumb: [{ name: '?Server' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'instances/:instance_id',
|
||||||
|
name: 'ServerManageInstance',
|
||||||
|
component: Hosting.Instance,
|
||||||
|
meta: {
|
||||||
|
breadcrumb: [{ name: '?Server' }],
|
||||||
|
},
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
name: 'ServerManageInstanceContent',
|
||||||
|
component: Hosting.Content,
|
||||||
|
meta: {
|
||||||
|
breadcrumb: [{ name: '?Server' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'files',
|
||||||
|
name: 'ServerManageInstanceFiles',
|
||||||
|
component: Hosting.Files,
|
||||||
|
meta: {
|
||||||
|
breadcrumb: [{ name: '?Server' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'backups',
|
||||||
|
name: 'ServerManageInstanceBackups',
|
||||||
|
component: Hosting.Backups,
|
||||||
|
meta: {
|
||||||
|
breadcrumb: [{ name: '?Server' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'files',
|
path: 'files',
|
||||||
name: 'ServerManageFiles',
|
name: 'ServerManageFiles',
|
||||||
@@ -81,6 +128,17 @@ export default new createRouter({
|
|||||||
component: Pages.Browse,
|
component: Pages.Browse,
|
||||||
meta: {
|
meta: {
|
||||||
useContext: true,
|
useContext: true,
|
||||||
|
keepAliveComponent: 'Browse',
|
||||||
|
keepAliveKey: (route) => {
|
||||||
|
return [
|
||||||
|
'browse',
|
||||||
|
getQueryParam(route.params.projectType),
|
||||||
|
getQueryParam(route.query.i),
|
||||||
|
getQueryParam(route.query.sid),
|
||||||
|
getQueryParam(route.query.wid),
|
||||||
|
getQueryParam(route.query.from),
|
||||||
|
].join(':')
|
||||||
|
},
|
||||||
breadcrumb: [{ name: '?BrowseTitle' }],
|
breadcrumb: [{ name: '?BrowseTitle' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<template>
|
||||||
|
<ButtonStyled size="large" circular>
|
||||||
|
<PopoutMenu
|
||||||
|
v-if="authUser"
|
||||||
|
:tooltip="
|
||||||
|
saved ? formatMessage(commonMessages.savedLabel) : formatMessage(commonMessages.saveButton)
|
||||||
|
"
|
||||||
|
from="top-right"
|
||||||
|
:aria-label="formatMessage(commonMessages.saveButton)"
|
||||||
|
:dropdown-id="`${baseId}-save`"
|
||||||
|
>
|
||||||
|
<BookmarkIcon aria-hidden="true" :fill="saved ? 'currentColor' : 'none'" />
|
||||||
|
<template #menu>
|
||||||
|
<StyledInput
|
||||||
|
v-model="displayCollectionsSearch"
|
||||||
|
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
||||||
|
wrapper-class="menu-search"
|
||||||
|
/>
|
||||||
|
<div v-if="filteredCollections.length > 0" class="collections-list text-primary">
|
||||||
|
<Checkbox
|
||||||
|
v-for="option in filteredCollections"
|
||||||
|
:key="option.id"
|
||||||
|
:model-value="option.projects.includes(projectId)"
|
||||||
|
class="popout-checkbox"
|
||||||
|
@update:model-value="() => collectProject(option, projectId)"
|
||||||
|
>
|
||||||
|
{{ option.name }}
|
||||||
|
</Checkbox>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="menu-text">
|
||||||
|
<p class="popout-text">{{ noCollectionsLabel }}</p>
|
||||||
|
</div>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button class="mx-3 mb-3" @click="createCollection">
|
||||||
|
<PlusIcon aria-hidden="true" />
|
||||||
|
{{ createNewCollectionLabel }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</PopoutMenu>
|
||||||
|
<nuxt-link
|
||||||
|
v-else
|
||||||
|
v-tooltip="formatMessage(commonMessages.saveButton)"
|
||||||
|
:to="signInRoute"
|
||||||
|
:aria-label="formatMessage(commonMessages.saveButton)"
|
||||||
|
>
|
||||||
|
<BookmarkIcon aria-hidden="true" />
|
||||||
|
</nuxt-link>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { BookmarkIcon, PlusIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
ButtonStyled,
|
||||||
|
Checkbox,
|
||||||
|
commonMessages,
|
||||||
|
PopoutMenu,
|
||||||
|
StyledInput,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
type CollectionOption = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
projects: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
authUser?: unknown
|
||||||
|
signInRoute: RouteLocationRaw
|
||||||
|
projectId: string
|
||||||
|
collections: CollectionOption[]
|
||||||
|
saved: boolean
|
||||||
|
baseId: string
|
||||||
|
noCollectionsLabel: string
|
||||||
|
createNewCollectionLabel: string
|
||||||
|
collectProject: (option: CollectionOption, projectId: string) => void | Promise<void>
|
||||||
|
createCollection: (event: MouseEvent) => void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const displayCollectionsSearch = ref('')
|
||||||
|
|
||||||
|
const filteredCollections = computed(() =>
|
||||||
|
props.collections
|
||||||
|
.filter((collection) =>
|
||||||
|
collection.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase()),
|
||||||
|
)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.popout-checkbox {
|
||||||
|
padding: var(--gap-sm) var(--gap-md);
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-text {
|
||||||
|
padding: 0 var(--gap-md);
|
||||||
|
font-size: var(--font-size-nm);
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-search {
|
||||||
|
margin: var(--gap-sm) var(--gap-md);
|
||||||
|
width: calc(100% - var(--gap-md) * 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.collections-list {
|
||||||
|
max-height: 40rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin: var(--gap-sm) var(--gap-md);
|
||||||
|
padding: var(--gap-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -65,7 +65,7 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
noServerWorld: {
|
noServerWorld: {
|
||||||
id: 'discover.install.error.no-server-world',
|
id: 'discover.install.error.no-server-world',
|
||||||
defaultMessage: 'No server world is available for install.',
|
defaultMessage: 'No server instance is available for install.',
|
||||||
},
|
},
|
||||||
backToSetup: {
|
backToSetup: {
|
||||||
id: 'discover.install.back-to-setup',
|
id: 'discover.install.back-to-setup',
|
||||||
@@ -83,6 +83,10 @@ const messages = defineMessages({
|
|||||||
id: 'discover.install.heading.reset-modpack',
|
id: 'discover.install.heading.reset-modpack',
|
||||||
defaultMessage: 'Selecting modpack to install after reset',
|
defaultMessage: 'Selecting modpack to install after reset',
|
||||||
},
|
},
|
||||||
|
worldFallbackName: {
|
||||||
|
id: 'discover.install.world-fallback-name',
|
||||||
|
defaultMessage: 'Instance',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
|
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
|
||||||
@@ -145,6 +149,11 @@ export function useServerInstallContent({
|
|||||||
return enabled
|
return enabled
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
const { data: serverFullData } = useQuery({
|
||||||
|
queryKey: computed(() => ['servers', 'v1', 'detail', currentServerId.value ?? ''] as const),
|
||||||
|
queryFn: () => client.archon.servers_v1.get(currentServerId.value!),
|
||||||
|
enabled: computed(() => !!currentServerId.value),
|
||||||
|
})
|
||||||
|
|
||||||
watch(serverData, (val) =>
|
watch(serverData, (val) =>
|
||||||
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
|
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
|
||||||
@@ -187,7 +196,10 @@ export function useServerInstallContent({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentQueryKey = computed(() => ['content', 'list', currentServerId.value ?? ''] as const)
|
const contentQueryKey = computed(
|
||||||
|
() =>
|
||||||
|
['content', 'list', 'v1', currentServerId.value ?? '', currentWorldId.value ?? null] as const,
|
||||||
|
)
|
||||||
const { data: serverContentData, error: serverContentError } = useQuery({
|
const { data: serverContentData, error: serverContentError } = useQuery({
|
||||||
queryKey: contentQueryKey,
|
queryKey: contentQueryKey,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
@@ -625,7 +637,9 @@ export function useServerInstallContent({
|
|||||||
if (fromContext.value === 'onboarding') {
|
if (fromContext.value === 'onboarding') {
|
||||||
await client.archon.servers_v1.endIntro(currentServerId.value)
|
await client.archon.servers_v1.endIntro(currentServerId.value)
|
||||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
|
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
|
||||||
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
|
navigateTo(
|
||||||
|
getServerInstanceContentPath(currentServerId.value, currentWorldId.value ?? null),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
|
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
|
||||||
}
|
}
|
||||||
@@ -641,9 +655,14 @@ export function useServerInstallContent({
|
|||||||
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
|
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
|
||||||
if (fromContext.value === 'reset-server')
|
if (fromContext.value === 'reset-server')
|
||||||
return `/hosting/manage/${id}?openSettings=installation`
|
return `/hosting/manage/${id}?openSettings=installation`
|
||||||
return `/hosting/manage/${id}/content`
|
return getServerInstanceContentPath(id, currentWorldId.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function getServerInstanceContentPath(serverId: string, worldId: string | null) {
|
||||||
|
const base = `/hosting/manage/${encodeURIComponent(serverId)}/instances`
|
||||||
|
return worldId ? `${base}/${encodeURIComponent(worldId)}` : base
|
||||||
|
}
|
||||||
|
|
||||||
const serverBackLabel = computed(() => {
|
const serverBackLabel = computed(() => {
|
||||||
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
|
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
|
||||||
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
|
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
|
||||||
@@ -655,13 +674,26 @@ export function useServerInstallContent({
|
|||||||
? formatMessage(messages.resetModpackHeading)
|
? formatMessage(messages.resetModpackHeading)
|
||||||
: formatMessage(commonMessages.installingContentLabel),
|
: formatMessage(commonMessages.installingContentLabel),
|
||||||
)
|
)
|
||||||
|
const currentWorld = computed(() => {
|
||||||
|
const full = serverFullData.value
|
||||||
|
if (!full) return null
|
||||||
|
|
||||||
|
const worldId = currentWorldId.value
|
||||||
|
if (worldId) {
|
||||||
|
return full.worlds.find((world) => world.id === worldId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return full.worlds.find((world) => world.is_active) ?? full.worlds[0] ?? null
|
||||||
|
})
|
||||||
|
|
||||||
const installContext = computed(() => {
|
const installContext = computed(() => {
|
||||||
if (!serverData.value) return null
|
if (!serverData.value) return null
|
||||||
return {
|
return {
|
||||||
name: serverData.value.name,
|
name: currentWorld.value?.name ?? formatMessage(messages.worldFallbackName),
|
||||||
loader: serverData.value.loader ?? '',
|
loader: currentWorld.value?.content?.modloader ?? serverData.value.loader ?? '',
|
||||||
gameVersion: serverData.value.mc_version ?? '',
|
loaderVersion:
|
||||||
|
currentWorld.value?.content?.modloader_version ?? serverData.value.loader_version ?? '',
|
||||||
|
gameVersion: currentWorld.value?.content?.game_version ?? serverData.value.mc_version ?? '',
|
||||||
serverId: currentServerId.value,
|
serverId: currentServerId.value,
|
||||||
upstream: serverData.value.upstream,
|
upstream: serverData.value.upstream,
|
||||||
iconSrc: serverIcon.value,
|
iconSrc: serverIcon.value,
|
||||||
|
|||||||
@@ -1353,7 +1353,7 @@
|
|||||||
"message": "Cancel reset"
|
"message": "Cancel reset"
|
||||||
},
|
},
|
||||||
"discover.install.error.no-server-world": {
|
"discover.install.error.no-server-world": {
|
||||||
"message": "No server world is available for install."
|
"message": "No server instance is available for install."
|
||||||
},
|
},
|
||||||
"discover.install.error.unsupported-content-type": {
|
"discover.install.error.unsupported-content-type": {
|
||||||
"message": "This content type cannot be installed to a server from browse."
|
"message": "This content type cannot be installed to a server from browse."
|
||||||
@@ -1361,6 +1361,9 @@
|
|||||||
"discover.install.heading.reset-modpack": {
|
"discover.install.heading.reset-modpack": {
|
||||||
"message": "Selecting modpack to install after reset"
|
"message": "Selecting modpack to install after reset"
|
||||||
},
|
},
|
||||||
|
"discover.install.world-fallback-name": {
|
||||||
|
"message": "Instance"
|
||||||
|
},
|
||||||
"discover.seo.description": {
|
"discover.seo.description": {
|
||||||
"message": "Search and browse thousands of Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}."
|
"message": "Search and browse thousands of Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType, select, mod {mods} modpack {modpacks} resourcepack {resource packs} shader {shaders} plugin {plugins} datapack {datapacks} other {projects}}."
|
||||||
},
|
},
|
||||||
@@ -3290,6 +3293,12 @@
|
|||||||
"servers.manage.content.title": {
|
"servers.manage.content.title": {
|
||||||
"message": "Content - {serverName} - Modrinth"
|
"message": "Content - {serverName} - Modrinth"
|
||||||
},
|
},
|
||||||
|
"servers.manage.instances.meta.title": {
|
||||||
|
"message": "Instances - {server} - Modrinth"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.slot-name": {
|
||||||
|
"message": "Instance #{index}"
|
||||||
|
},
|
||||||
"servers.notice.actions": {
|
"servers.notice.actions": {
|
||||||
"message": "Actions"
|
"message": "Actions"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useAppQueryClient } from '~/composables/query-client'
|
||||||
|
import { createModrinthClient } from '~/helpers/api.ts'
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const serverId = getRouteParam(to.params.id)
|
||||||
|
const worldId = getRouteParam(to.params.instance_id)
|
||||||
|
|
||||||
|
if (!serverId || !worldId) return
|
||||||
|
|
||||||
|
if (import.meta.client) startLoading()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await useAuth()
|
||||||
|
if (!auth.value.token) return
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const queryClient = useAppQueryClient()
|
||||||
|
const client = createModrinthClient(auth, {
|
||||||
|
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||||
|
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||||
|
rateLimitKey: config.rateLimitKey,
|
||||||
|
})
|
||||||
|
|
||||||
|
await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||||
|
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tab = getInstanceTab(to.path)
|
||||||
|
|
||||||
|
if (tab === 'backups') {
|
||||||
|
await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['backups', 'list', serverId, worldId],
|
||||||
|
queryFn: () => client.archon.backups_v1.list(serverId, worldId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab === 'files') {
|
||||||
|
const path = typeof to.query.path === 'string' ? to.query.path : '/'
|
||||||
|
await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['files', serverId, path],
|
||||||
|
queryFn: () => client.kyros.files_v0.listDirectory(path, 1, 2000),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['content', 'list', 'v1', serverId, worldId],
|
||||||
|
queryFn: () => client.archon.content_v1.getAddons(serverId, worldId, { from_modpack: false }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const modpackProjectId =
|
||||||
|
content.modpack?.spec.platform === 'modrinth' ? content.modpack.spec.project_id : null
|
||||||
|
|
||||||
|
if (modpackProjectId) {
|
||||||
|
await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['labrinth', 'project', modpackProjectId],
|
||||||
|
queryFn: () => client.labrinth.projects_v2.get(modpackProjectId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||||
|
} finally {
|
||||||
|
if (import.meta.client) stopLoading()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function getRouteParam(param: string | string[] | undefined): string | null {
|
||||||
|
if (Array.isArray(param)) return param[0] ?? null
|
||||||
|
return param ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInstanceTab(path: string): 'content' | 'files' | 'backups' {
|
||||||
|
const segments = path.split('/').filter(Boolean)
|
||||||
|
const lastSegment = segments[segments.length - 1]
|
||||||
|
if (lastSegment === 'files') return 'files'
|
||||||
|
return lastSegment === 'backups' ? 'backups' : 'content'
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { createModrinthClient } from '~/helpers/api.ts'
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware(async (to) => {
|
||||||
|
const match = to.path.match(/^\/hosting\/manage\/([^/]+)\/(content|files|backups)\/?$/)
|
||||||
|
if (!match) return
|
||||||
|
|
||||||
|
const serverId = decodeURIComponent(match[1])
|
||||||
|
const tab = match[2]
|
||||||
|
const instancesPath = `/hosting/manage/${encodeURIComponent(serverId)}/instances`
|
||||||
|
const tabPath = tab === 'content' ? '' : `/${tab}`
|
||||||
|
const auth = await useAuth()
|
||||||
|
|
||||||
|
if (auth.value.token) {
|
||||||
|
try {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const client = createModrinthClient(auth, {
|
||||||
|
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||||
|
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||||
|
rateLimitKey: config.rateLimitKey,
|
||||||
|
})
|
||||||
|
const serverFull = await client.archon.servers_v1.get(serverId)
|
||||||
|
const world = serverFull.worlds.find((item) => item.is_active) ?? serverFull.worlds[0]
|
||||||
|
|
||||||
|
if (world) {
|
||||||
|
return navigateTo(
|
||||||
|
{
|
||||||
|
path: `${instancesPath}/${encodeURIComponent(world.id)}${tabPath}`,
|
||||||
|
query: to.query,
|
||||||
|
hash: to.hash,
|
||||||
|
},
|
||||||
|
{ replace: true },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return navigateTo({ path: instancesPath, query: to.query, hash: to.hash }, { replace: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return navigateTo({ path: instancesPath, query: to.query, hash: to.hash }, { replace: true })
|
||||||
|
})
|
||||||
@@ -459,358 +459,8 @@
|
|||||||
:project="project"
|
:project="project"
|
||||||
:project-v3="projectV3"
|
:project-v3="projectV3"
|
||||||
:member="!!currentMember"
|
:member="!!currentMember"
|
||||||
>
|
:actions="projectHeaderActions"
|
||||||
<template #actions>
|
/>
|
||||||
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand" circular>
|
|
||||||
<nuxt-link
|
|
||||||
v-tooltip="'Edit project'"
|
|
||||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
|
|
||||||
class="!font-bold lg:!hidden"
|
|
||||||
>
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
</nuxt-link>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled v-if="auth.user && currentMember" size="large" color="brand">
|
|
||||||
<nuxt-link
|
|
||||||
:to="`/${project.project_type}/${project.slug ? project.slug : project.id}/settings`"
|
|
||||||
class="!font-bold max-lg:!hidden"
|
|
||||||
>
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
Edit project
|
|
||||||
</nuxt-link>
|
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<div class="hidden sm:contents">
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="!isServerProject"
|
|
||||||
size="large"
|
|
||||||
:color="
|
|
||||||
(auth.user && currentMember) || route.name === 'type-project-version-version'
|
|
||||||
? `standard`
|
|
||||||
: `brand`
|
|
||||||
"
|
|
||||||
:circular="!!auth.user && !!currentMember"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-tooltip="
|
|
||||||
auth.user && currentMember ? formatMessage(commonMessages.downloadButton) : ''
|
|
||||||
"
|
|
||||||
@click="(event) => downloadModal.show(event)"
|
|
||||||
>
|
|
||||||
<DownloadIcon aria-hidden="true" />
|
|
||||||
{{
|
|
||||||
auth.user && currentMember ? '' : formatMessage(commonMessages.downloadButton)
|
|
||||||
}}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else
|
|
||||||
size="large"
|
|
||||||
:color="
|
|
||||||
(auth.user && currentMember) || route.name === 'type-project-version-version'
|
|
||||||
? `standard`
|
|
||||||
: `brand`
|
|
||||||
"
|
|
||||||
:circular="!!auth.user && !!currentMember"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-tooltip="auth.user && currentMember && !openInAppModal?.open ? 'Play' : ''"
|
|
||||||
@click="handlePlayServerProject"
|
|
||||||
>
|
|
||||||
<PlayIcon aria-hidden="true" />
|
|
||||||
{{ auth.user && currentMember ? '' : 'Play' }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="contents sm:hidden">
|
|
||||||
<ButtonStyled
|
|
||||||
v-if="!isServerProject"
|
|
||||||
size="large"
|
|
||||||
circular
|
|
||||||
:color="
|
|
||||||
route.name === 'type-project-version-version' || (auth.user && currentMember)
|
|
||||||
? `standard`
|
|
||||||
: `brand`
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
:aria-label="formatMessage(commonMessages.downloadButton)"
|
|
||||||
class="flex sm:hidden"
|
|
||||||
@click="(event) => downloadModal.show(event)"
|
|
||||||
>
|
|
||||||
<DownloadIcon aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled
|
|
||||||
v-else
|
|
||||||
size="large"
|
|
||||||
circular
|
|
||||||
:color="
|
|
||||||
route.name === 'type-project-version-version' || (auth.user && currentMember)
|
|
||||||
? `standard`
|
|
||||||
: `brand`
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<button aria-label="Play" class="flex sm:hidden" @click="handlePlayServerProject">
|
|
||||||
<PlayIcon aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
<Tooltip
|
|
||||||
v-if="canCreateServerFrom && flags.showProjectPageQuickServerButton"
|
|
||||||
theme="dismissable-prompt"
|
|
||||||
:triggers="[]"
|
|
||||||
:shown="flags.showProjectPageCreateServersTooltip"
|
|
||||||
:auto-hide="false"
|
|
||||||
placement="bottom-start"
|
|
||||||
>
|
|
||||||
<ButtonStyled size="large" circular>
|
|
||||||
<nuxt-link
|
|
||||||
v-tooltip="formatMessage(messages.createServerTooltip)"
|
|
||||||
:to="`/hosting?project=${project.id}#plan`"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
flags.showProjectPageCreateServersTooltip = false
|
|
||||||
saveFeatureFlags()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<ServerPlusIcon aria-hidden="true" />
|
|
||||||
</nuxt-link>
|
|
||||||
</ButtonStyled>
|
|
||||||
<template #popper>
|
|
||||||
<div class="grid grid-cols-[min-content] gap-1">
|
|
||||||
<div class="flex min-w-60 items-center justify-between gap-4">
|
|
||||||
<h3
|
|
||||||
class="m-0 flex items-center gap-2 whitespace-nowrap text-base font-bold text-contrast"
|
|
||||||
>
|
|
||||||
{{ formatMessage(messages.serversPromoTitle) }}
|
|
||||||
<TagItem
|
|
||||||
:style="{
|
|
||||||
'--_color': 'var(--color-brand)',
|
|
||||||
'--_bg-color': 'var(--color-brand-highlight)',
|
|
||||||
}"
|
|
||||||
>{{ formatMessage(commonMessages.newBadge) }}</TagItem
|
|
||||||
>
|
|
||||||
</h3>
|
|
||||||
<ButtonStyled size="small" circular>
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(messages.dontShowAgain)"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
flags.showProjectPageCreateServersTooltip = false
|
|
||||||
saveFeatureFlags()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<XIcon aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
|
||||||
{{ formatMessage(messages.serversPromoDescription) }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
|
||||||
<IntlFormatted
|
|
||||||
:message-id="messages.serversPromoPricing"
|
|
||||||
:values="{
|
|
||||||
price: formatPrice(500, 'USD', true),
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #small="{ children }">
|
|
||||||
<span class="text-xs">
|
|
||||||
<component :is="() => children" />
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
</IntlFormatted>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Tooltip>
|
|
||||||
<ButtonStyled size="large" circular>
|
|
||||||
<ClientOnly>
|
|
||||||
<button
|
|
||||||
v-if="auth.user"
|
|
||||||
v-tooltip="
|
|
||||||
following
|
|
||||||
? formatMessage(commonMessages.unfollowButton)
|
|
||||||
: formatMessage(commonMessages.followButton)
|
|
||||||
"
|
|
||||||
:aria-label="
|
|
||||||
following
|
|
||||||
? formatMessage(commonMessages.unfollowButton)
|
|
||||||
: formatMessage(commonMessages.followButton)
|
|
||||||
"
|
|
||||||
@click="userFollowProject(project)"
|
|
||||||
>
|
|
||||||
<HeartIcon :fill="following ? 'currentColor' : 'none'" aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
<nuxt-link
|
|
||||||
v-else
|
|
||||||
v-tooltip="formatMessage(commonMessages.followButton)"
|
|
||||||
:to="signInRouteObj"
|
|
||||||
:aria-label="formatMessage(commonMessages.followButton)"
|
|
||||||
>
|
|
||||||
<HeartIcon aria-hidden="true" />
|
|
||||||
</nuxt-link>
|
|
||||||
<template #fallback>
|
|
||||||
<nuxt-link
|
|
||||||
v-tooltip="formatMessage(commonMessages.followButton)"
|
|
||||||
:to="signInRouteObj"
|
|
||||||
:aria-label="formatMessage(commonMessages.followButton)"
|
|
||||||
>
|
|
||||||
<HeartIcon aria-hidden="true" />
|
|
||||||
</nuxt-link>
|
|
||||||
</template>
|
|
||||||
</ClientOnly>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled size="large" circular>
|
|
||||||
<PopoutMenu
|
|
||||||
v-if="auth.user"
|
|
||||||
:tooltip="
|
|
||||||
collections.some((x) => x.projects.includes(project.id))
|
|
||||||
? formatMessage(commonMessages.savedLabel)
|
|
||||||
: formatMessage(commonMessages.saveButton)
|
|
||||||
"
|
|
||||||
from="top-right"
|
|
||||||
:aria-label="formatMessage(commonMessages.saveButton)"
|
|
||||||
:dropdown-id="`${baseId}-save`"
|
|
||||||
>
|
|
||||||
<BookmarkIcon
|
|
||||||
aria-hidden="true"
|
|
||||||
:fill="
|
|
||||||
collections.some((x) => x.projects.includes(project.id))
|
|
||||||
? 'currentColor'
|
|
||||||
: 'none'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<template #menu>
|
|
||||||
<StyledInput
|
|
||||||
v-model="displayCollectionsSearch"
|
|
||||||
:placeholder="formatMessage(commonMessages.searchPlaceholder)"
|
|
||||||
wrapper-class="menu-search"
|
|
||||||
/>
|
|
||||||
<div v-if="collections.length > 0" class="collections-list text-primary">
|
|
||||||
<Checkbox
|
|
||||||
v-for="option in collections
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))"
|
|
||||||
:key="option.id"
|
|
||||||
:model-value="option.projects.includes(project.id)"
|
|
||||||
class="popout-checkbox"
|
|
||||||
@update:model-value="() => onUserCollectProject(option, project.id)"
|
|
||||||
>
|
|
||||||
{{ option.name }}
|
|
||||||
</Checkbox>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="menu-text">
|
|
||||||
<p class="popout-text">{{ formatMessage(messages.noCollectionsFound) }}</p>
|
|
||||||
</div>
|
|
||||||
<ButtonStyled>
|
|
||||||
<button
|
|
||||||
class="mx-3 mb-3"
|
|
||||||
@click="(event) => $refs.modal_collection.show(event)"
|
|
||||||
>
|
|
||||||
<PlusIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(messages.createNewCollection) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</PopoutMenu>
|
|
||||||
<nuxt-link v-else v-tooltip="'Save'" :to="signInRouteObj" aria-label="Save">
|
|
||||||
<BookmarkIcon aria-hidden="true" />
|
|
||||||
</nuxt-link>
|
|
||||||
</ButtonStyled>
|
|
||||||
|
|
||||||
<ButtonStyled size="large" circular type="transparent">
|
|
||||||
<OverflowMenu
|
|
||||||
:tooltip="formatMessage(commonMessages.moreOptionsButton)"
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'analytics',
|
|
||||||
link: `/${project.project_type}/${project.slug ? project.slug : project.id}/settings/analytics`,
|
|
||||||
hoverOnly: true,
|
|
||||||
shown: auth.user && !!currentMember,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
shown: auth.user && !!currentMember,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'moderation-checklist',
|
|
||||||
action: openModerationChecklistFromMenu,
|
|
||||||
color: 'orange',
|
|
||||||
hoverOnly: true,
|
|
||||||
shown:
|
|
||||||
auth.user &&
|
|
||||||
tags.staffRoles.includes(auth.user.role) &&
|
|
||||||
!showModerationChecklist,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
shown:
|
|
||||||
auth.user &&
|
|
||||||
tags.staffRoles.includes(auth.user.role) &&
|
|
||||||
!showModerationChecklist,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'tech-review',
|
|
||||||
link: `/moderation/technical-review/${project.id}`,
|
|
||||||
color: 'orange',
|
|
||||||
hoverOnly: true,
|
|
||||||
shown: auth.user && tags.staffRoles.includes(auth.user.role),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
shown: auth.user && tags.staffRoles.includes(auth.user.role),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'report',
|
|
||||||
action: () =>
|
|
||||||
auth.user
|
|
||||||
? reportProject(project.id)
|
|
||||||
: navigateTo(
|
|
||||||
getSignInRouteObj(route, getReportPath('project', project.id)),
|
|
||||||
),
|
|
||||||
color: 'red',
|
|
||||||
hoverOnly: true,
|
|
||||||
shown: !isMember,
|
|
||||||
},
|
|
||||||
{ id: 'copy-id', action: () => copyId() },
|
|
||||||
{ id: 'copy-permalink', action: () => copyPermalink() },
|
|
||||||
]"
|
|
||||||
:aria-label="formatMessage(commonMessages.moreOptionsButton)"
|
|
||||||
:dropdown-id="`${baseId}-more-options`"
|
|
||||||
>
|
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
|
||||||
<template #analytics>
|
|
||||||
<ChartIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.analyticsButton) }}
|
|
||||||
</template>
|
|
||||||
<template #moderation-checklist>
|
|
||||||
<ScaleIcon aria-hidden="true" /> {{ formatMessage(messages.reviewProject) }}
|
|
||||||
</template>
|
|
||||||
<template #tech-review> <ScanEyeIcon aria-hidden="true" /> Tech review </template>
|
|
||||||
<template #report>
|
|
||||||
<ReportIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.reportButton) }}
|
|
||||||
</template>
|
|
||||||
<template #copy-id>
|
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
|
||||||
</template>
|
|
||||||
<template #copy-permalink>
|
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.copyPermalinkButton) }}
|
|
||||||
</template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</ProjectHeader>
|
|
||||||
<ProjectMemberHeader
|
<ProjectMemberHeader
|
||||||
v-if="currentMember"
|
v-if="currentMember"
|
||||||
:project="project"
|
:project="project"
|
||||||
@@ -1042,7 +692,6 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
BookmarkIcon,
|
|
||||||
BookTextIcon,
|
BookTextIcon,
|
||||||
CalendarIcon,
|
CalendarIcon,
|
||||||
ChartIcon,
|
ChartIcon,
|
||||||
@@ -1058,7 +707,6 @@ import {
|
|||||||
ModrinthIcon,
|
ModrinthIcon,
|
||||||
MoreVerticalIcon,
|
MoreVerticalIcon,
|
||||||
PlayIcon,
|
PlayIcon,
|
||||||
PlusIcon,
|
|
||||||
ReportIcon,
|
ReportIcon,
|
||||||
ScaleIcon,
|
ScaleIcon,
|
||||||
ScanEyeIcon,
|
ScanEyeIcon,
|
||||||
@@ -1067,7 +715,6 @@ import {
|
|||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
VersionIcon,
|
VersionIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
XIcon,
|
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
Admonition,
|
Admonition,
|
||||||
@@ -1079,12 +726,9 @@ import {
|
|||||||
getTagMessage,
|
getTagMessage,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
IntlFormatted,
|
|
||||||
NavTabs,
|
NavTabs,
|
||||||
NewModal,
|
NewModal,
|
||||||
OpenInAppModal,
|
OpenInAppModal,
|
||||||
OverflowMenu,
|
|
||||||
PopoutMenu,
|
|
||||||
PROJECT_DEP_MARKER_QUERY,
|
PROJECT_DEP_MARKER_QUERY,
|
||||||
ProjectBackgroundGradient,
|
ProjectBackgroundGradient,
|
||||||
ProjectEnvironmentModal,
|
ProjectEnvironmentModal,
|
||||||
@@ -1099,7 +743,6 @@ import {
|
|||||||
ScrollablePanel,
|
ScrollablePanel,
|
||||||
ServersPromo,
|
ServersPromo,
|
||||||
StyledInput,
|
StyledInput,
|
||||||
TagItem,
|
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
useFormatDateTime,
|
useFormatDateTime,
|
||||||
useFormatPrice,
|
useFormatPrice,
|
||||||
@@ -1111,7 +754,6 @@ import { capitalizeString, formatProjectType, renderString } from '@modrinth/uti
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { useLocalStorage } from '@vueuse/core'
|
import { useLocalStorage } from '@vueuse/core'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Tooltip } from 'floating-vue'
|
|
||||||
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
|
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
|
||||||
|
|
||||||
import { navigateTo } from '#app'
|
import { navigateTo } from '#app'
|
||||||
@@ -1122,6 +764,7 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
|
|||||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||||
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
||||||
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
||||||
|
import ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveButton.vue'
|
||||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||||
import { getSignInRouteObj } from '~/composables/auth.js'
|
import { getSignInRouteObj } from '~/composables/auth.js'
|
||||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||||
@@ -1200,6 +843,7 @@ const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.valu
|
|||||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||||
|
|
||||||
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
|
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
|
||||||
|
const modalCollection = useTemplateRef('modal_collection')
|
||||||
|
|
||||||
const baseId = useId()
|
const baseId = useId()
|
||||||
|
|
||||||
@@ -1630,13 +1274,8 @@ const filteredAlpha = computed(() => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const displayCollectionsSearch = ref('')
|
|
||||||
const collections = computed(() =>
|
const collections = computed(() =>
|
||||||
user.value && user.value.collections
|
user.value && user.value.collections ? user.value.collections : [],
|
||||||
? user.value.collections.filter((x) =>
|
|
||||||
x.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase()),
|
|
||||||
)
|
|
||||||
: [],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -2353,6 +1992,184 @@ const canCreateServerFrom = computed(() => {
|
|||||||
return project.value.project_type === 'modpack' && project.value.server_side !== 'unsupported'
|
return project.value.project_type === 'modpack' && project.value.server_side !== 'unsupported'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const projectHeaderActions = computed(() => {
|
||||||
|
if (!project.value) return []
|
||||||
|
|
||||||
|
const projectPath = `/${project.value.project_type}/${project.value.slug ? project.value.slug : project.value.id}`
|
||||||
|
const hasMember = !!currentMember.value
|
||||||
|
const userSignedIn = !!auth.value.user
|
||||||
|
const mutedPrimaryAction = hasMember || route.name === 'type-project-version-version'
|
||||||
|
const primaryLabel = isServerProject.value ? 'Play' : formatMessage(commonMessages.downloadButton)
|
||||||
|
|
||||||
|
return [
|
||||||
|
...(userSignedIn && hasMember
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'edit-project',
|
||||||
|
label: 'Edit project',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
color: 'brand',
|
||||||
|
to: `${projectPath}/settings`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: isServerProject.value ? 'play' : 'download',
|
||||||
|
label: primaryLabel,
|
||||||
|
icon: isServerProject.value ? PlayIcon : DownloadIcon,
|
||||||
|
color: mutedPrimaryAction ? 'standard' : 'brand',
|
||||||
|
labelHidden: userSignedIn && hasMember,
|
||||||
|
tooltip: userSignedIn && hasMember ? primaryLabel : undefined,
|
||||||
|
onClick: (event) => {
|
||||||
|
if (isServerProject.value) {
|
||||||
|
handlePlayServerProject()
|
||||||
|
} else {
|
||||||
|
downloadModal.value?.show(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(canCreateServerFrom.value && flags.value.showProjectPageQuickServerButton
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'create-server',
|
||||||
|
label: formatMessage(messages.serversPromoTitle),
|
||||||
|
icon: ServerPlusIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: formatMessage(messages.createServerTooltip),
|
||||||
|
to: `/hosting?project=${project.value.id}#plan`,
|
||||||
|
onClick: () => {
|
||||||
|
flags.value.showProjectPageCreateServersTooltip = false
|
||||||
|
saveFeatureFlags()
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
title: formatMessage(messages.serversPromoTitle),
|
||||||
|
description: formatMessage(messages.serversPromoDescription),
|
||||||
|
badge: formatMessage(commonMessages.newBadge),
|
||||||
|
footer: formatMessage(messages.serversPromoPricing, {
|
||||||
|
price: formatPrice(500, 'USD', true),
|
||||||
|
small: (children) => (Array.isArray(children) ? children.join('') : children),
|
||||||
|
}),
|
||||||
|
dismissLabel: formatMessage(messages.dontShowAgain),
|
||||||
|
shown: flags.value.showProjectPageCreateServersTooltip,
|
||||||
|
placement: 'bottom-start',
|
||||||
|
onDismiss: () => {
|
||||||
|
flags.value.showProjectPageCreateServersTooltip = false
|
||||||
|
saveFeatureFlags()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: 'follow',
|
||||||
|
label: following.value
|
||||||
|
? formatMessage(commonMessages.unfollowButton)
|
||||||
|
: formatMessage(commonMessages.followButton),
|
||||||
|
icon: HeartIcon,
|
||||||
|
iconProps: {
|
||||||
|
fill: following.value ? 'currentColor' : 'none',
|
||||||
|
},
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: following.value
|
||||||
|
? formatMessage(commonMessages.unfollowButton)
|
||||||
|
: formatMessage(commonMessages.followButton),
|
||||||
|
to: userSignedIn ? undefined : signInRouteObj.value,
|
||||||
|
onClick: userSignedIn ? () => userFollowProject(project.value) : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'save',
|
||||||
|
label: formatMessage(commonMessages.saveButton),
|
||||||
|
component: ProjectCollectionSaveButton,
|
||||||
|
componentProps: {
|
||||||
|
authUser: auth.value.user,
|
||||||
|
signInRoute: signInRouteObj.value,
|
||||||
|
projectId: project.value.id,
|
||||||
|
collections: collections.value,
|
||||||
|
saved: collections.value.some((x) => x.projects.includes(project.value.id)),
|
||||||
|
baseId,
|
||||||
|
noCollectionsLabel: formatMessage(messages.noCollectionsFound),
|
||||||
|
createNewCollectionLabel: formatMessage(messages.createNewCollection),
|
||||||
|
collectProject: onUserCollectProject,
|
||||||
|
createCollection: (event) => modalCollection.value?.show(event),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'more',
|
||||||
|
label: formatMessage(commonMessages.moreOptionsButton),
|
||||||
|
icon: MoreVerticalIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
type: 'transparent',
|
||||||
|
tooltip: formatMessage(commonMessages.moreOptionsButton),
|
||||||
|
menuActions: [
|
||||||
|
{
|
||||||
|
id: 'analytics',
|
||||||
|
label: formatMessage(commonMessages.analyticsButton),
|
||||||
|
icon: ChartIcon,
|
||||||
|
link: `${projectPath}/settings/analytics`,
|
||||||
|
shown: userSignedIn && hasMember,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: userSignedIn && hasMember,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'moderation-checklist',
|
||||||
|
label: formatMessage(messages.reviewProject),
|
||||||
|
icon: ScaleIcon,
|
||||||
|
action: openModerationChecklistFromMenu,
|
||||||
|
color: 'orange',
|
||||||
|
shown:
|
||||||
|
userSignedIn &&
|
||||||
|
tags.value.staffRoles.includes(auth.value.user.role) &&
|
||||||
|
!showModerationChecklist.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown:
|
||||||
|
userSignedIn &&
|
||||||
|
tags.value.staffRoles.includes(auth.value.user.role) &&
|
||||||
|
!showModerationChecklist.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tech-review',
|
||||||
|
label: 'Tech review',
|
||||||
|
icon: ScanEyeIcon,
|
||||||
|
link: `/moderation/technical-review/${project.value.id}`,
|
||||||
|
color: 'orange',
|
||||||
|
shown: userSignedIn && tags.value.staffRoles.includes(auth.value.user.role),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: userSignedIn && tags.value.staffRoles.includes(auth.value.user.role),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report',
|
||||||
|
label: formatMessage(commonMessages.reportButton),
|
||||||
|
icon: ReportIcon,
|
||||||
|
action: () =>
|
||||||
|
auth.value.user
|
||||||
|
? reportProject(project.value.id)
|
||||||
|
: navigateTo(getSignInRouteObj(route, getReportPath('project', project.value.id))),
|
||||||
|
color: 'red',
|
||||||
|
shown: !isMember.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-id',
|
||||||
|
label: formatMessage(commonMessages.copyIdButton),
|
||||||
|
icon: ClipboardCopyIcon,
|
||||||
|
action: () => copyId(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-permalink',
|
||||||
|
label: formatMessage(commonMessages.copyPermalinkButton),
|
||||||
|
icon: ClipboardCopyIcon,
|
||||||
|
action: () => copyPermalink(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const createCanonicalUrl = () =>
|
const createCanonicalUrl = () =>
|
||||||
project.value ? `https://modrinth.com/project/${project.value.id}` : undefined
|
project.value ? `https://modrinth.com/project/${project.value.id}` : undefined
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { commonProjectTypeCategoryMessages, NavTabs, useVIntl } from '@modrinth/
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
const flags = useFeatureFlags()
|
const flags = useFeatureFlags()
|
||||||
const cosmetics = useCosmetics()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const allowTabChanging = computed(() => !route.query.sid)
|
const allowTabChanging = computed(() => !route.query.sid)
|
||||||
@@ -48,15 +47,13 @@ const selectableProjectTypes = [
|
|||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
|
<div class="mx-auto box-border flex w-full max-w-[1280px] flex-col gap-4 px-6 pb-6">
|
||||||
<section class="normal-page__header mb-4 flex flex-col gap-4">
|
<NavTabs
|
||||||
<NavTabs
|
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
|
||||||
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
|
:links="selectableProjectTypes"
|
||||||
:links="selectableProjectTypes"
|
replace
|
||||||
replace
|
class="hidden md:flex"
|
||||||
class="hidden md:flex"
|
/>
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
<NuxtPage />
|
<NuxtPage />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -54,9 +54,6 @@ const client = injectModrinthClient()
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const filtersMenuOpen = ref(false)
|
const filtersMenuOpen = ref(false)
|
||||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
|
||||||
|
|
||||||
useStickyObserver(stickyInstallHeaderRef, 'DiscoverInstallHeader')
|
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
@@ -175,6 +172,11 @@ const {
|
|||||||
onboardingModalRef,
|
onboardingModalRef,
|
||||||
debug,
|
debug,
|
||||||
})
|
})
|
||||||
|
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||||
|
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||||
|
stickyInstallHeaderRef,
|
||||||
|
'DiscoverInstallHeader',
|
||||||
|
)
|
||||||
|
|
||||||
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
|
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||||
const content = project.minecraft_java_server?.content
|
const content = project.minecraft_java_server?.content
|
||||||
@@ -198,49 +200,66 @@ function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
async function search(requestParams: string) {
|
type DiscoverProjectSearchHit = Labrinth.Search.v2.ResultSearchProject & {
|
||||||
|
version_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapV3ProjectHit(hit: Labrinth.Search.v3.ResultSearchProject): DiscoverProjectSearchHit {
|
||||||
|
return {
|
||||||
|
...hit,
|
||||||
|
project_type: hit.project_types[0] ?? projectTypeId.value,
|
||||||
|
title: hit.name,
|
||||||
|
description: hit.summary,
|
||||||
|
versions: hit.version_id ? [hit.version_id] : [],
|
||||||
|
latest_version: hit.version_id,
|
||||||
|
icon_url: hit.icon_url ?? '',
|
||||||
|
client_side: 'unknown',
|
||||||
|
server_side: 'unknown',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSearch(requestParams: string) {
|
||||||
debug('search() called', {
|
debug('search() called', {
|
||||||
requestParams: requestParams.substring(0, 100),
|
requestParams: requestParams.substring(0, 100),
|
||||||
isServer: isServerType.value,
|
isServer: isServerType.value,
|
||||||
projectTypeId: projectTypeId.value,
|
projectTypeId: projectTypeId.value,
|
||||||
})
|
})
|
||||||
const config = useRuntimeConfig()
|
|
||||||
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
|
||||||
|
|
||||||
if (isServerType.value) {
|
const raw = await client.request<Labrinth.Search.v3.SearchResults>('/search', {
|
||||||
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
|
api: 'labrinth',
|
||||||
}
|
version: 3,
|
||||||
|
method: 'GET',
|
||||||
const url = `${base}search${requestParams}`
|
params: Object.fromEntries(new URLSearchParams(requestParams.replace(/^\?/, ''))),
|
||||||
debug('search() fetching:', url.substring(0, 120))
|
headers: withLabrinthCanaryHeader(),
|
||||||
|
})
|
||||||
const raw = await $fetch<Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults>(
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
headers: withLabrinthCanaryHeader(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length })
|
debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length })
|
||||||
|
|
||||||
if ('hits_per_page' in raw) {
|
if (isServerType.value) {
|
||||||
// v3 response (servers)
|
|
||||||
return {
|
return {
|
||||||
projectHits: [],
|
projectHits: [],
|
||||||
serverHits: raw.hits as Labrinth.Search.v3.ResultSearchProject[],
|
serverHits: raw.hits,
|
||||||
total_hits: raw.total_hits,
|
total_hits: raw.total_hits,
|
||||||
per_page: raw.hits_per_page,
|
per_page: raw.hits_per_page,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projectHits: raw.hits as Labrinth.Search.v2.ResultSearchProject[],
|
projectHits: raw.hits.map(mapV3ProjectHit),
|
||||||
serverHits: [],
|
serverHits: [],
|
||||||
total_hits: raw.total_hits,
|
total_hits: raw.total_hits,
|
||||||
per_page: raw.limit,
|
per_page: raw.hits_per_page,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function search(requestParams: string) {
|
||||||
|
return await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['discover', 'search', 'v3', requestParams],
|
||||||
|
queryFn: () => fetchSearch(requestParams),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function getCardActions(
|
function getCardActions(
|
||||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||||
currentProjectType: string,
|
currentProjectType: string,
|
||||||
@@ -412,7 +431,7 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
debug('calling initial refreshSearch')
|
debug('calling initial refreshSearch')
|
||||||
searchState.refreshSearch()
|
await searchState.refreshSearch()
|
||||||
|
|
||||||
const ogTitle = computed(() =>
|
const ogTitle = computed(() =>
|
||||||
searchState.query.value
|
searchState.query.value
|
||||||
@@ -480,20 +499,30 @@ provideBrowseManager({
|
|||||||
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
|
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
|
||||||
<div class="search-background"></div>
|
<div class="search-background"></div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="installContext"
|
v-if="installContext"
|
||||||
ref="stickyInstallHeaderRef"
|
ref="stickyInstallHeaderRef"
|
||||||
class="normal-page__header browse-install-header-bleed sticky top-0 z-20 mb-4 flex flex-col gap-2 border-0 bg-surface-1 py-3"
|
class="sticky top-0 z-20 -mx-6 border-0 border-solid border-divider bg-surface-1 px-6 pt-4"
|
||||||
|
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
||||||
>
|
>
|
||||||
<BrowseInstallHeader />
|
<BrowseInstallHeader divider bottom-padding />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" />
|
<SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" />
|
||||||
<aside class="normal-page__sidebar" :aria-label="formatMessage(commonMessages.filtersLabel)">
|
|
||||||
<AdPlaceholder v-if="!auth.user && !serverData" />
|
<div
|
||||||
<BrowseSidebar />
|
class="grid min-w-0 gap-3"
|
||||||
</aside>
|
:class="
|
||||||
<section class="normal-page__content">
|
cosmetics.rightSearchLayout
|
||||||
<div class="flex flex-col gap-3">
|
? 'lg:grid-cols-[minmax(0,1fr)_18.75rem]'
|
||||||
|
: 'lg:grid-cols-[18.75rem_minmax(0,1fr)]'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<section
|
||||||
|
class="flex min-w-0 flex-col gap-3"
|
||||||
|
:class="cosmetics.rightSearchLayout ? 'lg:order-1' : 'lg:order-2'"
|
||||||
|
>
|
||||||
<BrowsePageLayout>
|
<BrowsePageLayout>
|
||||||
<template #display-mode-icon>
|
<template #display-mode-icon>
|
||||||
<GridIcon v-if="resultsDisplayMode === 'grid'" />
|
<GridIcon v-if="resultsDisplayMode === 'grid'" />
|
||||||
@@ -501,78 +530,34 @@ provideBrowseManager({
|
|||||||
<ListIcon v-else />
|
<ListIcon v-else />
|
||||||
</template>
|
</template>
|
||||||
</BrowsePageLayout>
|
</BrowsePageLayout>
|
||||||
<CreationFlowModal
|
</section>
|
||||||
v-if="currentServerId && projectType?.id === 'modpack'"
|
|
||||||
ref="onboardingModalRef"
|
<aside
|
||||||
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
|
class="min-w-0"
|
||||||
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
|
:class="cosmetics.rightSearchLayout ? 'lg:order-2' : 'lg:order-1'"
|
||||||
:show-snapshot-toggle="true"
|
:aria-label="formatMessage(commonMessages.filtersLabel)"
|
||||||
:on-back="onOnboardingBack"
|
>
|
||||||
@hide="onOnboardingHide"
|
<BrowseSidebar>
|
||||||
@browse-modpacks="() => {}"
|
<template #prepend>
|
||||||
@create="onModpackFlowCreate"
|
<AdPlaceholder v-if="!auth.user && !serverData" />
|
||||||
/>
|
</template>
|
||||||
</div>
|
</BrowseSidebar>
|
||||||
</section>
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CreationFlowModal
|
||||||
|
v-if="currentServerId && projectType?.id === 'modpack'"
|
||||||
|
ref="onboardingModalRef"
|
||||||
|
:type="fromContext === 'reset-server' ? 'reset-server' : 'server-onboarding'"
|
||||||
|
:available-loaders="['vanilla', 'fabric', 'neoforge', 'forge', 'quilt', 'paper', 'purpur']"
|
||||||
|
:show-snapshot-toggle="true"
|
||||||
|
:on-back="onOnboardingBack"
|
||||||
|
@hide="onOnboardingHide"
|
||||||
|
@browse-modpacks="() => {}"
|
||||||
|
@create="onModpackFlowCreate"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.browse-install-header-bleed {
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
margin-inline: -1.5rem;
|
|
||||||
padding-inline: 0.75rem !important;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
right: 50%;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100vw;
|
|
||||||
border-bottom: 1px solid var(--surface-5);
|
|
||||||
transform: translateX(50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.normal-page__content {
|
|
||||||
display: contents;
|
|
||||||
|
|
||||||
@media screen and (min-width: 1024px) {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.normal-page__sidebar {
|
|
||||||
grid-row: 3;
|
|
||||||
|
|
||||||
@media screen and (min-width: 1024px) {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.filters-card {
|
|
||||||
padding: var(--spacing-card-md);
|
|
||||||
|
|
||||||
@media screen and (min-width: 1024px) {
|
|
||||||
padding: var(--spacing-card-lg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.content-wrapper {
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination-after {
|
|
||||||
grid-row: 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-results {
|
|
||||||
text-align: center;
|
|
||||||
display: flow-root;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-logo {
|
|
||||||
margin: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-background {
|
.search-background {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 20rem;
|
height: 20rem;
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
:server-id="serverId"
|
:server-id="serverId"
|
||||||
:reload-page="() => reloadNuxtApp({ path: route.path })"
|
:reload-page="() => reloadNuxtApp({ path: route.path })"
|
||||||
:resolve-viewer="resolveViewer"
|
:resolve-viewer="resolveViewer"
|
||||||
:show-copy-id-action="flags.developerMode"
|
|
||||||
:show-advanced-debug-info="flags.advancedDebugInfo"
|
:show-advanced-debug-info="flags.advancedDebugInfo"
|
||||||
:stripe-publishable-key="config.public.stripePublishableKey as string"
|
:stripe-publishable-key="config.public.stripePublishableKey as string"
|
||||||
:site-url="config.public.siteUrl as string"
|
:site-url="config.public.siteUrl as string"
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { injectModrinthClient } from '@modrinth/ui'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
|
|
||||||
|
const route = useNativeRoute()
|
||||||
|
const serverId = route.params.id as string
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
try {
|
||||||
|
await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||||
|
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtPage :route="route" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { injectModrinthClient, ServersManageInstanceRootLayout } from '@modrinth/ui'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
|
|
||||||
|
const route = useNativeRoute()
|
||||||
|
const serverId = route.params.id as string
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
definePageMeta({
|
||||||
|
middleware: 'server-instance-ready',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (serverId) {
|
||||||
|
try {
|
||||||
|
await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||||
|
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ServersManageInstanceRootLayout>
|
||||||
|
<NuxtPage :route="route" />
|
||||||
|
</ServersManageInstanceRootLayout>
|
||||||
|
</template>
|
||||||
+1
-1
@@ -14,7 +14,7 @@ const flags = useFeatureFlags()
|
|||||||
if (worldId.value) {
|
if (worldId.value) {
|
||||||
try {
|
try {
|
||||||
await queryClient.ensureQueryData({
|
await queryClient.ensureQueryData({
|
||||||
queryKey: ['backups', 'list', serverId],
|
queryKey: ['backups', 'list', serverId, worldId.value],
|
||||||
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
+4
-2
@@ -10,11 +10,13 @@ const client = injectModrinthClient()
|
|||||||
const { server, serverId } = injectModrinthServerContext()
|
const { server, serverId } = injectModrinthServerContext()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const flags = useFeatureFlags()
|
const flags = useFeatureFlags()
|
||||||
|
const route = useNativeRoute()
|
||||||
|
const initialPath = typeof route.query.path === 'string' ? route.query.path : '/'
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await queryClient.ensureQueryData({
|
await queryClient.ensureQueryData({
|
||||||
queryKey: ['files', serverId, '/'],
|
queryKey: ['files', serverId, initialPath],
|
||||||
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000),
|
queryFn: () => client.kyros.files_v0.listDirectory(initialPath, 1, 2000),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
+1
-1
@@ -38,7 +38,7 @@ const contentWorldId = await getContentWorldId()
|
|||||||
if (contentWorldId) {
|
if (contentWorldId) {
|
||||||
try {
|
try {
|
||||||
const content = await queryClient.ensureQueryData({
|
const content = await queryClient.ensureQueryData({
|
||||||
queryKey: ['content', 'list', 'v1', serverId],
|
queryKey: ['content', 'list', 'v1', serverId, contentWorldId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
client.archon.content_v1.getAddons(serverId, contentWorldId, { from_modpack: false }),
|
client.archon.content_v1.getAddons(serverId, contentWorldId, { from_modpack: false }),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Archon } from '@modrinth/api-client'
|
||||||
|
import {
|
||||||
|
commonMessages,
|
||||||
|
defineMessages,
|
||||||
|
formatLoaderLabel,
|
||||||
|
injectModrinthClient,
|
||||||
|
injectModrinthServerContext,
|
||||||
|
ServersManageInstancesPage,
|
||||||
|
useVIntl,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
|
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const { server, serverId, isServerRunning } = injectModrinthServerContext()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: {
|
||||||
|
id: 'servers.manage.instances.meta.title',
|
||||||
|
defaultMessage: 'Instances - {server} - Modrinth',
|
||||||
|
},
|
||||||
|
instanceSlotName: {
|
||||||
|
id: 'servers.manage.instances.slot-name',
|
||||||
|
defaultMessage: 'Instance #{index}',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type LinkedModpack = {
|
||||||
|
name: string
|
||||||
|
iconUrl: string | null
|
||||||
|
link: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorldSlot =
|
||||||
|
| {
|
||||||
|
type: 'world'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
active: boolean
|
||||||
|
gameVersion: string | null
|
||||||
|
loaderLabel: string | null
|
||||||
|
linkedModpack: LinkedModpack | null
|
||||||
|
installedContentCount: number | null
|
||||||
|
lastActiveAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'empty'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentSummary = {
|
||||||
|
gameVersion: string | null
|
||||||
|
loader: string | null
|
||||||
|
loaderVersion: string | null
|
||||||
|
linkedModpack: LinkedModpack | null
|
||||||
|
installedContentCount: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORLD_SLOT_COUNT = 3
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queryClient.ensureQueryData({
|
||||||
|
queryKey: ['servers', 'worlds', 'summary', 'v1', serverId],
|
||||||
|
queryFn: loadWorldSlots,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||||
|
}
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
title: () =>
|
||||||
|
formatMessage(messages.title, {
|
||||||
|
server: server.value?.name ?? formatMessage(commonMessages.serverLabel),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadWorldSlots(): Promise<WorldSlot[]> {
|
||||||
|
const serverFull = await client.archon.servers_v1.get(serverId)
|
||||||
|
const slots = await Promise.all(
|
||||||
|
serverFull.worlds.map(async (world, index) => {
|
||||||
|
const content = await loadContentSummary(world, index)
|
||||||
|
return toWorldSlot(world, content)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return padWorldSlots(slots)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContentSummary(
|
||||||
|
world: Archon.Servers.v1.WorldFull,
|
||||||
|
index: number,
|
||||||
|
): Promise<ContentSummary> {
|
||||||
|
try {
|
||||||
|
const content = await client.archon.content_v1.getAddons(serverId, world.id, {
|
||||||
|
addons: true,
|
||||||
|
updates: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameVersion: content.game_version ?? world.content?.game_version ?? null,
|
||||||
|
loader: content.modloader ?? world.content?.modloader ?? null,
|
||||||
|
loaderVersion: content.modloader_version ?? world.content?.modloader_version ?? null,
|
||||||
|
linkedModpack: getLinkedModpack(content.modpack),
|
||||||
|
installedContentCount: content.addons?.length ?? 0,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return createDummyContentSummary(world, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWorldSlot(world: Archon.Servers.v1.WorldFull, content: ContentSummary): WorldSlot {
|
||||||
|
return {
|
||||||
|
type: 'world',
|
||||||
|
id: world.id,
|
||||||
|
name: world.name,
|
||||||
|
active: world.is_active,
|
||||||
|
gameVersion: content.gameVersion,
|
||||||
|
loaderLabel: getLoaderLabel(content.loader, content.loaderVersion),
|
||||||
|
linkedModpack: content.linkedModpack,
|
||||||
|
installedContentCount: content.installedContentCount,
|
||||||
|
lastActiveAt: getLatestKnownActivity(world),
|
||||||
|
createdAt: world.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function padWorldSlots(slots: WorldSlot[]): WorldSlot[] {
|
||||||
|
const padded = [...slots]
|
||||||
|
for (let i = padded.length; i < WORLD_SLOT_COUNT; i++) {
|
||||||
|
padded.push({
|
||||||
|
type: 'empty',
|
||||||
|
id: `empty-world-slot-${i + 1}`,
|
||||||
|
name: formatMessage(messages.instanceSlotName, { index: i + 1 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkedModpack(modpack: Archon.Content.v1.ModpackFields | null): LinkedModpack | null {
|
||||||
|
if (!modpack) return null
|
||||||
|
|
||||||
|
const name =
|
||||||
|
modpack.title ??
|
||||||
|
(modpack.spec.platform === 'local_file' ? modpack.spec.name : modpack.spec.project_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
iconUrl: modpack.icon_url ?? null,
|
||||||
|
link: modpack.spec.platform === 'modrinth' ? `/project/${modpack.spec.project_id}` : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoaderLabel(loader: string | null, loaderVersion: string | null): string | null {
|
||||||
|
if (!loader) return null
|
||||||
|
const normalizedLoader = loader.toLowerCase()
|
||||||
|
return [formatLoaderLabel(normalizedLoader), loaderVersion].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestKnownActivity(world: Archon.Servers.v1.WorldFull): string | null {
|
||||||
|
const latestBackup = latestDate(world.backups.map((backup) => backup.created_at))
|
||||||
|
if (latestBackup) return latestBackup
|
||||||
|
if (world.is_active && isServerRunning.value) return new Date().toISOString()
|
||||||
|
return world.created_at ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestDate(dates: string[]): string | null {
|
||||||
|
let latest = 0
|
||||||
|
let latestIso: string | null = null
|
||||||
|
for (const date of dates) {
|
||||||
|
const timestamp = new Date(date).getTime()
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= latest) continue
|
||||||
|
latest = timestamp
|
||||||
|
latestIso = date
|
||||||
|
}
|
||||||
|
return latestIso
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDummyContentSummary(
|
||||||
|
world: Archon.Servers.v1.WorldFull,
|
||||||
|
index: number,
|
||||||
|
): ContentSummary {
|
||||||
|
const gameVersion = world.content?.game_version ?? server.value?.mc_version ?? '1.20.4'
|
||||||
|
const loader = world.content?.modloader ?? server.value?.loader?.toLowerCase() ?? 'fabric'
|
||||||
|
const loaderVersion = world.content?.modloader_version ?? server.value?.loader_version ?? '0.16.6'
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
return {
|
||||||
|
gameVersion,
|
||||||
|
loader,
|
||||||
|
loaderVersion,
|
||||||
|
linkedModpack: {
|
||||||
|
name: 'Cobblemon Official',
|
||||||
|
iconUrl: null,
|
||||||
|
link: null,
|
||||||
|
},
|
||||||
|
installedContentCount: 47,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameVersion,
|
||||||
|
loader,
|
||||||
|
loaderVersion,
|
||||||
|
linkedModpack: null,
|
||||||
|
installedContentCount: 13,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ServersManageInstancesPage />
|
||||||
|
</template>
|
||||||
@@ -62,86 +62,14 @@
|
|||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="normal-page__header py-4">
|
<div class="normal-page__header py-4">
|
||||||
<ContentPageHeader>
|
<PageHeader
|
||||||
<template #icon>
|
:header="organization.name"
|
||||||
<Avatar :src="organization.icon_url" :alt="organization.name" size="96px" />
|
:summary="organization.description"
|
||||||
</template>
|
:leading="organizationHeaderLeading"
|
||||||
<template #title>
|
:badges="organizationHeaderBadges"
|
||||||
{{ organization.name }}
|
:metadata="organizationHeaderMetadata"
|
||||||
</template>
|
:actions="organizationHeaderActions"
|
||||||
<template #title-suffix>
|
/>
|
||||||
<div class="ml-1 flex items-center gap-2 font-semibold">
|
|
||||||
<OrganizationIcon />
|
|
||||||
Organization
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #summary>
|
|
||||||
{{ organization.description }}
|
|
||||||
</template>
|
|
||||||
<template #stats>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
|
||||||
>
|
|
||||||
<UsersIcon class="h-6 w-6 text-secondary" />
|
|
||||||
{{ formatCompactNumber(acceptedMembers?.length || 0) }}
|
|
||||||
members
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
|
||||||
>
|
|
||||||
<BoxIcon class="h-6 w-6 text-secondary" />
|
|
||||||
{{ formatCompactNumber(projects?.length || 0) }}
|
|
||||||
projects
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-tooltip="formatNumber(sumDownloads)"
|
|
||||||
class="flex items-center gap-2 font-semibold"
|
|
||||||
>
|
|
||||||
<DownloadIcon class="h-6 w-6 text-secondary" />
|
|
||||||
{{ formatCompactNumber(sumDownloads) }}
|
|
||||||
downloads
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<ButtonStyled v-if="auth.user && currentMember" size="large">
|
|
||||||
<NuxtLink :to="`/organization/${organization.slug}/settings`">
|
|
||||||
<SettingsIcon aria-hidden="true" />
|
|
||||||
Manage
|
|
||||||
</NuxtLink>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled size="large" circular type="transparent">
|
|
||||||
<OverflowMenu
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'manage-projects',
|
|
||||||
action: () =>
|
|
||||||
router.push('/organization/' + organization?.slug + '/settings/projects'),
|
|
||||||
hoverFilledOnly: true,
|
|
||||||
shown: !!(auth.user && currentMember),
|
|
||||||
},
|
|
||||||
{ divider: true, shown: !!(auth?.user && currentMember) },
|
|
||||||
{ id: 'copy-id', action: () => copyId() },
|
|
||||||
{ id: 'copy-permalink', action: () => copyPermalink() },
|
|
||||||
]"
|
|
||||||
aria-label="More options"
|
|
||||||
>
|
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
|
||||||
<template #manage-projects>
|
|
||||||
<BoxIcon aria-hidden="true" />
|
|
||||||
Manage projects
|
|
||||||
</template>
|
|
||||||
<template #copy-id>
|
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
|
||||||
</template>
|
|
||||||
<template #copy-permalink>
|
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.copyPermalinkButton) }}
|
|
||||||
</template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</ContentPageHeader>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="normal-page__sidebar">
|
<div class="normal-page__sidebar">
|
||||||
<AdPlaceholder v-if="!auth.user" />
|
<AdPlaceholder v-if="!auth.user" />
|
||||||
@@ -303,10 +231,9 @@ import {
|
|||||||
Avatar,
|
Avatar,
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
ContentPageHeader,
|
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
NavTabs,
|
NavTabs,
|
||||||
OverflowMenu,
|
PageHeader,
|
||||||
PROJECT_DEP_MARKER_QUERY,
|
PROJECT_DEP_MARKER_QUERY,
|
||||||
ProjectCard,
|
ProjectCard,
|
||||||
ProjectCardList,
|
ProjectCardList,
|
||||||
@@ -535,6 +462,93 @@ provideOrganizationContext(organizationContext)
|
|||||||
|
|
||||||
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
|
const canAccessSettings = computed(() => !!currentMember.value?.accepted)
|
||||||
|
|
||||||
|
const organizationHeaderLeading = computed(() => ({
|
||||||
|
type: 'avatar' as const,
|
||||||
|
src: organization.value?.icon_url,
|
||||||
|
alt: organization.value?.name,
|
||||||
|
avatarSize: '96px',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const organizationHeaderBadges = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'organization',
|
||||||
|
label: 'Organization',
|
||||||
|
icon: OrganizationIcon,
|
||||||
|
class: 'px-0 text-primary',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const organizationHeaderMetadata = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'members',
|
||||||
|
label: `${formatCompactNumber(acceptedMembers.value?.length || 0)} members`,
|
||||||
|
icon: UsersIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'projects',
|
||||||
|
label: `${formatCompactNumber(projects.value?.length || 0)} projects`,
|
||||||
|
icon: BoxIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'downloads',
|
||||||
|
label: `${formatCompactNumber(sumDownloads.value)} downloads`,
|
||||||
|
icon: DownloadIcon,
|
||||||
|
tooltip: formatNumber(sumDownloads.value),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const organizationHeaderActions = computed(() => [
|
||||||
|
...(auth.value.user && currentMember.value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'manage',
|
||||||
|
label: 'Manage',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
to: `/organization/${organization.value?.slug}/settings`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: 'more',
|
||||||
|
label: 'More options',
|
||||||
|
icon: MoreVerticalIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
type: 'transparent' as const,
|
||||||
|
tooltip: 'More options',
|
||||||
|
menuActions: [
|
||||||
|
{
|
||||||
|
id: 'manage-projects',
|
||||||
|
label: 'Manage projects',
|
||||||
|
icon: BoxIcon,
|
||||||
|
action: () => {
|
||||||
|
void router.push(`/organization/${organization.value?.slug}/settings/projects`)
|
||||||
|
},
|
||||||
|
shown: !!(auth.value.user && currentMember.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: !!(auth.value.user && currentMember.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-id',
|
||||||
|
label: formatMessage(commonMessages.copyIdButton),
|
||||||
|
icon: ClipboardCopyIcon,
|
||||||
|
action: () => {
|
||||||
|
void copyId()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-permalink',
|
||||||
|
label: formatMessage(commonMessages.copyPermalinkButton),
|
||||||
|
icon: ClipboardCopyIcon,
|
||||||
|
action: () => {
|
||||||
|
void copyPermalink()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[routeHasSettings, acceptedMembers, currentMember],
|
[routeHasSettings, acceptedMembers, currentMember],
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -120,164 +120,14 @@
|
|||||||
</NewModal>
|
</NewModal>
|
||||||
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
|
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
|
||||||
<div class="normal-page__header py-4">
|
<div class="normal-page__header py-4">
|
||||||
<ContentPageHeader>
|
<PageHeader
|
||||||
<template #icon>
|
:header="user.username"
|
||||||
<Avatar :src="user.avatar_url" :alt="user.username" size="96px" circle />
|
:summary="profileHeaderSummary"
|
||||||
</template>
|
:leading="profileHeaderLeading"
|
||||||
<template #title>
|
:badges="profileHeaderBadges"
|
||||||
<span class="flex items-center gap-2">
|
:metadata="profileHeaderMetadata"
|
||||||
{{ user.username }}
|
:actions="profileHeaderActions"
|
||||||
<TagItem
|
/>
|
||||||
v-if="isAdminViewing && isAffiliate"
|
|
||||||
:style="{
|
|
||||||
'--_color': 'var(--color-brand)',
|
|
||||||
'--_bg-color': 'var(--color-brand-highlight)',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<AffiliateIcon /> Affiliate
|
|
||||||
</TagItem>
|
|
||||||
</span>
|
|
||||||
</template>
|
|
||||||
<template #summary>
|
|
||||||
{{
|
|
||||||
user.bio
|
|
||||||
? user.bio
|
|
||||||
: projects.length === 0
|
|
||||||
? formatMessage(messages.bioFallbackUser)
|
|
||||||
: formatMessage(messages.bioFallbackCreator)
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
<template #stats>
|
|
||||||
<div
|
|
||||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
|
||||||
>
|
|
||||||
<BoxIcon class="h-6 w-6 text-secondary" />
|
|
||||||
{{
|
|
||||||
formatMessage(messages.profileProjectsLabel, {
|
|
||||||
count: formatCompactNumber(projects?.length || 0),
|
|
||||||
countPlural: formatCompactNumberPlural(projects?.length || 0),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-tooltip="formatNumber(sumDownloads)"
|
|
||||||
class="flex items-center gap-2 border-0 border-r border-solid border-divider pr-4 font-semibold"
|
|
||||||
>
|
|
||||||
<DownloadIcon class="h-6 w-6 text-secondary" />
|
|
||||||
{{
|
|
||||||
formatMessage(messages.profileDownloadsLabel, {
|
|
||||||
count: formatCompactNumber(sumDownloads),
|
|
||||||
countPlural: formatCompactNumberPlural(sumDownloads),
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-tooltip="formatDateTime(user.created)"
|
|
||||||
class="flex items-center gap-2 font-semibold"
|
|
||||||
>
|
|
||||||
<CalendarIcon class="h-6 w-6 text-secondary" />
|
|
||||||
{{ formatMessage(messages.profileJoinedLabel) }}
|
|
||||||
{{ formatRelativeTime(user.created) }}
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<ButtonStyled size="large">
|
|
||||||
<NuxtLink v-if="auth.user && auth.user.id === user.id" to="/settings/profile">
|
|
||||||
<EditIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.editButton) }}
|
|
||||||
</NuxtLink>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled size="large" circular type="transparent">
|
|
||||||
<OverflowMenu
|
|
||||||
:options="[
|
|
||||||
{
|
|
||||||
id: 'manage-projects',
|
|
||||||
action: () => navigateTo('/dashboard/projects'),
|
|
||||||
hoverOnly: true,
|
|
||||||
shown: auth.user && auth.user.id === user.id,
|
|
||||||
},
|
|
||||||
{ divider: true, shown: auth.user && auth.user.id === user.id },
|
|
||||||
{
|
|
||||||
id: 'report',
|
|
||||||
action: () =>
|
|
||||||
auth.user ? reportUser(user.id) : navigateTo(getSignInRouteObj(route)),
|
|
||||||
color: 'red',
|
|
||||||
hoverOnly: true,
|
|
||||||
shown: auth.user?.id !== user.id,
|
|
||||||
},
|
|
||||||
{ id: 'copy-id', action: () => copyId() },
|
|
||||||
{ id: 'copy-permalink', action: () => copyPermalink() },
|
|
||||||
{
|
|
||||||
divider: true,
|
|
||||||
shown: auth.user && isAdmin(auth.user),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'open-billing',
|
|
||||||
action: () => navigateTo(`/admin/billing/${user.id}`),
|
|
||||||
shown: auth.user && isStaff(auth.user),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'toggle-affiliate',
|
|
||||||
action: () => toggleAffiliate(user.id),
|
|
||||||
shown: isAdminViewing,
|
|
||||||
remainOnClick: true,
|
|
||||||
color: isAffiliate ? 'red' : 'orange',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'open-info',
|
|
||||||
action: () => $refs.userDetailsModal.show(),
|
|
||||||
shown: auth.user && isStaff(auth.user),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edit-role',
|
|
||||||
action: () => openRoleEditModal(),
|
|
||||||
shown: auth.user && isAdmin(auth.user),
|
|
||||||
},
|
|
||||||
]"
|
|
||||||
aria-label="More options"
|
|
||||||
:dropdown-id="`${baseId}-more-options`"
|
|
||||||
>
|
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
|
||||||
<template #manage-projects>
|
|
||||||
<BoxIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(messages.profileManageProjectsButton) }}
|
|
||||||
</template>
|
|
||||||
<template #report>
|
|
||||||
<ReportIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.reportButton) }}
|
|
||||||
</template>
|
|
||||||
<template #copy-id>
|
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.copyIdButton) }}
|
|
||||||
</template>
|
|
||||||
<template #copy-permalink>
|
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.copyPermalinkButton) }}
|
|
||||||
</template>
|
|
||||||
<template #open-billing>
|
|
||||||
<CurrencyIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(messages.billingButton) }}
|
|
||||||
</template>
|
|
||||||
<template #open-info>
|
|
||||||
<InfoIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(messages.infoButton) }}
|
|
||||||
</template>
|
|
||||||
<template #toggle-affiliate>
|
|
||||||
<AffiliateIcon aria-hidden="true" />
|
|
||||||
{{
|
|
||||||
formatMessage(
|
|
||||||
isAffiliate ? messages.removeAffiliateButton : messages.setAffiliateButton,
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
<template #edit-role>
|
|
||||||
<EditIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(messages.editRoleButton) }}
|
|
||||||
</template>
|
|
||||||
</OverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</ContentPageHeader>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
|
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
|
||||||
@@ -489,17 +339,15 @@ import {
|
|||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
Combobox,
|
Combobox,
|
||||||
commonMessages,
|
commonMessages,
|
||||||
ContentPageHeader,
|
|
||||||
defineMessages,
|
defineMessages,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
IntlFormatted,
|
IntlFormatted,
|
||||||
NavTabs,
|
NavTabs,
|
||||||
NewModal,
|
NewModal,
|
||||||
OverflowMenu,
|
PageHeader,
|
||||||
ProjectCard,
|
ProjectCard,
|
||||||
ProjectCardList,
|
ProjectCardList,
|
||||||
TagItem,
|
|
||||||
useCompactNumber,
|
useCompactNumber,
|
||||||
useFormatDateTime,
|
useFormatDateTime,
|
||||||
useFormatNumber,
|
useFormatNumber,
|
||||||
@@ -543,8 +391,6 @@ const formatDateTime = useFormatDateTime({
|
|||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
|
||||||
const baseId = useId()
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
profileProjectsLabel: {
|
profileProjectsLabel: {
|
||||||
id: 'profile.label.projects',
|
id: 'profile.label.projects',
|
||||||
@@ -864,12 +710,165 @@ async function copyPermalink() {
|
|||||||
|
|
||||||
const isAffiliate = computed(() => user.value?.badges & UserBadge.AFFILIATE)
|
const isAffiliate = computed(() => user.value?.badges & UserBadge.AFFILIATE)
|
||||||
const isAdminViewing = computed(() => isAdmin(auth.value.user))
|
const isAdminViewing = computed(() => isAdmin(auth.value.user))
|
||||||
|
const userDetailsModal = useTemplateRef('userDetailsModal')
|
||||||
|
|
||||||
async function toggleAffiliate(id) {
|
async function toggleAffiliate(id) {
|
||||||
await client.labrinth.users_v2.patch(id, { badges: user.value.badges ^ (1 << 7) })
|
await client.labrinth.users_v2.patch(id, { badges: user.value.badges ^ (1 << 7) })
|
||||||
queryClient.invalidateQueries({ queryKey: ['user', userId] })
|
queryClient.invalidateQueries({ queryKey: ['user', userId] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileHeaderSummary = computed(() =>
|
||||||
|
user.value?.bio
|
||||||
|
? user.value.bio
|
||||||
|
: (projects.value?.length ?? 0) === 0
|
||||||
|
? formatMessage(messages.bioFallbackUser)
|
||||||
|
: formatMessage(messages.bioFallbackCreator),
|
||||||
|
)
|
||||||
|
|
||||||
|
const profileHeaderLeading = computed(() => ({
|
||||||
|
type: 'avatar',
|
||||||
|
src: user.value?.avatar_url,
|
||||||
|
alt: user.value?.username,
|
||||||
|
avatarSize: '96px',
|
||||||
|
circle: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const profileHeaderBadges = computed(() =>
|
||||||
|
isAdminViewing.value && isAffiliate.value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'affiliate',
|
||||||
|
label: 'Affiliate',
|
||||||
|
icon: AffiliateIcon,
|
||||||
|
class: 'border-brand-highlight bg-brand-highlight text-brand',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const profileHeaderMetadata = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'projects',
|
||||||
|
label: formatMessage(messages.profileProjectsLabel, {
|
||||||
|
count: formatCompactNumber(projects.value?.length || 0),
|
||||||
|
countPlural: formatCompactNumberPlural(projects.value?.length || 0),
|
||||||
|
}),
|
||||||
|
icon: BoxIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'downloads',
|
||||||
|
label: formatMessage(messages.profileDownloadsLabel, {
|
||||||
|
count: formatCompactNumber(sumDownloads.value),
|
||||||
|
countPlural: formatCompactNumberPlural(sumDownloads.value),
|
||||||
|
}),
|
||||||
|
icon: DownloadIcon,
|
||||||
|
tooltip: formatNumber(sumDownloads.value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'joined',
|
||||||
|
label: `${formatMessage(messages.profileJoinedLabel)} ${formatRelativeTime(user.value.created)}`,
|
||||||
|
icon: CalendarIcon,
|
||||||
|
tooltip: formatDateTime(user.value.created),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const profileHeaderActions = computed(() => {
|
||||||
|
if (!user.value) return []
|
||||||
|
|
||||||
|
const viewer = auth.value.user
|
||||||
|
const isSelf = viewer?.id === user.value.id
|
||||||
|
|
||||||
|
return [
|
||||||
|
...(isSelf
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'edit-profile',
|
||||||
|
label: formatMessage(commonMessages.editButton),
|
||||||
|
icon: EditIcon,
|
||||||
|
to: '/settings/profile',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
id: 'more',
|
||||||
|
label: 'More options',
|
||||||
|
icon: MoreVerticalIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
type: 'transparent',
|
||||||
|
tooltip: 'More options',
|
||||||
|
menuActions: [
|
||||||
|
{
|
||||||
|
id: 'manage-projects',
|
||||||
|
label: formatMessage(messages.profileManageProjectsButton),
|
||||||
|
icon: BoxIcon,
|
||||||
|
action: () => navigateTo('/dashboard/projects'),
|
||||||
|
shown: isSelf,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: isSelf,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'report',
|
||||||
|
label: formatMessage(commonMessages.reportButton),
|
||||||
|
icon: ReportIcon,
|
||||||
|
action: () => (viewer ? reportUser(user.value.id) : navigateTo(getSignInRouteObj(route))),
|
||||||
|
color: 'red',
|
||||||
|
shown: viewer?.id !== user.value.id,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-id',
|
||||||
|
label: formatMessage(commonMessages.copyIdButton),
|
||||||
|
icon: ClipboardCopyIcon,
|
||||||
|
action: () => copyId(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-permalink',
|
||||||
|
label: formatMessage(commonMessages.copyPermalinkButton),
|
||||||
|
icon: ClipboardCopyIcon,
|
||||||
|
action: () => copyPermalink(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: viewer && isAdmin(viewer),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-billing',
|
||||||
|
label: formatMessage(messages.billingButton),
|
||||||
|
icon: CurrencyIcon,
|
||||||
|
action: () => navigateTo(`/admin/billing/${user.value.id}`),
|
||||||
|
shown: viewer && isStaff(viewer),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'toggle-affiliate',
|
||||||
|
label: formatMessage(
|
||||||
|
isAffiliate.value ? messages.removeAffiliateButton : messages.setAffiliateButton,
|
||||||
|
),
|
||||||
|
icon: AffiliateIcon,
|
||||||
|
action: () => toggleAffiliate(user.value.id),
|
||||||
|
shown: isAdminViewing.value,
|
||||||
|
remainOnClick: true,
|
||||||
|
color: isAffiliate.value ? 'red' : 'orange',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-info',
|
||||||
|
label: formatMessage(messages.infoButton),
|
||||||
|
icon: InfoIcon,
|
||||||
|
action: () => userDetailsModal.value?.show(),
|
||||||
|
shown: viewer && isStaff(viewer),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'edit-role',
|
||||||
|
label: formatMessage(messages.editRoleButton),
|
||||||
|
icon: EditIcon,
|
||||||
|
action: () => openRoleEditModal(),
|
||||||
|
shown: viewer && isAdmin(viewer),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const navLinks = computed(() => [
|
const navLinks = computed(() => [
|
||||||
{
|
{
|
||||||
label: formatMessage(commonMessages.allProjectType),
|
label: formatMessage(commonMessages.allProjectType),
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ export class ArchonServersV0Module extends AbstractModule {
|
|||||||
*/
|
*/
|
||||||
public async power(
|
public async power(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
action: 'Start' | 'Stop' | 'Restart' | 'Kill',
|
action: Archon.Servers.v0.PowerAction,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await this.client.request(`/servers/${serverId}/power`, {
|
await this.client.request(`/servers/${serverId}/power`, {
|
||||||
api: 'archon',
|
api: 'archon',
|
||||||
|
|||||||
@@ -336,6 +336,8 @@ export namespace Archon {
|
|||||||
token: string // JWT token for filesystem access
|
token: string // JWT token for filesystem access
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
|
||||||
|
|
||||||
export type ReinstallLoaderRequest = {
|
export type ReinstallLoaderRequest = {
|
||||||
loader: string
|
loader: string
|
||||||
loader_version?: string
|
loader_version?: string
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col gap-2 border-0 border-b border-solid border-divider pb-4">
|
|
||||||
<div class="flex flex-wrap items-start gap-4 max-md:flex-col">
|
|
||||||
<div class="flex min-w-0 flex-1 gap-4">
|
|
||||||
<slot name="icon" />
|
|
||||||
<div class="flex min-w-0 flex-col gap-2 justify-center">
|
|
||||||
<div class="flex flex-col gap-1.5 justify-center">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<h1 class="m-0 text-2xl font-semibold leading-none text-contrast">
|
|
||||||
<slot name="title" />
|
|
||||||
</h1>
|
|
||||||
<slot name="title-suffix" />
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="$slots.summary"
|
|
||||||
class="m-0 max-w-[44rem] empty:hidden"
|
|
||||||
:class="[disableLineClamp ? '' : 'line-clamp-2']"
|
|
||||||
>
|
|
||||||
<slot name="summary" />
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div v-if="$slots.stats" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
|
|
||||||
<slot name="stats" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2 items-center">
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="$slots.stats" class="flex justify-between md:hidden">
|
|
||||||
<div class="flex flex-wrap gap-3 empty:hidden">
|
|
||||||
<slot name="stats" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
interface Props {
|
|
||||||
disableLineClamp?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const { disableLineClamp } = defineProps<Props>()
|
|
||||||
</script>
|
|
||||||
@@ -31,7 +31,8 @@ import { DropdownIcon } from '@modrinth/assets'
|
|||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { ButtonStyled, OverflowMenu } from '../index'
|
import ButtonStyled from './ButtonStyled.vue'
|
||||||
|
import OverflowMenu from './OverflowMenu.vue'
|
||||||
|
|
||||||
// TODO: This should be moved to a shared types file.
|
// TODO: This should be moved to a shared types file.
|
||||||
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||||
|
|||||||
@@ -6,40 +6,138 @@
|
|||||||
:class="{ 'drop-shadow-xl': mode === 'navigation' }"
|
:class="{ 'drop-shadow-xl': mode === 'navigation' }"
|
||||||
>
|
>
|
||||||
<template v-if="mode === 'navigation'">
|
<template v-if="mode === 'navigation'">
|
||||||
<RouterLink
|
<template v-for="(link, index) in filteredLinks" :key="link.href">
|
||||||
v-for="(link, index) in filteredLinks"
|
<Tooltip
|
||||||
v-show="link.shown ?? true"
|
v-if="link.prompt"
|
||||||
:key="link.href"
|
theme="dismissable-prompt"
|
||||||
ref="tabLinkElements"
|
:triggers="[]"
|
||||||
:replace="replace"
|
:shown="link.prompt.shown"
|
||||||
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
:auto-hide="false"
|
||||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
:placement="link.prompt.placement ?? 'bottom'"
|
||||||
:class="getSSRFallbackClasses(index)"
|
>
|
||||||
@mouseenter="link.onHover?.()"
|
<RouterLink
|
||||||
@focus="link.onHover?.()"
|
ref="tabLinkElements"
|
||||||
>
|
:replace="replace"
|
||||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||||
{{ link.label }}
|
:class="getSSRFallbackClasses(index)"
|
||||||
</span>
|
@mouseenter="link.onHover?.()"
|
||||||
</RouterLink>
|
@focus="link.onHover?.()"
|
||||||
|
@click="dismissPrompt(link)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="link.icon"
|
||||||
|
v-if="link.icon"
|
||||||
|
class="size-5"
|
||||||
|
:class="getIconClasses(index)"
|
||||||
|
/>
|
||||||
|
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||||
|
{{ link.label }}
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
<template #popper>
|
||||||
|
<div class="grid grid-cols-[min-content] gap-1">
|
||||||
|
<div class="flex min-w-48 items-center justify-between gap-8">
|
||||||
|
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
||||||
|
{{ link.prompt.title }}
|
||||||
|
</h3>
|
||||||
|
<ButtonStyled size="small" circular>
|
||||||
|
<button v-tooltip="link.prompt.dismissLabel" @click="link.prompt.onDismiss?.()">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||||
|
{{ link.prompt.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tooltip>
|
||||||
|
<RouterLink
|
||||||
|
v-else
|
||||||
|
ref="tabLinkElements"
|
||||||
|
:replace="replace"
|
||||||
|
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
|
||||||
|
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
|
||||||
|
:class="getSSRFallbackClasses(index)"
|
||||||
|
@mouseenter="link.onHover?.()"
|
||||||
|
@focus="link.onHover?.()"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="link.icon"
|
||||||
|
v-if="link.icon"
|
||||||
|
class="size-5"
|
||||||
|
:class="getIconClasses(index)"
|
||||||
|
/>
|
||||||
|
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||||
|
{{ link.label }}
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div
|
<template v-for="(link, index) in filteredLinks" :key="link.href">
|
||||||
v-for="(link, index) in filteredLinks"
|
<Tooltip
|
||||||
v-show="link.shown ?? true"
|
v-if="link.prompt"
|
||||||
:key="link.href"
|
theme="dismissable-prompt"
|
||||||
ref="tabLinkElements"
|
:triggers="[]"
|
||||||
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
|
:shown="link.prompt.shown"
|
||||||
:class="getSSRFallbackClasses(index)"
|
:auto-hide="false"
|
||||||
@click="emit('tabClick', index, link)"
|
:placement="link.prompt.placement ?? 'bottom'"
|
||||||
>
|
>
|
||||||
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
|
<div
|
||||||
<span class="text-nowrap" :class="getLabelClasses(index)">
|
ref="tabLinkElements"
|
||||||
{{ link.label }}
|
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
|
||||||
</span>
|
:class="getSSRFallbackClasses(index)"
|
||||||
</div>
|
@click="handleLocalTabClick(index, link)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="link.icon"
|
||||||
|
v-if="link.icon"
|
||||||
|
class="size-5"
|
||||||
|
:class="getIconClasses(index)"
|
||||||
|
/>
|
||||||
|
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||||
|
{{ link.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<template #popper>
|
||||||
|
<div class="grid grid-cols-[min-content] gap-1">
|
||||||
|
<div class="flex min-w-48 items-center justify-between gap-8">
|
||||||
|
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
||||||
|
{{ link.prompt.title }}
|
||||||
|
</h3>
|
||||||
|
<ButtonStyled size="small" circular>
|
||||||
|
<button v-tooltip="link.prompt.dismissLabel" @click="link.prompt.onDismiss?.()">
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||||
|
{{ link.prompt.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tooltip>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
ref="tabLinkElements"
|
||||||
|
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
|
||||||
|
:class="getSSRFallbackClasses(index)"
|
||||||
|
@click="handleLocalTabClick(index, link)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="link.icon"
|
||||||
|
v-if="link.icon"
|
||||||
|
class="size-5"
|
||||||
|
:class="getIconClasses(index)"
|
||||||
|
/>
|
||||||
|
<span class="text-nowrap" :class="getLabelClasses(index)">
|
||||||
|
{{ link.label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Animated slider background -->
|
<!-- Animated slider background -->
|
||||||
@@ -57,12 +155,25 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { XIcon } from '@modrinth/assets'
|
||||||
|
import { Tooltip } from 'floating-vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import ButtonStyled from './ButtonStyled.vue'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
|
interface TabPrompt {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
dismissLabel?: string
|
||||||
|
shown?: boolean
|
||||||
|
placement?: string
|
||||||
|
onDismiss?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
@@ -70,6 +181,7 @@ interface Tab {
|
|||||||
icon?: Component
|
icon?: Component
|
||||||
subpages?: string[]
|
subpages?: string[]
|
||||||
onHover?: () => void
|
onHover?: () => void
|
||||||
|
prompt?: TabPrompt
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -131,6 +243,17 @@ const isActiveAndNotSubpage = computed(
|
|||||||
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
|
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function dismissPrompt(link: Tab) {
|
||||||
|
if (link.prompt?.shown) {
|
||||||
|
link.prompt.onDismiss?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocalTabClick(index: number, link: Tab) {
|
||||||
|
dismissPrompt(link)
|
||||||
|
emit('tabClick', index, link)
|
||||||
|
}
|
||||||
|
|
||||||
function getSSRFallbackClasses(index: number) {
|
function getSSRFallbackClasses(index: number) {
|
||||||
if (sliderReady.value) return {}
|
if (sliderReady.value) return {}
|
||||||
if (currentActiveIndex.value !== index) return {}
|
if (currentActiveIndex.value !== index) return {}
|
||||||
|
|||||||
@@ -1,11 +1,679 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="grid grid-cols-[min-content_1fr_auto] gap-4">
|
<div
|
||||||
<slot name="icon" />
|
class="flex flex-col gap-2"
|
||||||
<div class="flex flex-col gap-1.5">
|
:class="[
|
||||||
<slot name="title" />
|
props.divider ? 'border-0 border-b border-solid border-divider' : '',
|
||||||
<slot name="summary" />
|
props.bottomPadding ? 'pb-4' : '',
|
||||||
<slot name="stats" />
|
props.headerClass,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-start gap-4 max-md:flex-col" :class="props.rowClass">
|
||||||
|
<div class="flex min-w-0 flex-1 gap-4" :class="props.mainClass">
|
||||||
|
<div v-if="leadingItems.length" class="contents">
|
||||||
|
<template v-for="(leadingItem, index) in leadingItems" :key="leadingItem.id ?? index">
|
||||||
|
<div
|
||||||
|
v-if="leadingItem.type === 'button'"
|
||||||
|
:class="
|
||||||
|
leadingWrapperClass(
|
||||||
|
leadingItem,
|
||||||
|
'flex size-16 shrink-0 items-center justify-center',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ButtonStyled
|
||||||
|
circular
|
||||||
|
:color="leadingItem.color ?? 'standard'"
|
||||||
|
:size="leadingItem.size ?? 'large'"
|
||||||
|
:type="leadingItem.buttonType ?? 'standard'"
|
||||||
|
>
|
||||||
|
<AutoLink
|
||||||
|
v-if="leadingItem.to"
|
||||||
|
v-tooltip="leadingItem.tooltip"
|
||||||
|
:to="leadingItem.to"
|
||||||
|
:aria-label="leadingLabel(leadingItem)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="leadingItem.icon"
|
||||||
|
v-if="leadingItem.icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="leadingItem.iconProps"
|
||||||
|
/>
|
||||||
|
</AutoLink>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
v-tooltip="leadingItem.tooltip"
|
||||||
|
type="button"
|
||||||
|
:aria-label="leadingLabel(leadingItem)"
|
||||||
|
@click="leadingItem.onClick"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="leadingItem.icon"
|
||||||
|
v-if="leadingItem.icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="leadingItem.iconProps"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<Avatar
|
||||||
|
v-else-if="leadingItem.type === 'avatar'"
|
||||||
|
:src="leadingItem.src"
|
||||||
|
:alt="leadingItem.alt ?? ''"
|
||||||
|
:size="leadingItem.avatarSize ?? '64px'"
|
||||||
|
:tint-by="leadingItem.tintBy ?? null"
|
||||||
|
:circle="leadingItem.circle ?? false"
|
||||||
|
:no-shadow="leadingItem.noShadow ?? false"
|
||||||
|
:raised="leadingItem.raised ?? false"
|
||||||
|
:loading="leadingItem.loading ?? 'eager'"
|
||||||
|
:class="leadingItem.class"
|
||||||
|
/>
|
||||||
|
<component
|
||||||
|
:is="leadingItem.component"
|
||||||
|
v-else-if="leadingItem.component"
|
||||||
|
:class="leadingItem.class"
|
||||||
|
v-bind="leadingItem.componentProps"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-col gap-2 justify-center">
|
||||||
|
<div class="flex flex-col gap-1.5 justify-center">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<h1
|
||||||
|
class="m-0 min-w-0 max-w-full text-2xl font-semibold leading-none text-contrast"
|
||||||
|
:class="[props.truncateTitle ? 'truncate' : '', props.titleClass]"
|
||||||
|
>
|
||||||
|
{{ props.header }}
|
||||||
|
</h1>
|
||||||
|
<template v-for="badge in props.badges" :key="badge.id">
|
||||||
|
<component
|
||||||
|
:is="badge.component"
|
||||||
|
v-if="badge.component"
|
||||||
|
:class="badge.class"
|
||||||
|
v-bind="badge.componentProps"
|
||||||
|
/>
|
||||||
|
<AutoLink
|
||||||
|
v-else-if="badge.to"
|
||||||
|
v-tooltip="badge.tooltip"
|
||||||
|
:to="badge.to"
|
||||||
|
:aria-label="badge.ariaLabel ?? badge.label"
|
||||||
|
:class="badgeClass(badge, true)"
|
||||||
|
:style="badge.style"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="badge.icon"
|
||||||
|
v-if="badge.icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="badge.iconProps"
|
||||||
|
/>
|
||||||
|
<span>{{ badge.label }}</span>
|
||||||
|
</AutoLink>
|
||||||
|
<button
|
||||||
|
v-else-if="badge.onClick"
|
||||||
|
v-tooltip="badge.tooltip"
|
||||||
|
type="button"
|
||||||
|
:aria-label="badge.ariaLabel ?? badge.label"
|
||||||
|
:class="badgeClass(badge, true)"
|
||||||
|
:style="badge.style"
|
||||||
|
@click="badge.onClick"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="badge.icon"
|
||||||
|
v-if="badge.icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="badge.iconProps"
|
||||||
|
/>
|
||||||
|
<span>{{ badge.label }}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-tooltip="badge.tooltip"
|
||||||
|
:aria-label="badge.ariaLabel"
|
||||||
|
:class="badgeClass(badge)"
|
||||||
|
:style="badge.style"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="badge.icon"
|
||||||
|
v-if="badge.icon"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="badge.iconProps"
|
||||||
|
/>
|
||||||
|
<span>{{ badge.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="props.summary"
|
||||||
|
class="m-0 max-w-[44rem] empty:hidden"
|
||||||
|
:class="[props.disableLineClamp ? '' : 'line-clamp-2']"
|
||||||
|
>
|
||||||
|
{{ props.summary }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.metadata.length" class="flex flex-wrap gap-3 empty:hidden max-md:hidden">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<template v-for="(item, index) in props.metadata" :key="item.id">
|
||||||
|
<BulletDivider v-if="index > 0" class="shrink-0" />
|
||||||
|
<div v-if="item.type === 'custom'" :class="item.class">
|
||||||
|
<slot :name="metadataSlotName(item)" :item="item" />
|
||||||
|
</div>
|
||||||
|
<AutoLink
|
||||||
|
v-else-if="item.to"
|
||||||
|
v-tooltip="item.tooltip"
|
||||||
|
:to="item.to"
|
||||||
|
:aria-label="item.ariaLabel ?? item.label ?? ''"
|
||||||
|
:class="metadataClass(item, true)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
v-if="item.icon"
|
||||||
|
class="flex size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="item.iconProps"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
</AutoLink>
|
||||||
|
<button
|
||||||
|
v-else-if="item.onClick"
|
||||||
|
v-tooltip="item.tooltip"
|
||||||
|
type="button"
|
||||||
|
:aria-label="item.ariaLabel ?? item.label ?? ''"
|
||||||
|
:class="metadataClass(item, true)"
|
||||||
|
@click="item.onClick"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
v-if="item.icon"
|
||||||
|
class="flex size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="item.iconProps"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-tooltip="item.tooltip"
|
||||||
|
:aria-label="item.ariaLabel"
|
||||||
|
:class="metadataClass(item)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
v-if="item.icon"
|
||||||
|
class="flex size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="item.iconProps"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.actions.length" class="flex flex-wrap gap-2 items-center">
|
||||||
|
<template v-for="action in props.actions" :key="action.id">
|
||||||
|
<component
|
||||||
|
:is="action.component"
|
||||||
|
v-if="action.component"
|
||||||
|
:class="action.class"
|
||||||
|
v-bind="action.componentProps"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
v-else-if="action.prompt"
|
||||||
|
theme="dismissable-prompt"
|
||||||
|
:triggers="[]"
|
||||||
|
:shown="action.prompt.shown"
|
||||||
|
:auto-hide="false"
|
||||||
|
:placement="action.prompt.placement ?? 'bottom'"
|
||||||
|
>
|
||||||
|
<JoinedButtons
|
||||||
|
v-if="action.joinedActions?.length"
|
||||||
|
:actions="action.joinedActions"
|
||||||
|
:color="joinedActionColor(action.color)"
|
||||||
|
:size="action.size ?? 'large'"
|
||||||
|
:disabled="action.disabled"
|
||||||
|
:primary-disabled="action.primaryDisabled"
|
||||||
|
:dropdown-disabled="action.dropdownDisabled"
|
||||||
|
:primary-muted="action.primaryMuted"
|
||||||
|
/>
|
||||||
|
<ButtonStyled
|
||||||
|
v-else
|
||||||
|
:color="action.color ?? 'standard'"
|
||||||
|
:size="action.size ?? 'large'"
|
||||||
|
:type="action.type ?? 'standard'"
|
||||||
|
:circular="action.circular ?? action.labelHidden ?? false"
|
||||||
|
>
|
||||||
|
<TeleportOverflowMenu
|
||||||
|
v-if="action.menuActions?.length"
|
||||||
|
:options="action.menuActions"
|
||||||
|
:tooltip="action.tooltip"
|
||||||
|
:aria-label="actionLabel(action)"
|
||||||
|
:disabled="action.disabled"
|
||||||
|
@open="dismissActionPrompt(action)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="action.icon"
|
||||||
|
v-if="action.icon"
|
||||||
|
:class="action.iconClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="action.iconProps"
|
||||||
|
/>
|
||||||
|
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
|
||||||
|
</TeleportOverflowMenu>
|
||||||
|
<AutoLink
|
||||||
|
v-else-if="action.to"
|
||||||
|
v-tooltip="action.tooltip"
|
||||||
|
:to="action.to"
|
||||||
|
:aria-label="actionLabel(action)"
|
||||||
|
@click="(event) => handleActionClick(action, event)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="action.icon"
|
||||||
|
v-if="action.icon"
|
||||||
|
:class="action.iconClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="action.iconProps"
|
||||||
|
/>
|
||||||
|
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
|
||||||
|
</AutoLink>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
v-tooltip="action.tooltip"
|
||||||
|
type="button"
|
||||||
|
:disabled="action.disabled"
|
||||||
|
:aria-label="actionLabel(action)"
|
||||||
|
@click="(event) => handleActionClick(action, event)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="action.icon"
|
||||||
|
v-if="action.icon"
|
||||||
|
:class="action.iconClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="action.iconProps"
|
||||||
|
/>
|
||||||
|
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<template #popper>
|
||||||
|
<div class="grid grid-cols-[min-content] gap-1">
|
||||||
|
<div class="flex min-w-48 items-center justify-between gap-8">
|
||||||
|
<h3
|
||||||
|
class="m-0 flex items-center gap-2 whitespace-nowrap text-base font-bold text-contrast"
|
||||||
|
>
|
||||||
|
{{ action.prompt.title }}
|
||||||
|
<span
|
||||||
|
v-if="action.prompt.badge"
|
||||||
|
class="inline-flex items-center rounded-full border border-solid border-brand-highlight bg-brand-highlight px-2 py-1 text-sm font-semibold leading-none text-brand"
|
||||||
|
>
|
||||||
|
{{ action.prompt.badge }}
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<ButtonStyled size="small" circular>
|
||||||
|
<button
|
||||||
|
v-tooltip="action.prompt.dismissLabel"
|
||||||
|
@click="action.prompt.onDismiss?.()"
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||||
|
{{ action.prompt.description }}
|
||||||
|
</p>
|
||||||
|
<p v-if="action.prompt.footer" class="m-0 text-wrap text-sm font-bold text-primary">
|
||||||
|
{{ action.prompt.footer }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Tooltip>
|
||||||
|
<template v-else>
|
||||||
|
<JoinedButtons
|
||||||
|
v-if="action.joinedActions?.length"
|
||||||
|
:actions="action.joinedActions"
|
||||||
|
:color="joinedActionColor(action.color)"
|
||||||
|
:size="action.size ?? 'large'"
|
||||||
|
:disabled="action.disabled"
|
||||||
|
:primary-disabled="action.primaryDisabled"
|
||||||
|
:dropdown-disabled="action.dropdownDisabled"
|
||||||
|
:primary-muted="action.primaryMuted"
|
||||||
|
/>
|
||||||
|
<ButtonStyled
|
||||||
|
v-else
|
||||||
|
:color="action.color ?? 'standard'"
|
||||||
|
:size="action.size ?? 'large'"
|
||||||
|
:type="action.type ?? 'standard'"
|
||||||
|
:circular="action.circular ?? action.labelHidden ?? false"
|
||||||
|
>
|
||||||
|
<TeleportOverflowMenu
|
||||||
|
v-if="action.menuActions?.length"
|
||||||
|
:options="action.menuActions"
|
||||||
|
:tooltip="action.tooltip"
|
||||||
|
:aria-label="actionLabel(action)"
|
||||||
|
:disabled="action.disabled"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="action.icon"
|
||||||
|
v-if="action.icon"
|
||||||
|
:class="action.iconClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="action.iconProps"
|
||||||
|
/>
|
||||||
|
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
|
||||||
|
</TeleportOverflowMenu>
|
||||||
|
<AutoLink
|
||||||
|
v-else-if="action.to"
|
||||||
|
v-tooltip="action.tooltip"
|
||||||
|
:to="action.to"
|
||||||
|
:aria-label="actionLabel(action)"
|
||||||
|
@click="(event) => handleActionClick(action, event)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="action.icon"
|
||||||
|
v-if="action.icon"
|
||||||
|
:class="action.iconClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="action.iconProps"
|
||||||
|
/>
|
||||||
|
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
|
||||||
|
</AutoLink>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
v-tooltip="action.tooltip"
|
||||||
|
type="button"
|
||||||
|
:disabled="action.disabled"
|
||||||
|
:aria-label="actionLabel(action)"
|
||||||
|
@click="(event) => handleActionClick(action, event)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="action.icon"
|
||||||
|
v-if="action.icon"
|
||||||
|
:class="action.iconClass"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="action.iconProps"
|
||||||
|
/>
|
||||||
|
<span v-if="!action.labelHidden && !action.circular">{{ action.label }}</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="props.metadata.length" class="flex justify-between md:hidden">
|
||||||
|
<div class="flex flex-wrap gap-3 empty:hidden">
|
||||||
|
<div class="flex min-w-0 flex-wrap items-center gap-2">
|
||||||
|
<template v-for="(item, index) in props.metadata" :key="item.id">
|
||||||
|
<BulletDivider v-if="index > 0" class="shrink-0" />
|
||||||
|
<div v-if="item.type === 'custom'" :class="item.class">
|
||||||
|
<slot :name="metadataSlotName(item)" :item="item" />
|
||||||
|
</div>
|
||||||
|
<AutoLink
|
||||||
|
v-else-if="item.to"
|
||||||
|
v-tooltip="item.tooltip"
|
||||||
|
:to="item.to"
|
||||||
|
:aria-label="item.ariaLabel ?? item.label ?? ''"
|
||||||
|
:class="metadataClass(item, true)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
v-if="item.icon"
|
||||||
|
class="flex size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="item.iconProps"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
</AutoLink>
|
||||||
|
<button
|
||||||
|
v-else-if="item.onClick"
|
||||||
|
v-tooltip="item.tooltip"
|
||||||
|
type="button"
|
||||||
|
:aria-label="item.ariaLabel ?? item.label ?? ''"
|
||||||
|
:class="metadataClass(item, true)"
|
||||||
|
@click="item.onClick"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
v-if="item.icon"
|
||||||
|
class="flex size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="item.iconProps"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
v-tooltip="item.tooltip"
|
||||||
|
:aria-label="item.ariaLabel"
|
||||||
|
:class="metadataClass(item)"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
v-if="item.icon"
|
||||||
|
class="flex size-5 shrink-0"
|
||||||
|
aria-hidden="true"
|
||||||
|
v-bind="item.iconProps"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { XIcon } from '@modrinth/assets'
|
||||||
|
import { Tooltip } from 'floating-vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import AutoLink from './AutoLink.vue'
|
||||||
|
import Avatar from './Avatar.vue'
|
||||||
|
import BulletDivider from './BulletDivider.vue'
|
||||||
|
import ButtonStyled from './ButtonStyled.vue'
|
||||||
|
import JoinedButtons, { type JoinedButtonAction } from './JoinedButtons.vue'
|
||||||
|
import TeleportOverflowMenu, {
|
||||||
|
type Item as TeleportOverflowMenuItem,
|
||||||
|
} from './TeleportOverflowMenu.vue'
|
||||||
|
|
||||||
|
type PageHeaderTarget = string | RouteLocationRaw
|
||||||
|
type ButtonColor =
|
||||||
|
| 'standard'
|
||||||
|
| 'brand'
|
||||||
|
| 'red'
|
||||||
|
| 'orange'
|
||||||
|
| 'green'
|
||||||
|
| 'blue'
|
||||||
|
| 'purple'
|
||||||
|
| 'medal-promo'
|
||||||
|
type ButtonSize = 'standard' | 'large' | 'small'
|
||||||
|
type ButtonType =
|
||||||
|
| 'standard'
|
||||||
|
| 'outlined'
|
||||||
|
| 'transparent'
|
||||||
|
| 'highlight'
|
||||||
|
| 'highlight-colored-text'
|
||||||
|
| 'chip'
|
||||||
|
|
||||||
|
type PageHeaderLeading = {
|
||||||
|
id?: string
|
||||||
|
type: 'avatar' | 'button' | 'component'
|
||||||
|
icon?: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
component?: Component
|
||||||
|
componentProps?: Record<string, unknown>
|
||||||
|
src?: string | null
|
||||||
|
alt?: string
|
||||||
|
tintBy?: string | null
|
||||||
|
avatarSize?: string
|
||||||
|
circle?: boolean
|
||||||
|
noShadow?: boolean
|
||||||
|
raised?: boolean
|
||||||
|
loading?: 'eager' | 'lazy'
|
||||||
|
class?: string
|
||||||
|
wrapperClass?: string
|
||||||
|
to?: PageHeaderTarget
|
||||||
|
onClick?: (event?: MouseEvent) => void | Promise<void>
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
color?: ButtonColor
|
||||||
|
size?: ButtonSize
|
||||||
|
buttonType?: ButtonType
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageHeaderBadge = {
|
||||||
|
id: string
|
||||||
|
label?: string
|
||||||
|
icon?: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
component?: Component
|
||||||
|
componentProps?: Record<string, unknown>
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
to?: PageHeaderTarget
|
||||||
|
onClick?: () => void | Promise<void>
|
||||||
|
class?: string
|
||||||
|
style?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageHeaderMetadataItem = {
|
||||||
|
id: string
|
||||||
|
type?: 'text' | 'custom'
|
||||||
|
label?: string
|
||||||
|
icon?: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
to?: PageHeaderTarget
|
||||||
|
onClick?: () => void | Promise<void>
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageHeaderActionPrompt = {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
badge?: string
|
||||||
|
footer?: string
|
||||||
|
dismissLabel?: string
|
||||||
|
shown?: boolean
|
||||||
|
placement?: string
|
||||||
|
onDismiss?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageHeaderAction = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
component?: Component
|
||||||
|
componentProps?: Record<string, unknown>
|
||||||
|
class?: string
|
||||||
|
icon?: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
iconClass?: string
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
to?: PageHeaderTarget
|
||||||
|
onClick?: () => void | Promise<void>
|
||||||
|
disabled?: boolean
|
||||||
|
labelHidden?: boolean
|
||||||
|
circular?: boolean
|
||||||
|
color?: ButtonColor
|
||||||
|
size?: ButtonSize
|
||||||
|
type?: ButtonType
|
||||||
|
joinedActions?: JoinedButtonAction[]
|
||||||
|
menuActions?: TeleportOverflowMenuItem[]
|
||||||
|
primaryDisabled?: boolean
|
||||||
|
dropdownDisabled?: boolean
|
||||||
|
primaryMuted?: boolean
|
||||||
|
prompt?: PageHeaderActionPrompt
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
header: string
|
||||||
|
summary?: string | null
|
||||||
|
leading?: PageHeaderLeading | PageHeaderLeading[] | null
|
||||||
|
badges?: PageHeaderBadge[]
|
||||||
|
metadata?: PageHeaderMetadataItem[]
|
||||||
|
actions?: PageHeaderAction[]
|
||||||
|
headerClass?: string
|
||||||
|
rowClass?: string
|
||||||
|
mainClass?: string
|
||||||
|
titleClass?: string
|
||||||
|
truncateTitle?: boolean
|
||||||
|
divider?: boolean
|
||||||
|
bottomPadding?: boolean
|
||||||
|
disableLineClamp?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
summary: null,
|
||||||
|
leading: null,
|
||||||
|
badges: () => [],
|
||||||
|
metadata: () => [],
|
||||||
|
actions: () => [],
|
||||||
|
headerClass: '',
|
||||||
|
rowClass: '',
|
||||||
|
mainClass: '',
|
||||||
|
titleClass: '',
|
||||||
|
truncateTitle: false,
|
||||||
|
divider: true,
|
||||||
|
bottomPadding: true,
|
||||||
|
disableLineClamp: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const leadingItems = computed(() => {
|
||||||
|
if (!props.leading) return []
|
||||||
|
return Array.isArray(props.leading) ? props.leading : [props.leading]
|
||||||
|
})
|
||||||
|
|
||||||
|
function leadingLabel(item: PageHeaderLeading) {
|
||||||
|
return item.ariaLabel ?? item.tooltip ?? 'Header action'
|
||||||
|
}
|
||||||
|
|
||||||
|
function leadingWrapperClass(item: PageHeaderLeading, defaultClass: string) {
|
||||||
|
return [item.wrapperClass ?? defaultClass]
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataClass(item: PageHeaderMetadataItem, interactive = false) {
|
||||||
|
return [
|
||||||
|
'flex min-w-0 items-center gap-2 font-medium text-secondary text-nowrap',
|
||||||
|
interactive ? 'm-0 cursor-pointer border-0 bg-transparent p-0 hover:underline' : '',
|
||||||
|
item.class,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataSlotName(item: PageHeaderMetadataItem) {
|
||||||
|
return `metadata-${item.id}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function badgeClass(badge: PageHeaderBadge, interactive = false) {
|
||||||
|
return [
|
||||||
|
'inline-flex items-center gap-1 rounded-full border border-solid border-surface-5 bg-button-bg px-2 py-1 text-sm font-semibold leading-none text-secondary text-nowrap [&>svg]:size-4 [&>svg]:shrink-0',
|
||||||
|
interactive ? 'm-0 cursor-pointer hover:underline' : '',
|
||||||
|
badge.class,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: PageHeaderAction) {
|
||||||
|
return action.ariaLabel ?? action.tooltip ?? action.label
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissActionPrompt(action: PageHeaderAction) {
|
||||||
|
if (action.prompt?.shown) {
|
||||||
|
action.prompt.onDismiss?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleActionClick(action: PageHeaderAction, event?: MouseEvent) {
|
||||||
|
dismissActionPrompt(action)
|
||||||
|
void action.onClick?.(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
function joinedActionColor(color?: ButtonColor) {
|
||||||
|
return color === 'medal-promo' ? 'standard' : color
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
<div data-pyro-telepopover-wrapper class="relative">
|
<div data-pyro-telepopover-wrapper class="relative">
|
||||||
<button
|
<button
|
||||||
ref="triggerRef"
|
ref="triggerRef"
|
||||||
|
v-tooltip="tooltip"
|
||||||
class="teleport-overflow-menu-trigger"
|
class="teleport-overflow-menu-trigger"
|
||||||
:class="btnClass"
|
:class="btnClass"
|
||||||
:aria-expanded="isOpen"
|
:aria-expanded="isOpen"
|
||||||
:aria-haspopup="true"
|
:aria-haspopup="true"
|
||||||
|
:aria-label="ariaLabel"
|
||||||
|
:disabled="disabled"
|
||||||
@mousedown="handleMouseDown"
|
@mousedown="handleMouseDown"
|
||||||
@mouseenter="handleMouseEnter"
|
@mouseenter="handleMouseEnter"
|
||||||
@mouseleave="handleMouseLeave"
|
@mouseleave="handleMouseLeave"
|
||||||
@@ -38,9 +41,14 @@
|
|||||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||||
>
|
>
|
||||||
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
||||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
<ButtonStyled
|
||||||
|
v-else
|
||||||
|
type="transparent"
|
||||||
|
role="menuitem"
|
||||||
|
:color="optionButtonColor(option)"
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
v-if="typeof option.action === 'function'"
|
v-if="typeof option.action === 'function' || option.disabled"
|
||||||
:ref="
|
:ref="
|
||||||
(el) => {
|
(el) => {
|
||||||
if (el) menuItemsRef[index] = el as HTMLElement
|
if (el) menuItemsRef[index] = el as HTMLElement
|
||||||
@@ -51,39 +59,41 @@
|
|||||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||||
:aria-selected="index === selectedIndex"
|
:aria-selected="index === selectedIndex"
|
||||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||||
@click="handleItemClick(option, index)"
|
@click="(event) => handleItemClick(option, index, event)"
|
||||||
@focus="selectedIndex = index"
|
@focus="selectedIndex = index"
|
||||||
@mouseover="handleMouseOver(index)"
|
@mouseover="handleMouseOver(index)"
|
||||||
>
|
>
|
||||||
<slot :name="option.id">
|
<slot :name="option.id">
|
||||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||||
{{ option.id }}
|
{{ option.label ?? option.id }}
|
||||||
</slot>
|
</slot>
|
||||||
</button>
|
</button>
|
||||||
<AutoLink
|
<AutoLink
|
||||||
v-else-if="typeof option.action === 'string'"
|
v-else-if="optionLink(option)"
|
||||||
:ref="
|
:ref="
|
||||||
(el) => {
|
(el) => {
|
||||||
if (el) menuItemsRef[index] = el as HTMLElement
|
if (el) menuItemsRef[index] = el as HTMLElement
|
||||||
}
|
}
|
||||||
"
|
"
|
||||||
:to="option.action"
|
:to="optionLink(option)"
|
||||||
|
:target="option.external ? '_blank' : undefined"
|
||||||
|
:rel="option.external ? 'noopener noreferrer' : undefined"
|
||||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||||
:aria-selected="index === selectedIndex"
|
:aria-selected="index === selectedIndex"
|
||||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||||
@click="handleItemClick(option, index)"
|
@click="(event) => handleItemClick(option, index, event)"
|
||||||
@focus="selectedIndex = index"
|
@focus="selectedIndex = index"
|
||||||
@mouseover="handleMouseOver(index)"
|
@mouseover="handleMouseOver(index)"
|
||||||
>
|
>
|
||||||
<slot :name="option.id">
|
<slot :name="option.id">
|
||||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||||
{{ option.id }}
|
{{ option.label ?? option.id }}
|
||||||
</slot>
|
</slot>
|
||||||
</AutoLink>
|
</AutoLink>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
<slot :name="option.id">
|
<slot :name="option.id">
|
||||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||||
{{ option.id }}
|
{{ option.label ?? option.id }}
|
||||||
</slot>
|
</slot>
|
||||||
</span>
|
</span>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -95,29 +105,48 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { AutoLink, ButtonStyled } from '@modrinth/ui'
|
|
||||||
import { onClickOutside, useElementHover } from '@vueuse/core'
|
import { onClickOutside, useElementHover } from '@vueuse/core'
|
||||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
interface Option {
|
import AutoLink from './AutoLink.vue'
|
||||||
|
import ButtonStyled from './ButtonStyled.vue'
|
||||||
|
|
||||||
|
type OptionColor =
|
||||||
|
| 'standard'
|
||||||
|
| 'brand'
|
||||||
|
| 'primary'
|
||||||
|
| 'danger'
|
||||||
|
| 'secondary'
|
||||||
|
| 'highlight'
|
||||||
|
| 'red'
|
||||||
|
| 'orange'
|
||||||
|
| 'green'
|
||||||
|
| 'blue'
|
||||||
|
| 'purple'
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
id: string
|
id: string
|
||||||
|
label?: string
|
||||||
icon?: Component
|
icon?: Component
|
||||||
action?: (() => void) | string
|
action?: ((event?: MouseEvent) => void) | string
|
||||||
|
link?: string
|
||||||
|
external?: boolean
|
||||||
shown?: boolean
|
shown?: boolean
|
||||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
color?: OptionColor
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
tooltip?: string
|
tooltip?: string
|
||||||
|
remainOnClick?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Divider = {
|
export type Divider = {
|
||||||
divider?: boolean
|
divider?: boolean
|
||||||
shown?: boolean
|
shown?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
type Item = Option | Divider
|
export type Item = Option | Divider
|
||||||
|
|
||||||
function isDivider(item: Item): item is Divider {
|
function isDivider(item: Item): item is Divider {
|
||||||
return (item as Divider).divider
|
return !!(item as Divider).divider
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -125,10 +154,16 @@ const props = withDefaults(
|
|||||||
options: Item[]
|
options: Item[]
|
||||||
hoverable?: boolean
|
hoverable?: boolean
|
||||||
btnClass?: string | string[] | Record<string, boolean>
|
btnClass?: string | string[] | Record<string, boolean>
|
||||||
|
disabled?: boolean
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
hoverable: false,
|
hoverable: false,
|
||||||
btnClass: undefined,
|
btnClass: undefined,
|
||||||
|
disabled: false,
|
||||||
|
tooltip: undefined,
|
||||||
|
ariaLabel: undefined,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,6 +226,7 @@ const calculateMenuPosition = () => {
|
|||||||
|
|
||||||
const toggleMenu = (event: MouseEvent) => {
|
const toggleMenu = (event: MouseEvent) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
if (props.disabled) return
|
||||||
if (!props.hoverable) {
|
if (!props.hoverable) {
|
||||||
if (isOpen.value) {
|
if (isOpen.value) {
|
||||||
closeMenu()
|
closeMenu()
|
||||||
@@ -201,6 +237,7 @@ const toggleMenu = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openMenu = () => {
|
const openMenu = () => {
|
||||||
|
if (props.disabled) return
|
||||||
isOpen.value = true
|
isOpen.value = true
|
||||||
emit('open')
|
emit('open')
|
||||||
disableBodyScroll()
|
disableBodyScroll()
|
||||||
@@ -218,15 +255,18 @@ const closeMenu = () => {
|
|||||||
document.removeEventListener('mousemove', handleMouseMove)
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectOption = (option: Option) => {
|
const selectOption = (option: Option, event?: MouseEvent) => {
|
||||||
emit('select', option)
|
emit('select', option)
|
||||||
if (typeof option.action === 'function') {
|
if (typeof option.action === 'function') {
|
||||||
option.action()
|
option.action(event)
|
||||||
|
}
|
||||||
|
if (!option.remainOnClick) {
|
||||||
|
closeMenu()
|
||||||
}
|
}
|
||||||
closeMenu()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseDown = (event: MouseEvent) => {
|
const handleMouseDown = (event: MouseEvent) => {
|
||||||
|
if (props.disabled) return
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
isMouseDown.value = true
|
isMouseDown.value = true
|
||||||
}
|
}
|
||||||
@@ -270,10 +310,10 @@ const handleMouseMove = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleItemClick = (option: Option, index: number) => {
|
const handleItemClick = (option: Option, index: number, event?: MouseEvent) => {
|
||||||
if (option.disabled) return
|
if (option.disabled) return
|
||||||
selectedIndex.value = index
|
selectedIndex.value = index
|
||||||
selectOption(option)
|
selectOption(option, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMouseOver = (index: number) => {
|
const handleMouseOver = (index: number) => {
|
||||||
@@ -432,4 +472,23 @@ onClickOutside(menuRef, (event) => {
|
|||||||
closeMenu()
|
closeMenu()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function optionLink(option: Option) {
|
||||||
|
if (typeof option.action === 'string') return option.action
|
||||||
|
return option.link
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionButtonColor(option: Option) {
|
||||||
|
switch (option.color) {
|
||||||
|
case 'primary':
|
||||||
|
return 'brand'
|
||||||
|
case 'danger':
|
||||||
|
return 'red'
|
||||||
|
case 'secondary':
|
||||||
|
case 'highlight':
|
||||||
|
return 'standard'
|
||||||
|
default:
|
||||||
|
return option.color ?? 'standard'
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
|
|||||||
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
|
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
|
||||||
export type { ComboboxOption } from './Combobox.vue'
|
export type { ComboboxOption } from './Combobox.vue'
|
||||||
export { default as Combobox } from './Combobox.vue'
|
export { default as Combobox } from './Combobox.vue'
|
||||||
export { default as ContentPageHeader } from './ContentPageHeader.vue'
|
|
||||||
export { default as CopyCode } from './CopyCode.vue'
|
export { default as CopyCode } from './CopyCode.vue'
|
||||||
export { default as DatePicker } from './DatePicker.vue'
|
export { default as DatePicker } from './DatePicker.vue'
|
||||||
export { default as DoubleIcon } from './DoubleIcon.vue'
|
export { default as DoubleIcon } from './DoubleIcon.vue'
|
||||||
@@ -59,6 +58,7 @@ export { default as OptionGroup } from './OptionGroup.vue'
|
|||||||
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
|
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
|
||||||
export { default as OverflowMenu } from './OverflowMenu.vue'
|
export { default as OverflowMenu } from './OverflowMenu.vue'
|
||||||
export { default as Page } from './Page.vue'
|
export { default as Page } from './Page.vue'
|
||||||
|
export { default as PageHeader } from './PageHeader.vue'
|
||||||
export { default as Pagination } from './Pagination.vue'
|
export { default as Pagination } from './Pagination.vue'
|
||||||
export { default as PopoutMenu } from './PopoutMenu.vue'
|
export { default as PopoutMenu } from './PopoutMenu.vue'
|
||||||
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
|
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
|
||||||
@@ -83,6 +83,11 @@ export type { TabsTab, TabsValue } from './Tabs.vue'
|
|||||||
export { default as Tabs } from './Tabs.vue'
|
export { default as Tabs } from './Tabs.vue'
|
||||||
export { default as TagItem } from './TagItem.vue'
|
export { default as TagItem } from './TagItem.vue'
|
||||||
export { default as TagTagItem } from './TagTagItem.vue'
|
export { default as TagTagItem } from './TagTagItem.vue'
|
||||||
|
export type {
|
||||||
|
Item as TeleportOverflowMenuItem,
|
||||||
|
Option as TeleportOverflowMenuOption,
|
||||||
|
} from './TeleportOverflowMenu.vue'
|
||||||
|
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
|
||||||
export { default as Timeline } from './Timeline.vue'
|
export { default as Timeline } from './Timeline.vue'
|
||||||
export { default as Toggle } from './Toggle.vue'
|
export { default as Toggle } from './Toggle.vue'
|
||||||
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
|
export { default as UnsavedChangesPopup } from './UnsavedChangesPopup.vue'
|
||||||
|
|||||||
@@ -1,18 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<ContentPageHeader>
|
<PageHeader
|
||||||
<template #icon>
|
:header="project.title"
|
||||||
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
|
:summary="project.description"
|
||||||
</template>
|
:leading="leadingItem"
|
||||||
<template #title>
|
:badges="headerBadges"
|
||||||
{{ project.title }}
|
:metadata="headerMetadata"
|
||||||
</template>
|
:actions="actions"
|
||||||
<template #title-suffix>
|
:disable-line-clamp="disableLineClamp"
|
||||||
<ProjectStatusBadge v-if="member || project.status !== 'approved'" :status="project.status" />
|
>
|
||||||
</template>
|
<template #metadata-project-stats>
|
||||||
<template #summary>
|
|
||||||
{{ project.description }}
|
|
||||||
</template>
|
|
||||||
<template #stats>
|
|
||||||
<div class="flex items-center gap-3 flex-wrap gap-y-0">
|
<div class="flex items-center gap-3 flex-wrap gap-y-0">
|
||||||
<template v-if="isServerProject">
|
<template v-if="isServerProject">
|
||||||
<ServerDetails
|
<ServerDetails
|
||||||
@@ -66,24 +62,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template #actions>
|
</PageHeader>
|
||||||
<slot name="actions" />
|
|
||||||
</template>
|
|
||||||
</ContentPageHeader>
|
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
|
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
|
||||||
import { capitalizeString, type Project } from '@modrinth/utils'
|
import { capitalizeString, type Project } from '@modrinth/utils'
|
||||||
|
import type { Component } from 'vue'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useCompactNumber, useVIntl } from '../../composables'
|
import { useCompactNumber, useVIntl } from '../../composables'
|
||||||
import { commonMessages } from '../../utils'
|
import { commonMessages } from '../../utils'
|
||||||
import Avatar from '../base/Avatar.vue'
|
|
||||||
import ContentPageHeader from '../base/ContentPageHeader.vue'
|
|
||||||
import FormattedTag from '../base/FormattedTag.vue'
|
import FormattedTag from '../base/FormattedTag.vue'
|
||||||
|
import type { JoinedButtonAction } from '../base/JoinedButtons.vue'
|
||||||
|
import PageHeader from '../base/PageHeader.vue'
|
||||||
import TagItem from '../base/TagItem.vue'
|
import TagItem from '../base/TagItem.vue'
|
||||||
|
import type { Item as TeleportOverflowMenuItem } from '../base/TeleportOverflowMenu.vue'
|
||||||
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
||||||
import ServerDetails from './server/ServerDetails.vue'
|
import ServerDetails from './server/ServerDetails.vue'
|
||||||
|
|
||||||
@@ -91,18 +87,77 @@ const router = useRouter()
|
|||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { formatCompactNumber } = useCompactNumber()
|
const { formatCompactNumber } = useCompactNumber()
|
||||||
|
|
||||||
|
type HeaderAction = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
component?: Component
|
||||||
|
componentProps?: Record<string, unknown>
|
||||||
|
class?: string
|
||||||
|
icon?: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
iconClass?: string
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
to?: string | RouteLocationRaw
|
||||||
|
onClick?: (event?: MouseEvent) => void | Promise<void>
|
||||||
|
disabled?: boolean
|
||||||
|
labelHidden?: boolean
|
||||||
|
circular?: boolean
|
||||||
|
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
|
||||||
|
size?: 'standard' | 'large' | 'small'
|
||||||
|
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
|
||||||
|
joinedActions?: JoinedButtonAction[]
|
||||||
|
menuActions?: TeleportOverflowMenuItem[]
|
||||||
|
primaryDisabled?: boolean
|
||||||
|
dropdownDisabled?: boolean
|
||||||
|
primaryMuted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
project: Project
|
project: Project
|
||||||
member?: boolean
|
member?: boolean
|
||||||
projectV3?: Labrinth.Projects.v3.Project | null
|
projectV3?: Labrinth.Projects.v3.Project | null
|
||||||
ping?: number
|
ping?: number
|
||||||
|
actions?: HeaderAction[]
|
||||||
|
disableLineClamp?: boolean
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
member: false,
|
member: false,
|
||||||
|
actions: () => [],
|
||||||
|
disableLineClamp: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const leadingItem = computed(() => ({
|
||||||
|
type: 'avatar' as const,
|
||||||
|
src: props.project.icon_url,
|
||||||
|
alt: props.project.title,
|
||||||
|
avatarSize: '96px',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const headerBadges = computed(() =>
|
||||||
|
props.member || props.project.status !== 'approved'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
component: ProjectStatusBadge,
|
||||||
|
componentProps: {
|
||||||
|
status: props.project.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const headerMetadata = [
|
||||||
|
{
|
||||||
|
id: 'project-stats',
|
||||||
|
type: 'custom' as const,
|
||||||
|
class: 'contents',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
const searchUrl = computed(
|
const searchUrl = computed(
|
||||||
() => `/discover/${isServerProject.value ? 'servers' : `${props.project.project_type}s`}`,
|
() => `/discover/${isServerProject.value ? 'servers' : `${props.project.project_type}s`}`,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ onMounted(() => {
|
|||||||
isClient.value = true
|
isClient.value = true
|
||||||
})
|
})
|
||||||
|
|
||||||
const { serverId } = injectModrinthServerContext()
|
const { serverId, worldId } = injectModrinthServerContext()
|
||||||
const { featureFlags } = injectPageContext()
|
const { featureFlags } = injectPageContext()
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -190,7 +190,9 @@ const metrics = computed(() => {
|
|||||||
showGraph: false,
|
showGraph: false,
|
||||||
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
|
chartOptions: null as ReturnType<typeof buildChartOptions> | null,
|
||||||
series: null as { name: string; data: number[] }[] | null,
|
series: null as { name: string; data: number[] }[] | null,
|
||||||
link: `/hosting/manage/${encodeURIComponent(serverId)}/files`,
|
link: worldId.value
|
||||||
|
? `/hosting/manage/${encodeURIComponent(serverId)}/instances/${encodeURIComponent(worldId.value)}/files`
|
||||||
|
: `/hosting/manage/${encodeURIComponent(serverId)}/instances`,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.loading) {
|
if (props.loading) {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ async function show({ serverId, tabIndex, tabId }: ShowOptions) {
|
|||||||
queryFn: () => client.archon.properties_v1.getProperties(targetServerId, worldId.value!),
|
queryFn: () => client.archon.properties_v1.getProperties(targetServerId, worldId.value!),
|
||||||
})
|
})
|
||||||
queryClient.prefetchQuery({
|
queryClient.prefetchQuery({
|
||||||
queryKey: ['content', 'list', 'v1', targetServerId],
|
queryKey: ['content', 'list', 'v1', targetServerId, worldId.value],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
client.archon.content_v1.getAddons(targetServerId, worldId.value!, {
|
client.archon.content_v1.getAddons(targetServerId, worldId.value!, {
|
||||||
from_modpack: false,
|
from_modpack: false,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import InstallingBanner, {
|
|||||||
type SyncProgress,
|
type SyncProgress,
|
||||||
} from '#ui/components/servers/InstallingBanner.vue'
|
} from '#ui/components/servers/InstallingBanner.vue'
|
||||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
|
||||||
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
|
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
|
||||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||||
|
|
||||||
@@ -53,8 +53,12 @@ const messages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const isOnContentTab = computed(() => route.path.includes('/content'))
|
|
||||||
const isOnFilesTab = computed(() => route.path.includes('/files'))
|
const isOnFilesTab = computed(() => route.path.includes('/files'))
|
||||||
|
const isOnContentTab = computed(
|
||||||
|
() =>
|
||||||
|
route.path.includes('/content') ||
|
||||||
|
(!!route.params.instance_id && !isOnFilesTab.value && !route.path.includes('/backups')),
|
||||||
|
)
|
||||||
|
|
||||||
const bannerCoversInstalling = computed(
|
const bannerCoversInstalling = computed(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -87,12 +87,12 @@ const props = defineProps<{
|
|||||||
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (name: string) =>
|
mutationFn: (name: string) =>
|
||||||
client.archon.backups_queue_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
|
client.archon.backups_queue_v1.create(ctx.serverId, ctx.worldId.value!, { name }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey.value }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const modal = ref<InstanceType<typeof NewModal>>()
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
|||||||
@@ -69,12 +69,12 @@ const props = defineProps<{
|
|||||||
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
backups?: Archon.BackupsQueue.v1.BackupQueueBackup[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
|
||||||
|
|
||||||
const renameMutation = useMutation({
|
const renameMutation = useMutation({
|
||||||
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
||||||
client.archon.backups_v1.rename(ctx.serverId, ctx.worldId.value!, backupId, { name }),
|
client.archon.backups_v1.rename(ctx.serverId, ctx.worldId.value!, backupId, { name }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey.value }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const modal = ref<InstanceType<typeof NewModal>>()
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
import type { Archon } from '@modrinth/api-client'
|
import type { Archon } from '@modrinth/api-client'
|
||||||
import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
import { RotateCounterClockwiseIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
@@ -56,7 +56,7 @@ const client = injectModrinthClient()
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const ctx = injectModrinthServerContext()
|
const ctx = injectModrinthServerContext()
|
||||||
|
|
||||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
|
||||||
|
|
||||||
function safetyBackupName(backupName: string) {
|
function safetyBackupName(backupName: string) {
|
||||||
const base = `Before restoring "${backupName}"`
|
const base = `Before restoring "${backupName}"`
|
||||||
@@ -66,7 +66,7 @@ function safetyBackupName(backupName: string) {
|
|||||||
const restoreMutation = useMutation({
|
const restoreMutation = useMutation({
|
||||||
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
||||||
client.archon.backups_queue_v1.restore(ctx.serverId, ctx.worldId.value!, backupId, { name }),
|
client.archon.backups_queue_v1.restore(ctx.serverId, ctx.worldId.value!, backupId, { name }),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: backupsQueryKey.value }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const modal = ref<InstanceType<typeof NewModal>>()
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
<template>
|
||||||
|
<article
|
||||||
|
class="flex min-h-[19.75rem] w-full flex-col overflow-hidden rounded-2xl border border-solid border-surface-5 bg-bg-raised shadow-xl"
|
||||||
|
:class="(world as any)?.active ? '!border-brand' : ''"
|
||||||
|
>
|
||||||
|
<template v-if="world.type === 'empty'">
|
||||||
|
<div class="flex flex-1 flex-col items-center pt-[3.125rem] text-center">
|
||||||
|
<svg
|
||||||
|
class="size-[5.6645rem]"
|
||||||
|
viewBox="0 0 91 91"
|
||||||
|
fill="none"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="22.4356"
|
||||||
|
y="0.629395"
|
||||||
|
width="71"
|
||||||
|
height="71"
|
||||||
|
rx="19.5"
|
||||||
|
transform="rotate(17.8856 22.4356 0.629395)"
|
||||||
|
stroke="#42444A"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="4.36354"
|
||||||
|
y="16.5661"
|
||||||
|
width="71"
|
||||||
|
height="71"
|
||||||
|
rx="19.5"
|
||||||
|
transform="rotate(-8.79487 4.36354 16.5661)"
|
||||||
|
fill="#34363C"
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x="4.36354"
|
||||||
|
y="16.5661"
|
||||||
|
width="71"
|
||||||
|
height="71"
|
||||||
|
rx="19.5"
|
||||||
|
transform="rotate(-8.79487 4.36354 16.5661)"
|
||||||
|
stroke="#42444A"
|
||||||
|
/>
|
||||||
|
<g :clip-path="`url(#${emptyCardClipId})`">
|
||||||
|
<path
|
||||||
|
d="M47.4227 62.6919C56.5193 61.2846 62.7525 52.7695 61.3452 43.673C59.9378 34.5764 51.4227 28.3432 42.3262 29.7505C33.2297 31.1579 26.9964 39.673 28.4038 48.7695C29.8111 57.866 38.3262 64.0993 47.4227 62.6919Z"
|
||||||
|
stroke="#B0BAC5"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M42.3246 29.7502C38.7824 34.8453 37.3358 41.1078 38.2846 47.2402C39.2334 53.3727 42.5048 58.9052 47.4212 62.6916C50.9634 57.5965 52.41 51.3341 51.4612 45.2016C50.5124 39.0691 47.241 33.5366 42.3246 29.7502Z"
|
||||||
|
stroke="#B0BAC5"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M28.4023 48.7695L61.3437 43.673"
|
||||||
|
stroke="#B0BAC5"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath :id="emptyCardClipId">
|
||||||
|
<rect
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
fill="white"
|
||||||
|
transform="translate(22.0508 29.5137) rotate(-8.79487)"
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<div class="mt-6 flex flex-col gap-1">
|
||||||
|
<h2 class="m-0 text-2xl font-semibold leading-8 text-contrast">{{ world.name }}</h2>
|
||||||
|
<p class="m-0 text-base leading-6 text-secondary">
|
||||||
|
{{ formatMessage(messages.newInstance) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-5 pb-4">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button class="w-full !h-10" @click="emit('create', world.id)">
|
||||||
|
<PlusIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.createInstance) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<header class="flex min-h-[5.75rem] flex-col justify-center gap-1 px-5 py-4">
|
||||||
|
<div class="flex min-w-0 items-center justify-between gap-3">
|
||||||
|
<span
|
||||||
|
ref="worldNameRef"
|
||||||
|
v-tooltip="truncatedTooltip(worldNameRef, world.name)"
|
||||||
|
class="m-0 truncate text-xl font-semibold text-contrast"
|
||||||
|
>{{ world.name }}</span
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="world.active"
|
||||||
|
class="shrink-0 rounded-full bg-brand-highlight border border-brand border-solid px-2.5 py-1 text-green"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.active) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-0 text-md items-center gap-2 text-secondary">
|
||||||
|
{{ world.gameVersion }} <BulletDivider /> {{ world.loaderLabel }}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="flex flex-1 flex-col gap-3 border-0 border-y bg-surface-2 border-solid border-surface-5 my-auto px-5 py-4"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,70%)] items-center gap-4 text-base text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
|
||||||
|
>
|
||||||
|
<span>{{ formatMessage(commonMessages.modpackLabel) }}</span>
|
||||||
|
<div
|
||||||
|
v-if="world.linkedModpack"
|
||||||
|
class="flex min-w-0 items-center gap-2 text-right font-semibold leading-5 text-contrast"
|
||||||
|
>
|
||||||
|
<AutoLink :to="world.linkedModpack.link" class="flex shrink-0 items-center">
|
||||||
|
<Avatar
|
||||||
|
:src="world.linkedModpack.iconUrl"
|
||||||
|
:alt="world.linkedModpack.name"
|
||||||
|
:tint-by="world.linkedModpack.name"
|
||||||
|
size="1.25rem"
|
||||||
|
no-shadow
|
||||||
|
/>
|
||||||
|
</AutoLink>
|
||||||
|
<AutoLink
|
||||||
|
:to="world.linkedModpack.link"
|
||||||
|
class="flex min-w-0 items-center font-semibold leading-5 text-contrast"
|
||||||
|
:class="world.linkedModpack.link ? 'hover:underline' : ''"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
ref="modpackNameRef"
|
||||||
|
v-tooltip="truncatedTooltip(modpackNameRef, world.linkedModpack.name)"
|
||||||
|
class="block truncate leading-5"
|
||||||
|
>
|
||||||
|
{{ world.linkedModpack.name }}
|
||||||
|
</span>
|
||||||
|
</AutoLink>
|
||||||
|
</div>
|
||||||
|
<span v-else class="font-semibold text-contrast">{{ formatMessage(messages.noModpack) }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,25%)] items-center gap-4 text-base text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
|
||||||
|
>
|
||||||
|
<span>{{ formatMessage(messages.installedContent) }}</span>
|
||||||
|
<span class="font-semibold text-contrast">{{ installedContentLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,45%)] items-center gap-4 text-base text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
|
||||||
|
>
|
||||||
|
<span>{{ formatMessage(messages.lastActive) }}</span>
|
||||||
|
<span class="font-semibold text-contrast">{{ lastActiveLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="grid min-h-6 grid-cols-[minmax(0,1fr)_minmax(0,45%)] items-center gap-4 text-base text-secondary [&>*:last-child]:max-w-full [&>*:last-child]:justify-self-end"
|
||||||
|
>
|
||||||
|
<span>{{ formatMessage(messages.created) }}</span>
|
||||||
|
<span class="font-semibold text-contrast">{{ createdLabel }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="flex items-center justify-between gap-3 px-5 py-4">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button class="!shadow-none" @click="emit('edit', world.id)">
|
||||||
|
<PencilIcon aria-hidden="true" />
|
||||||
|
{{ formatMessage(messages.editInstance) }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<button
|
||||||
|
v-tooltip="formatMessage(messages.instanceSettings)"
|
||||||
|
class="!shadow-none"
|
||||||
|
@click="emit('settings', world.id)"
|
||||||
|
>
|
||||||
|
<Settings2Icon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { PencilIcon, PlusIcon, Settings2Icon } from '@modrinth/assets'
|
||||||
|
import { capitalizeString } from '@modrinth/utils'
|
||||||
|
import { computed, useId, useTemplateRef } from 'vue'
|
||||||
|
|
||||||
|
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||||
|
import Avatar from '#ui/components/base/Avatar.vue'
|
||||||
|
import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||||
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||||
|
import { useFormatDateTime, useRelativeTime } from '#ui/composables'
|
||||||
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
|
import { truncatedTooltip } from '#ui/utils'
|
||||||
|
import { commonMessages } from '#ui/utils/common-messages'
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
newInstance: {
|
||||||
|
id: 'servers.manage.instances.card.empty-description',
|
||||||
|
defaultMessage: 'New instance',
|
||||||
|
},
|
||||||
|
createInstance: {
|
||||||
|
id: 'servers.manage.instances.card.create',
|
||||||
|
defaultMessage: 'Create instance',
|
||||||
|
},
|
||||||
|
active: {
|
||||||
|
id: 'servers.manage.instances.card.active',
|
||||||
|
defaultMessage: 'Active',
|
||||||
|
},
|
||||||
|
noModpack: {
|
||||||
|
id: 'servers.manage.instances.card.no-modpack',
|
||||||
|
defaultMessage: '—',
|
||||||
|
},
|
||||||
|
installedContent: {
|
||||||
|
id: 'servers.manage.instances.card.installed-content',
|
||||||
|
defaultMessage: 'Installed content',
|
||||||
|
},
|
||||||
|
lastActive: {
|
||||||
|
id: 'servers.manage.instances.card.last-active',
|
||||||
|
defaultMessage: 'Last active',
|
||||||
|
},
|
||||||
|
created: {
|
||||||
|
id: 'servers.manage.instances.card.created',
|
||||||
|
defaultMessage: 'Created',
|
||||||
|
},
|
||||||
|
editInstance: {
|
||||||
|
id: 'servers.manage.instances.card.edit',
|
||||||
|
defaultMessage: 'Edit instance',
|
||||||
|
},
|
||||||
|
instanceSettings: {
|
||||||
|
id: 'servers.manage.instances.card.settings',
|
||||||
|
defaultMessage: 'Instance settings',
|
||||||
|
},
|
||||||
|
notTrackedYet: {
|
||||||
|
id: 'servers.manage.instances.card.not-tracked-yet',
|
||||||
|
defaultMessage: 'Not tracked yet',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type LinkedModpack = {
|
||||||
|
name: string
|
||||||
|
iconUrl: string | null
|
||||||
|
link: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsedWorld = {
|
||||||
|
type: 'world'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
active: boolean
|
||||||
|
gameVersion: string | null
|
||||||
|
loaderLabel: string | null
|
||||||
|
linkedModpack: LinkedModpack | null
|
||||||
|
installedContentCount: number | null
|
||||||
|
lastActiveAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type EmptyWorld = {
|
||||||
|
type: 'empty'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
world: UsedWorld | EmptyWorld
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
create: [slotId: string]
|
||||||
|
edit: [worldId: string]
|
||||||
|
settings: [worldId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
const formatDate = useFormatDateTime({ dateStyle: 'medium' })
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const emptyCardClipId = useId()
|
||||||
|
|
||||||
|
const modpackNameRef = useTemplateRef<HTMLElement>('modpackNameRef')
|
||||||
|
const worldNameRef = useTemplateRef<HTMLElement>('worldNameRef')
|
||||||
|
|
||||||
|
const installedContentLabel = computed(() => {
|
||||||
|
if (props.world.type === 'empty') return ''
|
||||||
|
return props.world.installedContentCount === null
|
||||||
|
? formatMessage(commonMessages.unknownLabel)
|
||||||
|
: String(props.world.installedContentCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
const lastActiveLabel = computed(() => {
|
||||||
|
if (props.world.type === 'empty') return ''
|
||||||
|
return props.world.lastActiveAt
|
||||||
|
? capitalizeString(formatRelativeTime(props.world.lastActiveAt))
|
||||||
|
: formatMessage(messages.notTrackedYet)
|
||||||
|
})
|
||||||
|
|
||||||
|
const createdLabel = computed(() => {
|
||||||
|
if (props.world.type === 'empty') return ''
|
||||||
|
return props.world.createdAt
|
||||||
|
? formatDate(props.world.createdAt)
|
||||||
|
: formatMessage(commonMessages.unknownLabel)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="contents">
|
<div class="contents">
|
||||||
<div class="flex flex-row items-center gap-2 rounded-lg">
|
<div class="flex flex-row items-center gap-2 rounded-lg">
|
||||||
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
|
<ButtonStyled v-if="isInstalling" type="standard" color="brand" :size="size">
|
||||||
<button disabled class="flex-shrink-0">
|
<button disabled class="flex-shrink-0">
|
||||||
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
|
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
|
|
||||||
<template v-else-if="showRestartButton">
|
<template v-else-if="showRestartButton">
|
||||||
<ButtonStyled type="standard" color="orange" size="large">
|
<ButtonStyled type="standard" color="orange" :size="size">
|
||||||
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||||
<UpdatedIcon />
|
<UpdatedIcon />
|
||||||
<span>{{ primaryActionText }}</span>
|
<span>{{ primaryActionText }}</span>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
<JoinedButtons
|
<JoinedButtons
|
||||||
color="red"
|
color="red"
|
||||||
size="large"
|
:size="size"
|
||||||
:actions="stopSplitActions"
|
:actions="stopSplitActions"
|
||||||
:primary-disabled="!canTakeAction"
|
:primary-disabled="!canTakeAction"
|
||||||
:dropdown-disabled="!canKill"
|
:dropdown-disabled="!canKill"
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<template v-else-if="isStopping">
|
<template v-else-if="isStopping">
|
||||||
<JoinedButtons
|
<JoinedButtons
|
||||||
color="red"
|
color="red"
|
||||||
size="large"
|
:size="size"
|
||||||
:actions="stopSplitActions"
|
:actions="stopSplitActions"
|
||||||
:primary-disabled="true"
|
:primary-disabled="true"
|
||||||
:dropdown-disabled="!canKill"
|
:dropdown-disabled="!canKill"
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ButtonStyled type="standard" color="brand" size="large">
|
<ButtonStyled type="standard" color="brand" :size="size">
|
||||||
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
|
||||||
<PlayIcon />
|
<PlayIcon />
|
||||||
<span>{{ primaryActionText }}</span>
|
<span>{{ startActionText }}</span>
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</template>
|
</template>
|
||||||
@@ -74,9 +74,13 @@ import { useServerPowerAction } from './use-server-power-action'
|
|||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
size?: 'standard' | 'large' | 'small'
|
||||||
|
startLabel?: string
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
disabled: false,
|
disabled: false,
|
||||||
|
size: 'large',
|
||||||
|
startLabel: 'Start',
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -94,6 +98,11 @@ const {
|
|||||||
disabled: computed(() => props.disabled),
|
disabled: computed(() => props.disabled),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const size = computed(() => props.size)
|
||||||
|
const startActionText = computed(() =>
|
||||||
|
primaryActionText.value === 'Start' ? props.startLabel : primaryActionText.value,
|
||||||
|
)
|
||||||
|
|
||||||
const stopSplitActions = computed<JoinedButtonAction[]>(() => [
|
const stopSplitActions = computed<JoinedButtonAction[]>(() => [
|
||||||
{
|
{
|
||||||
id: 'stop',
|
id: 'stop',
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="contents">
|
|
||||||
<ButtonStyled circular type="transparent" size="large">
|
|
||||||
<TeleportOverflowMenu :options="menuOptions">
|
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
|
||||||
<template #allServers>
|
|
||||||
<ServerIcon class="h-5 w-5" />
|
|
||||||
<span>All servers</span>
|
|
||||||
</template>
|
|
||||||
<template #copy-id>
|
|
||||||
<ClipboardCopyIcon class="h-5 w-5" aria-hidden="true" />
|
|
||||||
<span>Copy ID</span>
|
|
||||||
</template>
|
|
||||||
</TeleportOverflowMenu>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ClipboardCopyIcon, MoreVerticalIcon, ServerIcon } from '@modrinth/assets'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
|
|
||||||
import { ButtonStyled } from '#ui/components'
|
|
||||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
|
||||||
import { injectModrinthServerContext } from '#ui/providers'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
disabled?: boolean
|
|
||||||
showCopyIdAction?: boolean
|
|
||||||
showDebugInfo?: boolean
|
|
||||||
uptimeSeconds?: number
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
disabled: false,
|
|
||||||
showCopyIdAction: false,
|
|
||||||
showDebugInfo: false,
|
|
||||||
uptimeSeconds: 0,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const { serverId } = injectModrinthServerContext()
|
|
||||||
|
|
||||||
const menuOptions = computed(() => [
|
|
||||||
{
|
|
||||||
id: 'allServers',
|
|
||||||
label: 'All servers',
|
|
||||||
icon: ServerIcon,
|
|
||||||
action: () => router.push('/hosting/manage'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'copy-id',
|
|
||||||
label: 'Copy ID',
|
|
||||||
icon: ClipboardCopyIcon,
|
|
||||||
action: () => copyId(),
|
|
||||||
shown: props.showCopyIdAction,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
async function copyId() {
|
|
||||||
await navigator.clipboard.writeText(serverId)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,96 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
|
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
|
||||||
<ContentPageHeader :class="props.headerClass">
|
<PageHeader
|
||||||
<template #icon>
|
:header="props.server?.name || 'Server'"
|
||||||
<ServerIcon
|
:leading="leadingItems"
|
||||||
:image="headerImage"
|
:metadata="metadataItems"
|
||||||
:class="isNuxt ? 'size-20 !rounded-2xl' : 'size-16 !rounded-xl'"
|
:actions="headerActions"
|
||||||
/>
|
:header-class="props.headerClass"
|
||||||
</template>
|
/>
|
||||||
<template #title>
|
|
||||||
{{ props.server?.name || 'Server' }}
|
|
||||||
</template>
|
|
||||||
<template #stats>
|
|
||||||
<div
|
|
||||||
v-if="props.server?.flows?.intro"
|
|
||||||
class="flex items-center gap-2 font-semibold text-secondary"
|
|
||||||
>
|
|
||||||
<SettingsIcon />
|
|
||||||
Configuring server...
|
|
||||||
</div>
|
|
||||||
<div v-else class="flex flex-wrap items-center gap-2">
|
|
||||||
<div v-if="props.server?.loader" class="flex items-center gap-2 font-medium capitalize">
|
|
||||||
<LoaderIcon :loader="props.server.loader" class="flex shrink-0 [&&]:size-5" />
|
|
||||||
{{ props.server.loader }} {{ props.server.mc_version }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="
|
|
||||||
props.server?.loader &&
|
|
||||||
props.server?.net?.domain &&
|
|
||||||
!userPreferences.hideSubdomainLabel
|
|
||||||
"
|
|
||||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="props.server?.net?.domain && !userPreferences.hideSubdomainLabel"
|
|
||||||
v-tooltip="'Copy server address'"
|
|
||||||
class="flex cursor-pointer items-center gap-2 font-medium hover:underline text-nowrap"
|
|
||||||
@click="copyServerAddress"
|
|
||||||
>
|
|
||||||
<LinkIcon class="flex size-5 shrink-0" />
|
|
||||||
{{ props.server.net.domain }}.modrinth.gg
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="showUptime" class="h-1.5 w-1.5 rounded-full bg-surface-5" />
|
|
||||||
|
|
||||||
<div v-if="showUptime" class="flex items-center gap-2 font-medium">
|
|
||||||
<TimerIcon class="flex size-5 shrink-0" />
|
|
||||||
{{ formattedUptime }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="showProject && (props.server?.loader || props.server?.net?.domain || showUptime)"
|
|
||||||
class="h-1.5 w-1.5 rounded-full bg-surface-5"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="showProject"
|
|
||||||
class="flex items-center gap-1.5 font-medium text-primary text-nowrap"
|
|
||||||
>
|
|
||||||
Linked to
|
|
||||||
<Avatar
|
|
||||||
:src="props.serverProject?.icon_url ?? undefined"
|
|
||||||
:alt="props.serverProject?.title ?? ''"
|
|
||||||
size="24px"
|
|
||||||
/>
|
|
||||||
<AutoLink :to="serverProjectLink" class="truncate text-primary hover:underline">
|
|
||||||
{{ props.serverProject?.title }}
|
|
||||||
</AutoLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<slot name="actions" />
|
|
||||||
</template>
|
|
||||||
</ContentPageHeader>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Archon } from '@modrinth/api-client'
|
import type { Archon } from '@modrinth/api-client'
|
||||||
import { NuxtModrinthClient } from '@modrinth/api-client'
|
import { NuxtModrinthClient } from '@modrinth/api-client'
|
||||||
import { LinkIcon, LoaderIcon, SettingsIcon, TimerIcon } from '@modrinth/assets'
|
|
||||||
import { useStorage } from '@vueuse/core'
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
import { AutoLink, Avatar, ContentPageHeader, ServerIcon } from '#ui/components'
|
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
GlobeIcon,
|
||||||
injectModrinthServerContext,
|
LinkIcon,
|
||||||
injectNotificationManager,
|
LoaderCircleIcon,
|
||||||
} from '#ui/providers'
|
PlayIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
SlashIcon,
|
||||||
|
StopCircleIcon,
|
||||||
|
UpdatedIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { RouteLocationRaw } from 'vue-router'
|
||||||
|
|
||||||
|
import type { JoinedButtonAction } from '#ui/components/base/JoinedButtons.vue'
|
||||||
|
import PageHeader from '#ui/components/base/PageHeader.vue'
|
||||||
|
import ServerIcon from '#ui/components/servers/icons/ServerIcon.vue'
|
||||||
|
import { injectModrinthClient, injectNotificationManager } from '#ui/providers'
|
||||||
|
|
||||||
|
import { useServerPowerAction } from './use-server-power-action'
|
||||||
|
|
||||||
type ServerProjectSummary = {
|
type ServerProjectSummary = {
|
||||||
id: string
|
id: string
|
||||||
@@ -99,37 +41,115 @@ type ServerProjectSummary = {
|
|||||||
icon_url?: string | null
|
icon_url?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HeaderWorld = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderMetadataItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
to?: string | RouteLocationRaw
|
||||||
|
onClick?: () => void | Promise<void>
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type HeaderAction = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
iconClass?: string
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
to?: string | RouteLocationRaw
|
||||||
|
onClick?: () => void | Promise<void>
|
||||||
|
disabled?: boolean
|
||||||
|
labelHidden?: boolean
|
||||||
|
circular?: boolean
|
||||||
|
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||||
|
size?: 'standard' | 'large' | 'small'
|
||||||
|
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
|
||||||
|
joinedActions?: JoinedButtonAction[]
|
||||||
|
primaryDisabled?: boolean
|
||||||
|
dropdownDisabled?: boolean
|
||||||
|
primaryMuted?: boolean
|
||||||
|
prompt?: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
dismissLabel?: string
|
||||||
|
shown?: boolean
|
||||||
|
placement?: string
|
||||||
|
onDismiss?: () => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
server: Archon.Servers.v0.Server | null | undefined
|
server: Archon.Servers.v0.Server | null | undefined
|
||||||
serverImage?: string | null
|
serverImage?: string | null
|
||||||
serverProject?: ServerProjectSummary | null
|
serverProject?: ServerProjectSummary | null
|
||||||
serverProjectLink?: string
|
serverProjectLink?: string
|
||||||
|
activeWorldName?: string | null
|
||||||
uptimeSeconds?: number
|
uptimeSeconds?: number
|
||||||
showUptime?: boolean
|
showUptime?: boolean
|
||||||
backHref?: string
|
backHref?: string
|
||||||
breadcrumbClass?: string
|
breadcrumbClass?: string
|
||||||
headerClass?: string
|
headerClass?: string
|
||||||
|
worlds?: HeaderWorld[]
|
||||||
|
powerDisabled?: boolean
|
||||||
|
settingsLabel?: string
|
||||||
|
showSettingsHint?: boolean
|
||||||
|
settingsHintTitle?: string
|
||||||
|
settingsHintDescription?: string
|
||||||
|
settingsHintDismissLabel?: string
|
||||||
|
actions?: HeaderAction[]
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
serverImage: null,
|
serverImage: null,
|
||||||
serverProject: null,
|
serverProject: null,
|
||||||
serverProjectLink: '',
|
serverProjectLink: '',
|
||||||
|
activeWorldName: null,
|
||||||
uptimeSeconds: 0,
|
uptimeSeconds: 0,
|
||||||
showUptime: true,
|
showUptime: true,
|
||||||
backHref: '/hosting/manage',
|
backHref: '/hosting/manage',
|
||||||
breadcrumbClass: 'breadcrumb goto-link flex w-fit items-center',
|
breadcrumbClass: 'breadcrumb goto-link flex w-fit items-center',
|
||||||
headerClass: '',
|
headerClass: '',
|
||||||
|
worlds: () => [],
|
||||||
|
powerDisabled: false,
|
||||||
|
settingsLabel: 'Server settings',
|
||||||
|
showSettingsHint: false,
|
||||||
|
settingsHintTitle: '',
|
||||||
|
settingsHintDescription: '',
|
||||||
|
settingsHintDismissLabel: "Don't show again",
|
||||||
|
actions: () => [],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
openSettings: []
|
||||||
|
dismissSettingsHint: []
|
||||||
|
}>()
|
||||||
|
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const { serverId } = injectModrinthServerContext()
|
|
||||||
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||||
|
const {
|
||||||
const userPreferences = useStorage(`pyro-server-${serverId}-preferences`, {
|
isInstalling,
|
||||||
hideSubdomainLabel: false,
|
isStopping,
|
||||||
|
showRestartButton,
|
||||||
|
busyTooltip,
|
||||||
|
canTakeAction,
|
||||||
|
canKill,
|
||||||
|
primaryActionText,
|
||||||
|
initiateAction,
|
||||||
|
handlePrimaryAction,
|
||||||
|
} = useServerPowerAction({
|
||||||
|
disabled: computed(() => props.powerDisabled),
|
||||||
})
|
})
|
||||||
|
|
||||||
const headerImage = computed(() => {
|
const headerImage = computed(() => {
|
||||||
@@ -139,37 +159,206 @@ const headerImage = computed(() => {
|
|||||||
return props.serverImage ?? undefined
|
return props.serverImage ?? undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
const showUptime = computed(() => props.showUptime && (props.uptimeSeconds ?? 0) > 0)
|
const leadingItems = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'server-icon',
|
||||||
|
type: 'component' as const,
|
||||||
|
component: ServerIcon,
|
||||||
|
componentProps: {
|
||||||
|
image: headerImage.value,
|
||||||
|
},
|
||||||
|
class: isNuxt.value ? 'size-15 !rounded-2xl' : 'size-15 !rounded-xl',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
const formattedUptime = computed(() => {
|
const serverAddress = computed(() => {
|
||||||
const uptime = props.uptimeSeconds ?? 0
|
const domain = props.server?.net?.domain
|
||||||
const days = Math.floor(uptime / (24 * 3600))
|
if (domain) return `${domain}.modrinth.gg`
|
||||||
const hours = Math.floor((uptime % (24 * 3600)) / 3600)
|
|
||||||
const minutes = Math.floor((uptime % 3600) / 60)
|
|
||||||
const seconds = uptime % 60
|
|
||||||
|
|
||||||
let formatted = ''
|
const ip = props.server?.net?.ip
|
||||||
if (days > 0) formatted += `${days}d `
|
if (!ip) return null
|
||||||
if (hours > 0 || days > 0) formatted += `${hours}h `
|
const port = props.server?.net?.port
|
||||||
formatted += `${minutes}m ${seconds}s`
|
return port ? `${ip}:${port}` : ip
|
||||||
return formatted.trim()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const showProject = computed(() => !!props.serverProject)
|
const metadataItems = computed<HeaderMetadataItem[]>(() => {
|
||||||
|
if (props.server?.flows?.intro) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'configuring',
|
||||||
|
label: 'Configuring server...',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
const serverProjectLink = computed(() => {
|
const items: HeaderMetadataItem[] = []
|
||||||
if (props.serverProjectLink) {
|
const worldName = props.activeWorldName
|
||||||
return props.serverProjectLink
|
if (worldName) {
|
||||||
|
items.push({
|
||||||
|
id: 'world',
|
||||||
|
label: worldName,
|
||||||
|
icon: GlobeIcon,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (!props.serverProject) {
|
if (serverAddress.value) {
|
||||||
return ''
|
items.push({
|
||||||
|
id: 'address',
|
||||||
|
label: serverAddress.value,
|
||||||
|
icon: LinkIcon,
|
||||||
|
tooltip: 'Copy server address',
|
||||||
|
onClick: copyServerAddress,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return `/project/${props.serverProject.slug ?? props.serverProject.id}`
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const startActionText = computed(() =>
|
||||||
|
primaryActionText.value === 'Start' ? 'Start server' : primaryActionText.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const startableWorlds = computed(() => {
|
||||||
|
if (props.worlds.length > 0) return props.worlds
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'current-world',
|
||||||
|
name: props.activeWorldName ?? 'current instance',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const startSplitActions = computed<JoinedButtonAction[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'start',
|
||||||
|
label: startActionText.value,
|
||||||
|
icon: PlayIcon,
|
||||||
|
action: handlePrimaryAction,
|
||||||
|
},
|
||||||
|
...startableWorlds.value.map((world) => ({
|
||||||
|
id: `start-${world.id}`,
|
||||||
|
label: `Start with ${world.name}`,
|
||||||
|
icon: GlobeIcon,
|
||||||
|
action: handlePrimaryAction,
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
|
||||||
|
const restartSplitActions = computed<JoinedButtonAction[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'restart',
|
||||||
|
label: primaryActionText.value,
|
||||||
|
icon: UpdatedIcon,
|
||||||
|
action: () => initiateAction('Restart'),
|
||||||
|
},
|
||||||
|
// TODO: Implement world scoping when Archon/Kyros support target worlds in power requests.
|
||||||
|
...startableWorlds.value.map((world) => ({
|
||||||
|
id: `restart-${world.id}`,
|
||||||
|
label: `Restart with ${world.name}`,
|
||||||
|
icon: GlobeIcon,
|
||||||
|
action: () => initiateAction('Restart'),
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
|
||||||
|
const stopSplitActions = computed<JoinedButtonAction[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'stop',
|
||||||
|
label: isStopping.value ? 'Stopping' : 'Stop',
|
||||||
|
icon: StopCircleIcon,
|
||||||
|
action: () => initiateAction('Stop'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kill_server',
|
||||||
|
label: 'Kill server',
|
||||||
|
icon: SlashIcon,
|
||||||
|
action: () => initiateAction('Kill'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const powerActions = computed<HeaderAction[]>(() => {
|
||||||
|
if (isInstalling.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'installing',
|
||||||
|
label: 'Installing...',
|
||||||
|
icon: LoaderCircleIcon,
|
||||||
|
iconClass: 'animate-spin',
|
||||||
|
color: 'brand',
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (showRestartButton.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'restart',
|
||||||
|
label: primaryActionText.value,
|
||||||
|
color: 'orange',
|
||||||
|
joinedActions: restartSplitActions.value,
|
||||||
|
primaryDisabled: !canTakeAction.value,
|
||||||
|
dropdownDisabled: !canTakeAction.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stop',
|
||||||
|
label: 'Stop server',
|
||||||
|
color: 'red',
|
||||||
|
joinedActions: stopSplitActions.value,
|
||||||
|
primaryDisabled: !canTakeAction.value,
|
||||||
|
dropdownDisabled: !canKill.value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (isStopping.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'stop',
|
||||||
|
label: 'Stop server',
|
||||||
|
color: 'red',
|
||||||
|
joinedActions: stopSplitActions.value,
|
||||||
|
primaryDisabled: true,
|
||||||
|
dropdownDisabled: !canKill.value,
|
||||||
|
primaryMuted: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'start',
|
||||||
|
label: startActionText.value,
|
||||||
|
color: 'brand',
|
||||||
|
tooltip: busyTooltip.value,
|
||||||
|
joinedActions: startSplitActions.value,
|
||||||
|
primaryDisabled: !canTakeAction.value,
|
||||||
|
dropdownDisabled: !canTakeAction.value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const settingsAction = computed<HeaderAction>(() => ({
|
||||||
|
id: 'settings',
|
||||||
|
label: props.settingsLabel,
|
||||||
|
icon: SettingsIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: props.showSettingsHint ? undefined : props.settingsLabel,
|
||||||
|
onClick: () => emit('openSettings'),
|
||||||
|
prompt: {
|
||||||
|
title: props.settingsHintTitle,
|
||||||
|
description: props.settingsHintDescription,
|
||||||
|
dismissLabel: props.settingsHintDismissLabel,
|
||||||
|
shown: props.showSettingsHint,
|
||||||
|
placement: 'bottom-end',
|
||||||
|
onDismiss: () => emit('dismissSettingsHint'),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
const headerActions = computed<HeaderAction[]>(() => [
|
||||||
|
...powerActions.value,
|
||||||
|
settingsAction.value,
|
||||||
|
...props.actions,
|
||||||
|
])
|
||||||
|
|
||||||
function copyServerAddress() {
|
function copyServerAddress() {
|
||||||
if (!props.server?.net?.domain) return
|
if (!serverAddress.value) return
|
||||||
navigator.clipboard.writeText(`${props.server.net.domain}.modrinth.gg`)
|
navigator.clipboard.writeText(serverAddress.value)
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Server address copied',
|
title: 'Server address copied',
|
||||||
text: "Your server's address has been copied to your clipboard.",
|
text: "Your server's address has been copied to your clipboard.",
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
|
||||||
|
<PageHeader
|
||||||
|
:header="props.name || props.fallbackName"
|
||||||
|
:leading="leadingItems"
|
||||||
|
:metadata="headerMetadata"
|
||||||
|
:actions="props.actions"
|
||||||
|
:header-class="props.headerClass"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { NuxtModrinthClient } from '@modrinth/api-client'
|
||||||
|
import { LeftArrowIcon, TagCategoryGamepad2Icon as Gamepad2Icon, TimerIcon } from '@modrinth/assets'
|
||||||
|
import { type Component, computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import PageHeader from '#ui/components/base/PageHeader.vue'
|
||||||
|
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
|
||||||
|
import { injectModrinthClient } from '#ui/providers'
|
||||||
|
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||||
|
|
||||||
|
type MetadataItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
type HeaderAction = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
iconClass?: string
|
||||||
|
tooltip?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
onClick?: () => void | Promise<void>
|
||||||
|
disabled?: boolean
|
||||||
|
labelHidden?: boolean
|
||||||
|
circular?: boolean
|
||||||
|
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||||
|
size?: 'standard' | 'large' | 'small'
|
||||||
|
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
|
||||||
|
joinedActions?: {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: Component
|
||||||
|
action: () => void
|
||||||
|
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||||
|
hoverFilled?: boolean
|
||||||
|
}[]
|
||||||
|
primaryDisabled?: boolean
|
||||||
|
dropdownDisabled?: boolean
|
||||||
|
primaryMuted?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
name?: string | null
|
||||||
|
metadataItems?: MetadataItem[]
|
||||||
|
gameVersion?: string | null
|
||||||
|
loader?: string | null
|
||||||
|
loaderVersion?: string | null
|
||||||
|
lastActive?: string | null
|
||||||
|
backHref: string
|
||||||
|
backLabel: string
|
||||||
|
fallbackName?: string
|
||||||
|
headerClass?: string
|
||||||
|
actions?: HeaderAction[]
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
name: null,
|
||||||
|
metadataItems: () => [],
|
||||||
|
gameVersion: null,
|
||||||
|
loader: null,
|
||||||
|
loaderVersion: null,
|
||||||
|
lastActive: null,
|
||||||
|
fallbackName: 'World',
|
||||||
|
headerClass: '',
|
||||||
|
actions: () => [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const router = useRouter()
|
||||||
|
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
|
||||||
|
const leadingItems = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'back',
|
||||||
|
type: 'button' as const,
|
||||||
|
icon: LeftArrowIcon,
|
||||||
|
ariaLabel: props.backLabel,
|
||||||
|
tooltip: props.backLabel,
|
||||||
|
onClick: () => router.push(props.backHref),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const loaderLabel = computed(() => {
|
||||||
|
if (!props.loader) return null
|
||||||
|
const label = formatLoaderLabel(props.loader.toLowerCase())
|
||||||
|
return [label, props.loaderVersion].filter(Boolean).join(' ')
|
||||||
|
})
|
||||||
|
const headerMetadata = computed<MetadataItem[]>(() => {
|
||||||
|
if (props.metadataItems.length) return props.metadataItems
|
||||||
|
|
||||||
|
const items: MetadataItem[] = []
|
||||||
|
if (props.gameVersion) {
|
||||||
|
items.push({
|
||||||
|
id: 'game-version',
|
||||||
|
label: props.gameVersion,
|
||||||
|
icon: Gamepad2Icon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (props.loader && loaderLabel.value) {
|
||||||
|
items.push({
|
||||||
|
id: 'loader',
|
||||||
|
label: loaderLabel.value,
|
||||||
|
icon: LoaderIcon,
|
||||||
|
iconProps: {
|
||||||
|
loader: formatLoaderLabel(props.loader.toLowerCase()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (props.lastActive) {
|
||||||
|
items.push({
|
||||||
|
id: 'last-active',
|
||||||
|
label: props.lastActive,
|
||||||
|
icon: TimerIcon,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
export { default as PanelServerActionButton } from './PanelServerActionButton.vue'
|
export { default as PanelServerActionButton } from './PanelServerActionButton.vue'
|
||||||
export { default as PanelServerOverflowMenu } from './PanelServerOverflowMenu.vue'
|
|
||||||
export { default as ServerManageHeader } from './ServerManageHeader.vue'
|
export { default as ServerManageHeader } from './ServerManageHeader.vue'
|
||||||
|
export { default as WorldManageHeader } from './WorldManageHeader.vue'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Archon } from '@modrinth/api-client'
|
||||||
import { computed, type Ref } from 'vue'
|
import { computed, type Ref } from 'vue'
|
||||||
|
|
||||||
import { useVIntl } from '#ui/composables/i18n'
|
import { useVIntl } from '#ui/composables/i18n'
|
||||||
@@ -7,7 +8,7 @@ import {
|
|||||||
injectNotificationManager,
|
injectNotificationManager,
|
||||||
} from '#ui/providers'
|
} from '#ui/providers'
|
||||||
|
|
||||||
export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
|
export type PowerAction = Archon.Servers.v0.PowerAction
|
||||||
|
|
||||||
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
|
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ export * from './i18n'
|
|||||||
export * from './i18n-debug'
|
export * from './i18n-debug'
|
||||||
export * from './page-leave-safety'
|
export * from './page-leave-safety'
|
||||||
export * from './scroll-indicator'
|
export * from './scroll-indicator'
|
||||||
export * from './server-backup'
|
export * from './servers/server-backup'
|
||||||
export * from './server-backups-queue'
|
export * from './servers/server-backups-queue'
|
||||||
export * from './server-console'
|
export * from './servers/server-console'
|
||||||
export * from './server-manage-core-runtime'
|
export * from './servers/server-manage-core-runtime'
|
||||||
|
export * from './servers/use-server-image'
|
||||||
export * from './sticky-observer'
|
export * from './sticky-observer'
|
||||||
export * from './terminal'
|
export * from './terminal'
|
||||||
export * from './use-loading-bar-token'
|
export * from './use-loading-bar-token'
|
||||||
export * from './use-loading-state-core'
|
export * from './use-loading-state-core'
|
||||||
export * from './use-ready-state'
|
export * from './use-ready-state'
|
||||||
export * from './use-server-image'
|
|
||||||
export * from './use-server-project'
|
export * from './use-server-project'
|
||||||
export * from './virtual-scroll'
|
export * from './virtual-scroll'
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import type { Archon } from '@modrinth/api-client'
|
import type { Archon } from '@modrinth/api-client'
|
||||||
|
|
||||||
import { injectModrinthClient } from '../providers/api-client'
|
import { injectModrinthClient } from '../../providers/api-client'
|
||||||
import { injectNotificationManager } from '../providers/web-notifications'
|
import { injectNotificationManager } from '../../providers/web-notifications'
|
||||||
|
|
||||||
export function useServerBackupDownload() {
|
export function useServerBackupDownload() {
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
+2
-2
@@ -4,7 +4,7 @@ import { computed, reactive, type Ref } from 'vue'
|
|||||||
|
|
||||||
import { type BusyReason, injectModrinthClient } from '#ui/providers'
|
import { type BusyReason, injectModrinthClient } from '#ui/providers'
|
||||||
|
|
||||||
import { defineMessage } from './i18n'
|
import { defineMessage } from '../i18n'
|
||||||
|
|
||||||
type ProgressKey = `${string}:${'create' | 'restore'}`
|
type ProgressKey = `${string}:${'create' | 'restore'}`
|
||||||
|
|
||||||
@@ -12,7 +12,7 @@ export function useServerBackupsQueue(serverId: Ref<string>, worldId: Ref<string
|
|||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const queryKey = computed(() => ['backups', 'queue', serverId.value] as const)
|
const queryKey = computed(() => ['backups', 'queue', serverId.value, worldId.value] as const)
|
||||||
|
|
||||||
const progressOverlay = reactive(new Map<ProgressKey, number>())
|
const progressOverlay = reactive(new Map<ProgressKey, number>())
|
||||||
const lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>()
|
const lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>()
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { createGlobalState } from '@vueuse/core'
|
import { createGlobalState } from '@vueuse/core'
|
||||||
import { type Ref, ref, shallowRef, triggerRef } from 'vue'
|
import { type Ref, ref, shallowRef, triggerRef } from 'vue'
|
||||||
|
|
||||||
import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
|
import { detectLogLevel } from '../../layouts/shared/console/composables/log-level'
|
||||||
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
|
import type { Log4jEvent, LogLevel, LogLine } from '../../layouts/shared/console/types'
|
||||||
|
|
||||||
// Flip to true during development to enable console perf logging.
|
// Flip to true during development to enable console perf logging.
|
||||||
// Uses a plain constant to avoid turbo env-var declarations.
|
// Uses a plain constant to avoid turbo env-var declarations.
|
||||||
+4
-4
@@ -8,10 +8,10 @@ import type { Stats } from '@modrinth/utils'
|
|||||||
import type { ComputedRef, Ref } from 'vue'
|
import type { ComputedRef, Ref } from 'vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
import type { FileOperation } from '../layouts/shared/files-tab/types'
|
import type { FileOperation } from '../../layouts/shared/files-tab/types'
|
||||||
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
|
import { injectModrinthClient, provideModrinthServerContext } from '../../providers'
|
||||||
import type { BusyReason, CancelUploadHandler } from '../providers/server-context'
|
import type { BusyReason, CancelUploadHandler } from '../../providers/server-context'
|
||||||
import { defineMessage } from './i18n'
|
import { defineMessage } from '../i18n'
|
||||||
import { useModrinthServersConsole } from './server-console'
|
import { useModrinthServersConsole } from './server-console'
|
||||||
|
|
||||||
type ReadableRef<T> = Ref<T> | ComputedRef<T>
|
type ReadableRef<T> = Ref<T> | ComputedRef<T>
|
||||||
+19
-5
@@ -5,6 +5,7 @@ import { computed, type ComputedRef, ref } from 'vue'
|
|||||||
import { injectModrinthClient } from '#ui/providers'
|
import { injectModrinthClient } from '#ui/providers'
|
||||||
|
|
||||||
type UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
|
type UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
|
||||||
|
type ServerIdSource = string | { readonly value: string }
|
||||||
|
|
||||||
type UseServerImageOptions = {
|
type UseServerImageOptions = {
|
||||||
enabled?: ComputedRef<boolean> | boolean
|
enabled?: ComputedRef<boolean> | boolean
|
||||||
@@ -39,7 +40,7 @@ function isNotFound(error: unknown): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useServerImage(
|
export function useServerImage(
|
||||||
serverId: string,
|
serverId: ServerIdSource,
|
||||||
upstream: UpstreamRef,
|
upstream: UpstreamRef,
|
||||||
options: UseServerImageOptions = {},
|
options: UseServerImageOptions = {},
|
||||||
) {
|
) {
|
||||||
@@ -47,24 +48,33 @@ export function useServerImage(
|
|||||||
const localImage = ref<string | null | undefined>(undefined)
|
const localImage = ref<string | null | undefined>(undefined)
|
||||||
const iconSize = options.size ?? 512
|
const iconSize = options.size ?? 512
|
||||||
const includeProjectFallback = options.includeProjectFallback ?? false
|
const includeProjectFallback = options.includeProjectFallback ?? false
|
||||||
|
const resolvedServerId = computed(() => resolveServerId(serverId))
|
||||||
|
|
||||||
const queryKey = computed(
|
const queryKey = computed(
|
||||||
() => ['servers', 'detail', serverId, 'icon', upstream.value?.project_id ?? null] as const,
|
() =>
|
||||||
|
[
|
||||||
|
'servers',
|
||||||
|
'detail',
|
||||||
|
resolvedServerId.value,
|
||||||
|
'icon',
|
||||||
|
upstream.value?.project_id ?? null,
|
||||||
|
] as const,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isEnabled = computed(() => {
|
const isEnabled = computed(() => {
|
||||||
const explicitEnabled =
|
const explicitEnabled =
|
||||||
typeof options.enabled === 'boolean' ? options.enabled : options.enabled?.value
|
typeof options.enabled === 'boolean' ? options.enabled : options.enabled?.value
|
||||||
return !!serverId && (explicitEnabled ?? true)
|
return !!resolvedServerId.value && (explicitEnabled ?? true)
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: remoteImage, refetch } = useQuery({
|
const { data: remoteImage, refetch } = useQuery({
|
||||||
queryKey,
|
queryKey,
|
||||||
queryFn: async (): Promise<string | null> => {
|
queryFn: async (): Promise<string | null> => {
|
||||||
if (!serverId) return null
|
const id = resolvedServerId.value
|
||||||
|
if (!id) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(id)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
|
const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
|
||||||
@@ -132,3 +142,7 @@ export function useServerImage(
|
|||||||
resetLocalOverride,
|
resetLocalOverride,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveServerId(serverId: ServerIdSource): string {
|
||||||
|
return typeof serverId === 'string' ? serverId : serverId.value
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import type { ComputedRef, Ref, ShallowRef } from 'vue'
|
|||||||
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
import { computed, nextTick, ref, shallowRef, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
|
||||||
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
|
import type { FilterType, FilterValue, ProjectType, SortType } from '#ui/utils/search'
|
||||||
import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search'
|
import { LOADER_FILTER_TYPES, useSearch } from '#ui/utils/search'
|
||||||
import { useServerSearch } from '#ui/utils/server-search'
|
import { useServerSearch } from '#ui/utils/server-search'
|
||||||
@@ -18,6 +17,7 @@ export interface UseBrowseSearchOptions {
|
|||||||
categories: Labrinth.Tags.v2.Category[]
|
categories: Labrinth.Tags.v2.Category[]
|
||||||
}>
|
}>
|
||||||
providedFilters?: ComputedRef<FilterValue[]>
|
providedFilters?: ComputedRef<FilterValue[]>
|
||||||
|
active?: ComputedRef<boolean>
|
||||||
search: (params: string) => Promise<BrowseSearchResponse>
|
search: (params: string) => Promise<BrowseSearchResponse>
|
||||||
persistentQueryParams: string[]
|
persistentQueryParams: string[]
|
||||||
getExtraQueryParams?: () => Record<string, string | undefined>
|
getExtraQueryParams?: () => Record<string, string | undefined>
|
||||||
@@ -61,12 +61,10 @@ export interface BrowseSearchState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
|
export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchState {
|
||||||
const debug = useDebugLogger('BrowseSearch')
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
debug('init, projectType:', options.projectType.value)
|
const active = computed(() => options.active?.value ?? true)
|
||||||
|
|
||||||
const projectTypes = computed(() => [options.projectType.value] as ProjectType[])
|
const projectTypes = computed(() => [options.projectType.value] as ProjectType[])
|
||||||
const isServerType = computed(() => options.projectType.value === 'server')
|
const isServerType = computed(() => options.projectType.value === 'server')
|
||||||
|
|
||||||
@@ -172,6 +170,13 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
|||||||
let searchVersion = 0
|
let searchVersion = 0
|
||||||
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function clearSearchDebounce() {
|
||||||
|
if (searchDebounceTimer) {
|
||||||
|
clearTimeout(searchDebounceTimer)
|
||||||
|
searchDebounceTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? [])
|
const providedFiltersOrEmpty = computed(() => options.providedFilters?.value ?? [])
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -192,24 +197,29 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
|||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(effectiveRequestParams, (newVal, oldVal) => {
|
watch(effectiveRequestParams, () => {
|
||||||
debug('effectiveRequestParams changed', {
|
clearSearchDebounce()
|
||||||
from: oldVal?.substring(0, 80),
|
if (!active.value) {
|
||||||
to: newVal?.substring(0, 80),
|
return
|
||||||
})
|
}
|
||||||
if (searchDebounceTimer) clearTimeout(searchDebounceTimer)
|
|
||||||
searchDebounceTimer = setTimeout(() => {
|
searchDebounceTimer = setTimeout(() => {
|
||||||
refreshSearch()
|
refreshSearch()
|
||||||
}, 200)
|
}, 200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(active, (isActive, wasActive) => {
|
||||||
|
clearSearchDebounce()
|
||||||
|
if (isActive && wasActive === false) {
|
||||||
|
void refreshSearch()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
async function refreshSearch() {
|
async function refreshSearch() {
|
||||||
|
if (!active.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const version = ++searchVersion
|
const version = ++searchVersion
|
||||||
debug('refreshSearch start', {
|
|
||||||
version,
|
|
||||||
projectType: options.projectType.value,
|
|
||||||
params: effectiveRequestParams.value.substring(0, 100),
|
|
||||||
})
|
|
||||||
|
|
||||||
const currentHitsEmpty = isServerType.value
|
const currentHitsEmpty = isServerType.value
|
||||||
? serverHits.value.length === 0
|
? serverHits.value.length === 0
|
||||||
@@ -221,8 +231,11 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
|||||||
try {
|
try {
|
||||||
const response = await options.search(effectiveRequestParams.value)
|
const response = await options.search(effectiveRequestParams.value)
|
||||||
|
|
||||||
|
if (!active.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (version !== searchVersion) {
|
if (version !== searchVersion) {
|
||||||
debug('refreshSearch stale, discarding', { version, current: searchVersion })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,17 +245,10 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
|||||||
projectHits.value = response.projectHits
|
projectHits.value = response.projectHits
|
||||||
}
|
}
|
||||||
totalHits.value = response.total_hits
|
totalHits.value = response.total_hits
|
||||||
debug('refreshSearch complete', {
|
|
||||||
version,
|
|
||||||
hits: response.total_hits,
|
|
||||||
projectHits: response.projectHits.length,
|
|
||||||
serverHits: response.serverHits.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
updateUrlParams()
|
updateUrlParams()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug('refreshSearch error', err)
|
|
||||||
console.error('Browse search error:', err)
|
console.error('Browse search error:', err)
|
||||||
if (version === searchVersion) {
|
if (version === searchVersion) {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -251,7 +257,9 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateUrlParams() {
|
function updateUrlParams() {
|
||||||
debug('updateUrlParams', { path: route.path })
|
if (!active.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}
|
const persistentParams: Record<string, string | (string | null)[] | null | undefined> = {}
|
||||||
|
|
||||||
for (const [key, value] of Object.entries(route.query)) {
|
for (const [key, value] of Object.entries(route.query)) {
|
||||||
@@ -292,8 +300,7 @@ export function useBrowseSearch(options: UseBrowseSearchOptions): BrowseSearchSt
|
|||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => options.projectType.value,
|
() => options.projectType.value,
|
||||||
(newType, oldType) => {
|
() => {
|
||||||
debug('projectType changed', { from: oldType, to: newType })
|
|
||||||
effectiveCurrentSortType.value =
|
effectiveCurrentSortType.value =
|
||||||
effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ??
|
effectiveSortTypes.value.find((sortType) => sortType.name === 'relevance') ??
|
||||||
effectiveSortTypes.value[0]
|
effectiveSortTypes.value[0]
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { LeftArrowIcon } from '@modrinth/assets'
|
import { LeftArrowIcon, TagCategoryGamepad2Icon as Gamepad2Icon } from '@modrinth/assets'
|
||||||
|
import type { Component } from 'vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import Admonition from '#ui/components/base/Admonition.vue'
|
import PageHeader from '#ui/components/base/PageHeader.vue'
|
||||||
import Avatar from '#ui/components/base/Avatar.vue'
|
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
|
||||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
import { useServerImage } from '#ui/composables/servers/use-server-image.ts'
|
||||||
import { useServerImage } from '#ui/composables/use-server-image'
|
|
||||||
import { formatLoaderLabel } from '#ui/utils/loaders'
|
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||||
|
|
||||||
import SelectedProjectsLeaveModal from './components/SelectedProjectsLeaveModal.vue'
|
import SelectedProjectsLeaveModal from './components/SelectedProjectsLeaveModal.vue'
|
||||||
@@ -18,8 +18,17 @@ const MEDAL_ICON_URL = 'https://cdn-raw.modrinth.com/medal_icon.webp'
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
installContext?: BrowseInstallContext | null
|
installContext?: BrowseInstallContext | null
|
||||||
|
divider?: boolean
|
||||||
|
bottomPadding?: boolean
|
||||||
}>()
|
}>()
|
||||||
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
|
type SelectedProjectsLeaveResult = 'cancel' | 'discard' | 'install'
|
||||||
|
type BrowseHeaderMetadataItem = {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
icon?: Component
|
||||||
|
iconProps?: Record<string, unknown>
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
const ctx = injectBrowseManager(null)
|
const ctx = injectBrowseManager(null)
|
||||||
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
|
const installContext = computed(() => props.installContext ?? ctx?.installContext?.value ?? null)
|
||||||
@@ -37,14 +46,66 @@ const iconSrc = computed(() => {
|
|||||||
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
|
return fetchedIcon.value ?? installContext.value?.iconSrc ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const leadingItems = computed(() => {
|
||||||
|
const context = installContext.value
|
||||||
|
if (!context) return []
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'back',
|
||||||
|
type: 'button' as const,
|
||||||
|
icon: LeftArrowIcon,
|
||||||
|
ariaLabel: context.backLabel,
|
||||||
|
tooltip: context.backLabel,
|
||||||
|
onClick: handleBack,
|
||||||
|
},
|
||||||
|
...(iconSrc.value
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'icon',
|
||||||
|
type: 'avatar' as const,
|
||||||
|
src: iconSrc.value,
|
||||||
|
alt: context.name,
|
||||||
|
avatarSize: '48px',
|
||||||
|
class: 'shrink-0',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
const metadataItems = computed(() => {
|
const metadataItems = computed(() => {
|
||||||
const context = installContext.value
|
const context = installContext.value
|
||||||
if (!context) return []
|
if (!context) return []
|
||||||
return [
|
|
||||||
context.heading,
|
const items: BrowseHeaderMetadataItem[] = []
|
||||||
context.gameVersion ? `MC ${context.gameVersion}` : '',
|
if (context.heading) {
|
||||||
context.loader ? formatLoaderLabel(context.loader) : '',
|
items.push({
|
||||||
].filter(Boolean)
|
id: 'heading',
|
||||||
|
label: context.heading,
|
||||||
|
class: '!text-primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (context.gameVersion) {
|
||||||
|
items.push({
|
||||||
|
id: 'game-version',
|
||||||
|
label: context.gameVersion,
|
||||||
|
icon: Gamepad2Icon,
|
||||||
|
class: '!text-primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (context.loader) {
|
||||||
|
const loaderName = formatLoaderLabel(context.loader)
|
||||||
|
const loaderLabel = [loaderName, context.loaderVersion].filter(Boolean).join(' ')
|
||||||
|
items.push({
|
||||||
|
id: 'loader',
|
||||||
|
label: loaderLabel,
|
||||||
|
icon: LoaderIcon,
|
||||||
|
iconProps: { loader: loaderName },
|
||||||
|
class: '!text-primary',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
})
|
})
|
||||||
|
|
||||||
const selectedCount = computed(() => installContext.value?.selectedProjects?.length ?? 0)
|
const selectedCount = computed(() => installContext.value?.selectedProjects?.length ?? 0)
|
||||||
@@ -94,39 +155,15 @@ async function handleSelectedProjectsLeaveResult(
|
|||||||
:count="selectedCount"
|
:count="selectedCount"
|
||||||
:installing="isInstallingSelected"
|
:installing="isInstallingSelected"
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-col gap-2">
|
<PageHeader
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
:header="installContext.name"
|
||||||
<div class="flex min-w-0 items-center gap-4">
|
:leading="leadingItems"
|
||||||
<ButtonStyled circular size="large">
|
:metadata="metadataItems"
|
||||||
<button :aria-label="installContext.backLabel" @click="handleBack">
|
:divider="props.divider ?? false"
|
||||||
<LeftArrowIcon />
|
:bottom-padding="props.bottomPadding ?? false"
|
||||||
</button>
|
main-class="items-center"
|
||||||
</ButtonStyled>
|
title-class="leading-8"
|
||||||
|
truncate-title
|
||||||
<Avatar v-if="iconSrc" :src="iconSrc" size="48px" class="shrink-0" />
|
/>
|
||||||
|
|
||||||
<div class="flex min-w-0 flex-col justify-center gap-1">
|
|
||||||
<h1 class="m-0 truncate text-2xl font-semibold leading-8 text-contrast">
|
|
||||||
{{ installContext.name }}
|
|
||||||
</h1>
|
|
||||||
<div
|
|
||||||
v-if="metadataItems.length"
|
|
||||||
class="flex flex-wrap items-center gap-2 text-base font-medium leading-6 text-primary"
|
|
||||||
>
|
|
||||||
<template v-for="(item, index) in metadataItems" :key="item">
|
|
||||||
<span
|
|
||||||
v-if="index > 0"
|
|
||||||
class="h-1.5 w-1.5 shrink-0 rounded-full bg-current opacity-60"
|
|
||||||
/>
|
|
||||||
<span>{{ item }}</span>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
|
|
||||||
{{ installContext.warning }}
|
|
||||||
</Admonition>
|
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Labrinth } from '@modrinth/api-client'
|
import type { Labrinth } from '@modrinth/api-client'
|
||||||
import { SearchIcon } from '@modrinth/assets'
|
import { SearchIcon } from '@modrinth/assets'
|
||||||
import { computed, ref, toValue } from 'vue'
|
import { computed, toValue } from 'vue'
|
||||||
|
|
||||||
|
import Admonition from '#ui/components/base/Admonition.vue'
|
||||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||||
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
import Combobox, { type ComboboxOption } from '#ui/components/base/Combobox.vue'
|
||||||
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
import LoadingIndicator from '#ui/components/base/LoadingIndicator.vue'
|
||||||
@@ -13,22 +14,15 @@ import ProjectCard from '#ui/components/project/card/ProjectCard.vue'
|
|||||||
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
|
import ProjectCardList from '#ui/components/project/ProjectCardList.vue'
|
||||||
import SearchFilterControl from '#ui/components/search/SearchFilterControl.vue'
|
import SearchFilterControl from '#ui/components/search/SearchFilterControl.vue'
|
||||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
import { useStickyObserver } from '#ui/composables/sticky-observer'
|
|
||||||
import { commonMessages } from '#ui/utils/common-messages'
|
import { commonMessages } from '#ui/utils/common-messages'
|
||||||
import type { SortType } from '#ui/utils/search'
|
import type { SortType } from '#ui/utils/search'
|
||||||
|
|
||||||
import SelectedProjectsFloatingBar from './components/SelectedProjectsFloatingBar.vue'
|
|
||||||
import BrowseInstallHeader from './header.vue'
|
|
||||||
import { injectBrowseManager } from './providers/browse-manager'
|
import { injectBrowseManager } from './providers/browse-manager'
|
||||||
|
|
||||||
const ctx = injectBrowseManager()
|
const ctx = injectBrowseManager()
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
const lockedMessages = computed(() => toValue(ctx.lockedFilterMessages))
|
||||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
const installWarning = computed(() => ctx.installContext?.value?.warning)
|
||||||
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
|
||||||
stickyInstallHeaderRef,
|
|
||||||
'BrowseInstallHeader',
|
|
||||||
)
|
|
||||||
|
|
||||||
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
|
const sortOptions = computed<ComboboxOption<SortType>[]>(() =>
|
||||||
ctx.effectiveSortTypes.value.map((st) => ({
|
ctx.effectiveSortTypes.value.map((st) => ({
|
||||||
@@ -70,16 +64,9 @@ const messages = defineMessages({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
|
<Admonition v-if="installWarning" type="warning">
|
||||||
<div
|
{{ installWarning }}
|
||||||
ref="stickyInstallHeaderRef"
|
</Admonition>
|
||||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
|
|
||||||
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
|
||||||
>
|
|
||||||
<BrowseInstallHeader />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<SelectedProjectsFloatingBar v-if="ctx.installContext?.value && ctx.variant !== 'web'" />
|
|
||||||
|
|
||||||
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
|
<NavTabs v-if="ctx.showProjectTypeTabs.value" :links="ctx.selectableProjectTypes.value" />
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface BrowseSelectedProject {
|
|||||||
export interface BrowseInstallContext {
|
export interface BrowseInstallContext {
|
||||||
name: string
|
name: string
|
||||||
loader: string
|
loader: string
|
||||||
|
loaderVersion?: string | null
|
||||||
gameVersion: string
|
gameVersion: string
|
||||||
serverId?: string | null
|
serverId?: string | null
|
||||||
upstream?: { project_id?: string | null } | null
|
upstream?: { project_id?: string | null } | null
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { onBeforeRouteLeave } from 'vue-router'
|
import { onBeforeRouteLeave } from 'vue-router'
|
||||||
|
|
||||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
|
||||||
import {
|
import {
|
||||||
injectAppBackup,
|
injectAppBackup,
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
|
|||||||
@@ -206,18 +206,19 @@ const isInstalling = computed(() => {
|
|||||||
})
|
})
|
||||||
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
|
const installationSettingsLayout = ref<InstanceType<typeof InstallationSettingsLayout>>()
|
||||||
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
|
const setupModal = ref<InstanceType<typeof ServerSetupModal>>()
|
||||||
|
const contentListQueryKey = computed(() => ['content', 'list', 'v1', serverId, worldId.value])
|
||||||
|
|
||||||
async function invalidateServerState() {
|
async function invalidateServerState() {
|
||||||
debug('invalidateServerState: starting')
|
debug('invalidateServerState: starting')
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
|
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] }),
|
queryClient.invalidateQueries({ queryKey: contentListQueryKey.value }),
|
||||||
])
|
])
|
||||||
debug('invalidateServerState: complete')
|
debug('invalidateServerState: complete')
|
||||||
}
|
}
|
||||||
|
|
||||||
const addonsQuery = useQuery({
|
const addonsQuery = useQuery({
|
||||||
queryKey: computed(() => ['content', 'list', 'v1', serverId]),
|
queryKey: contentListQueryKey,
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||||
enabled: computed(() => worldId.value !== null),
|
enabled: computed(() => worldId.value !== null),
|
||||||
@@ -628,7 +629,7 @@ provideInstallationSettings({
|
|||||||
const previousData = addonsQuery.data.value
|
const previousData = addonsQuery.data.value
|
||||||
if (previousData) {
|
if (previousData) {
|
||||||
debug('unlinkModpack: optimistically removing modpack from cache')
|
debug('unlinkModpack: optimistically removing modpack from cache')
|
||||||
queryClient.setQueryData(['content', 'list', 'v1', serverId], {
|
queryClient.setQueryData(contentListQueryKey.value, {
|
||||||
...previousData,
|
...previousData,
|
||||||
modpack: null,
|
modpack: null,
|
||||||
})
|
})
|
||||||
@@ -640,7 +641,7 @@ provideInstallationSettings({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
debug('unlinkModpack: failed, reverting cache', err)
|
debug('unlinkModpack: failed, reverting cache', err)
|
||||||
if (previousData) {
|
if (previousData) {
|
||||||
queryClient.setQueryData(['content', 'list', 'v1', serverId], previousData)
|
queryClient.setQueryData(contentListQueryKey.value, previousData)
|
||||||
}
|
}
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
@@ -653,7 +654,7 @@ provideInstallationSettings({
|
|||||||
queryKey: ['servers', 'detail', serverId],
|
queryKey: ['servers', 'detail', serverId],
|
||||||
}),
|
}),
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['content', 'list', 'v1', serverId],
|
queryKey: contentListQueryKey.value,
|
||||||
}),
|
}),
|
||||||
])
|
])
|
||||||
debug('unlinkModpack: invalidation complete')
|
debug('unlinkModpack: invalidation complete')
|
||||||
|
|||||||
@@ -284,8 +284,10 @@ const { addNotification } = injectNotificationManager()
|
|||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
|
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const filesTabLink = computed(
|
const filesTabLink = computed(() =>
|
||||||
() => `/hosting/manage/${encodeURIComponent(serverId)}/files?path=/&editing=server.properties`,
|
worldId.value
|
||||||
|
? `/hosting/manage/${encodeURIComponent(serverId)}/instances/${encodeURIComponent(worldId.value)}/files?path=/&editing=server.properties`
|
||||||
|
: `/hosting/manage/${encodeURIComponent(serverId)}/instances`,
|
||||||
)
|
)
|
||||||
const serverSettings = injectServerSettings(null)
|
const serverSettings = injectServerSettings(null)
|
||||||
|
|
||||||
|
|||||||
+400
-167
@@ -22,8 +22,8 @@
|
|||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<ErrorInformationCard
|
<ErrorInformationCard
|
||||||
title="We're getting your server ready"
|
:title="formatMessage(messages.serverPreparingTitle)"
|
||||||
description="Your server's hardware is being prepared and will be available shortly!"
|
:description="formatMessage(messages.serverPreparingDescription)"
|
||||||
:icon="TransferIcon"
|
:icon="TransferIcon"
|
||||||
icon-color="blue"
|
icon-color="blue"
|
||||||
:action="generalErrorAction"
|
:action="generalErrorAction"
|
||||||
@@ -34,8 +34,8 @@
|
|||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<ErrorInformationCard
|
<ErrorInformationCard
|
||||||
title="Server upgrading"
|
:title="formatMessage(messages.serverUpgradingTitle)"
|
||||||
description="Your server's hardware is currently being upgraded and will be back online shortly!"
|
:description="formatMessage(messages.serverUpgradingDescription)"
|
||||||
:icon="TransferIcon"
|
:icon="TransferIcon"
|
||||||
icon-color="blue"
|
icon-color="blue"
|
||||||
:action="generalErrorAction"
|
:action="generalErrorAction"
|
||||||
@@ -46,7 +46,7 @@
|
|||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<ErrorInformationCard
|
<ErrorInformationCard
|
||||||
title="Server suspended"
|
:title="formatMessage(messages.serverSuspendedTitle)"
|
||||||
:description="suspendedDescription"
|
:description="suspendedDescription"
|
||||||
:icon="LockIcon"
|
:icon="LockIcon"
|
||||||
icon-color="orange"
|
icon-color="orange"
|
||||||
@@ -58,8 +58,8 @@
|
|||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<ErrorInformationCard
|
<ErrorInformationCard
|
||||||
title="An error occured."
|
:title="formatMessage(messages.generalErrorTitle)"
|
||||||
description="Please contact Modrinth Support."
|
:description="formatMessage(messages.contactSupportDescription)"
|
||||||
:icon="TransferIcon"
|
:icon="TransferIcon"
|
||||||
icon-color="orange"
|
icon-color="orange"
|
||||||
:error-details="generalErrorDetails"
|
:error-details="generalErrorDetails"
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
class="flex min-h-[calc(100vh-4rem)] items-center justify-center text-contrast"
|
||||||
>
|
>
|
||||||
<ErrorInformationCard
|
<ErrorInformationCard
|
||||||
title="Server Node Unavailable"
|
:title="formatMessage(messages.nodeUnavailableTitle)"
|
||||||
:icon="TriangleAlertIcon"
|
:icon="TriangleAlertIcon"
|
||||||
icon-color="red"
|
icon-color="red"
|
||||||
:action="nodeUnavailableAction"
|
:action="nodeUnavailableAction"
|
||||||
@@ -80,22 +80,29 @@
|
|||||||
<template #description>
|
<template #description>
|
||||||
<div class="text-md space-y-4">
|
<div class="text-md space-y-4">
|
||||||
<p class="leading-[170%] text-secondary">
|
<p class="leading-[170%] text-secondary">
|
||||||
Your server's node, where your Modrinth Server is physically hosted, is not accessible
|
{{ formatMessage(messages.nodeUnavailableDescription) }}
|
||||||
at the moment. We are working to resolve the issue as quickly as possible.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="leading-[170%] text-secondary">
|
<p class="leading-[170%] text-secondary">
|
||||||
Your data is safe and will not be lost, and your server will be back online as soon as
|
{{ formatMessage(messages.nodeUnavailableDataDescription) }}
|
||||||
the issue is resolved.
|
|
||||||
</p>
|
</p>
|
||||||
<p class="leading-[170%] text-secondary">
|
<p class="leading-[170%] text-secondary">
|
||||||
If reloading does not work initially, please contact Modrinth Support via the chat
|
{{ formatMessage(messages.nodeUnavailableSupportDescription) }}
|
||||||
bubble in the bottom right corner and we'll be happy to help.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ErrorInformationCard>
|
</ErrorInformationCard>
|
||||||
</div>
|
</div>
|
||||||
<!-- SERVER START -->
|
<div
|
||||||
|
v-else-if="serverData && isInstanceDetailRoute"
|
||||||
|
data-pyro-instance-manager-root
|
||||||
|
class="experimental-styles-within relative mx-auto box-border flex w-full min-w-0 flex-col px-6 transition-all duration-300"
|
||||||
|
:class="[
|
||||||
|
'server-panel-' + revealState,
|
||||||
|
isNuxt ? 'min-h-[100svh] max-w-[1280px] pb-16' : 'min-h-[calc(100svh-100px)] pb-6',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot :on-reinstall="onReinstall" :on-reinstall-failed="onReinstallFailed" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="serverData"
|
v-else-if="serverData"
|
||||||
data-pyro-server-manager-root
|
data-pyro-server-manager-root
|
||||||
@@ -118,61 +125,18 @@
|
|||||||
:server="serverData"
|
:server="serverData"
|
||||||
:server-image="serverImage"
|
:server-image="serverImage"
|
||||||
:server-project="serverProject"
|
:server-project="serverProject"
|
||||||
|
:active-world-name="activeWorldName"
|
||||||
:uptime-seconds="showUptime ? uptimeSeconds : undefined"
|
:uptime-seconds="showUptime ? uptimeSeconds : undefined"
|
||||||
>
|
:worlds="serverFull?.worlds ?? []"
|
||||||
<template #actions>
|
:power-disabled="!!installError"
|
||||||
<div class="flex gap-2">
|
:settings-label="formatMessage(messages.serverSettings)"
|
||||||
<PanelServerActionButton :disabled="!!installError" />
|
:show-settings-hint="showSettingsHint"
|
||||||
<Tooltip
|
:settings-hint-title="formatMessage(settingsHintMessages.title)"
|
||||||
theme="dismissable-prompt"
|
:settings-hint-description="formatMessage(settingsHintMessages.description)"
|
||||||
:triggers="[]"
|
:settings-hint-dismiss-label="formatMessage(settingsHintMessages.dismiss)"
|
||||||
:shown="showSettingsHint"
|
@open-settings="openServerSettingsModal()"
|
||||||
:auto-hide="false"
|
@dismiss-settings-hint="dismissSettingsHint"
|
||||||
placement="bottom-end"
|
/>
|
||||||
>
|
|
||||||
<ButtonStyled circular size="large">
|
|
||||||
<button
|
|
||||||
v-tooltip="showSettingsHint ? undefined : 'Server settings'"
|
|
||||||
@click="
|
|
||||||
() => {
|
|
||||||
openServerSettingsModal()
|
|
||||||
dismissSettingsHint()
|
|
||||||
}
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<SettingsIcon />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<template #popper>
|
|
||||||
<div class="grid grid-cols-[min-content] gap-1">
|
|
||||||
<div class="flex min-w-48 items-center justify-between gap-8">
|
|
||||||
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
|
||||||
{{ formatMessage(settingsHintMessages.title) }}
|
|
||||||
</h3>
|
|
||||||
<ButtonStyled size="small" circular>
|
|
||||||
<button
|
|
||||||
v-tooltip="formatMessage(settingsHintMessages.dismiss)"
|
|
||||||
@click="dismissSettingsHint"
|
|
||||||
>
|
|
||||||
<XIcon aria-hidden="true" />
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
|
||||||
{{ formatMessage(settingsHintMessages.description) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</Tooltip>
|
|
||||||
<PanelServerOverflowMenu
|
|
||||||
:disabled="!!installError"
|
|
||||||
:uptime-seconds="uptimeSeconds"
|
|
||||||
:show-copy-id-action="showCopyIdAction"
|
|
||||||
:show-debug-info="showAdvancedDebugInfo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ServerManageHeader>
|
|
||||||
|
|
||||||
<ServerOnboardingPanelPage v-if="isOnboarding" :browse-modpacks="handleBrowseModpacks" />
|
<ServerOnboardingPanelPage v-if="isOnboarding" :browse-modpacks="handleBrowseModpacks" />
|
||||||
|
|
||||||
@@ -199,70 +163,59 @@
|
|||||||
<div class="flex flex-col gap-2 leading-[150%]">
|
<div class="flex flex-col gap-2 leading-[150%]">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
|
<IssuesIcon class="flex h-8 w-8 shrink-0 text-red sm:hidden" />
|
||||||
<div class="flex gap-2 text-2xl font-bold">{{ errorTitle }}</div>
|
<div class="flex gap-2 text-2xl font-bold">{{ errorTitleLabel }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-if="errorTitle === 'installation'" class="font-normal">
|
||||||
v-if="errorTitle.toLocaleLowerCase() === 'installation error'"
|
|
||||||
class="font-normal"
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
|
errorMessage.toLocaleLowerCase() === 'the specified version may be incorrect'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
An invalid loader or Minecraft version was specified and could not be installed.
|
{{ formatMessage(messages.installInvalidVersionDescription) }}
|
||||||
<ul class="m-0 mt-4 p-0 pl-4">
|
<ul class="m-0 mt-4 p-0 pl-4">
|
||||||
<li>
|
<li>
|
||||||
If this version of Minecraft was released recently, please check if Modrinth
|
{{ formatMessage(messages.installRecentMinecraftVersionNotice) }}
|
||||||
Hosting supports it.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If you've installed a modpack, it may have been packaged incorrectly or may
|
{{ formatMessage(messages.installModpackCompatibilityNotice) }}
|
||||||
not be compatible with the loader.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
Your server may need to be reinstalled with a valid mod loader and version.
|
{{ formatMessage(messages.installChangeLoaderNotice) }}
|
||||||
You can change the loader by clicking the "Change Loader" button.
|
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
If you're stuck, please contact Modrinth Support with the information below:
|
{{ formatMessage(messages.installSupportNotice) }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button class="mt-2" @click="copyServerDebugInfo">
|
<button class="mt-2" @click="copyServerDebugInfo">
|
||||||
<CopyIcon v-if="!copied" />
|
<CopyIcon v-if="!copied" />
|
||||||
<CheckIcon v-else />
|
<CheckIcon v-else />
|
||||||
Copy Debug Info
|
{{ formatMessage(messages.copyDebugInfo) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
|
<div v-if="errorMessage.toLocaleLowerCase() === 'internal error'">
|
||||||
An internal error occurred while installing your server. Don't fret — try
|
{{ formatMessage(messages.installInternalErrorDescription) }}
|
||||||
reinstalling your server, and if the problem persists, please contact Modrinth
|
|
||||||
support with your server's debug information.
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
|
v-if="errorMessage.toLocaleLowerCase() === 'this version is not yet supported'"
|
||||||
>
|
>
|
||||||
An error occurred while installing your server because Modrinth Hosting does not
|
{{ formatMessage(messages.installUnsupportedVersionDescription) }}
|
||||||
support the version of Minecraft or the loader you specified. Try reinstalling
|
|
||||||
your server with a different version or loader, and if the problem persists,
|
|
||||||
please contact Modrinth Support with your server's debug information.
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div class="mt-2 flex flex-col gap-4 sm:flex-row">
|
||||||
v-if="errorTitle === 'Installation error'"
|
|
||||||
class="mt-2 flex flex-col gap-4 sm:flex-row"
|
|
||||||
>
|
|
||||||
<ButtonStyled v-if="errorLog">
|
<ButtonStyled v-if="errorLog">
|
||||||
<button @click="openInstallLog"><FileIcon />Open Installation Log</button>
|
<button @click="openInstallLog">
|
||||||
|
<FileIcon />
|
||||||
|
{{ formatMessage(messages.openInstallationLog) }}
|
||||||
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled>
|
<ButtonStyled>
|
||||||
<button @click="copyServerDebugInfo">
|
<button @click="copyServerDebugInfo">
|
||||||
<CopyIcon v-if="!copied" />
|
<CopyIcon v-if="!copied" />
|
||||||
<CheckIcon v-else />
|
<CheckIcon v-else />
|
||||||
Copy Debug Info
|
{{ formatMessage(messages.copyDebugInfo) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
<ButtonStyled color="red" type="standard">
|
<ButtonStyled color="red" type="standard">
|
||||||
@@ -271,7 +224,7 @@
|
|||||||
@click="openServerSettingsModal('installation')"
|
@click="openServerSettingsModal('installation')"
|
||||||
>
|
>
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
Change Loader
|
{{ formatMessage(messages.changeLoader) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
@@ -295,7 +248,7 @@
|
|||||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
|
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-red p-4 text-contrast"
|
||||||
>
|
>
|
||||||
<IssuesIcon class="size-5 text-red" />
|
<IssuesIcon class="size-5 text-red" />
|
||||||
Something went wrong...
|
{{ formatMessage(messages.websocketError) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -304,7 +257,7 @@
|
|||||||
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
|
class="mb-4 flex w-full flex-row items-center gap-4 rounded-2xl bg-bg-orange p-4 text-sm text-contrast"
|
||||||
>
|
>
|
||||||
<LoaderCircleIcon class="h-5 w-5 animate-spin" />
|
<LoaderCircleIcon class="h-5 w-5 animate-spin" />
|
||||||
Hang on, we're reconnecting to your server.
|
{{ formatMessage(messages.websocketReconnecting) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ServerPanelAdmonitions
|
<ServerPanelAdmonitions
|
||||||
@@ -319,10 +272,12 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="showAdvancedDebugInfo"
|
v-if="showAdvancedDebugInfo && !isInstanceDetailRoute"
|
||||||
class="relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
|
class="experimental-styles-within relative mx-auto mt-6 box-border w-full min-w-0 max-w-[1280px] px-6"
|
||||||
>
|
>
|
||||||
<h2 class="m-0 text-lg font-extrabold text-contrast">Server data</h2>
|
<h2 class="m-0 text-lg font-extrabold text-contrast">
|
||||||
|
{{ formatMessage(messages.serverDataTitle) }}
|
||||||
|
</h2>
|
||||||
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
|
<pre class="markdown-body w-full overflow-auto rounded-2xl bg-bg-raised p-4 text-sm">{{
|
||||||
safeStringify(serverData)
|
safeStringify(serverData)
|
||||||
}}</pre>
|
}}</pre>
|
||||||
@@ -346,27 +301,22 @@
|
|||||||
import type { Archon, Labrinth } from '@modrinth/api-client'
|
import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||||
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
|
import { ModrinthApiError, NuxtModrinthClient } from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
BoxesIcon,
|
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
DatabaseBackupIcon,
|
|
||||||
FileIcon,
|
FileIcon,
|
||||||
FolderOpenIcon,
|
GlobeIcon,
|
||||||
IssuesIcon,
|
IssuesIcon,
|
||||||
LayoutTemplateIcon,
|
LayoutTemplateIcon,
|
||||||
LoaderCircleIcon,
|
LoaderCircleIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
SettingsIcon,
|
|
||||||
TransferIcon,
|
TransferIcon,
|
||||||
TriangleAlertIcon,
|
TriangleAlertIcon,
|
||||||
XIcon,
|
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import type { Stats } from '@modrinth/utils'
|
import type { Stats } from '@modrinth/utils'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { useStorage, useTimeoutFn } from '@vueuse/core'
|
import { useStorage, useTimeoutFn } from '@vueuse/core'
|
||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify'
|
||||||
import { Tooltip } from 'floating-vue'
|
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -377,11 +327,7 @@ import ServerNotice from '#ui/components/base/ServerNotice.vue'
|
|||||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||||
import ServerPanelAdmonitions from '#ui/components/servers/admonitions/ServerPanelAdmonitions.vue'
|
import ServerPanelAdmonitions from '#ui/components/servers/admonitions/ServerPanelAdmonitions.vue'
|
||||||
import MedalServerCountdown from '#ui/components/servers/marketing/MedalServerCountdown.vue'
|
import MedalServerCountdown from '#ui/components/servers/marketing/MedalServerCountdown.vue'
|
||||||
import {
|
import { ServerManageHeader } from '#ui/components/servers/server-header'
|
||||||
PanelServerActionButton,
|
|
||||||
PanelServerOverflowMenu,
|
|
||||||
ServerManageHeader,
|
|
||||||
} from '#ui/components/servers/server-header'
|
|
||||||
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
|
import ServerSettingsModal from '#ui/components/servers/ServerSettingsModal.vue'
|
||||||
import {
|
import {
|
||||||
useDebugLogger,
|
useDebugLogger,
|
||||||
@@ -392,8 +338,8 @@ import {
|
|||||||
useServerProject,
|
useServerProject,
|
||||||
} from '#ui/composables'
|
} from '#ui/composables'
|
||||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
|
||||||
import { useServerManageCoreRuntime } from '#ui/composables/server-manage-core-runtime'
|
import { useServerManageCoreRuntime } from '#ui/composables/servers/server-manage-core-runtime.ts'
|
||||||
import type { LogLine } from '#ui/layouts/shared/console'
|
import type { LogLine } from '#ui/layouts/shared/console'
|
||||||
import type { ServerSettingsTabId } from '#ui/layouts/shared/server-settings'
|
import type { ServerSettingsTabId } from '#ui/layouts/shared/server-settings'
|
||||||
import {
|
import {
|
||||||
@@ -408,13 +354,21 @@ import {
|
|||||||
writePendingServerContentInstalls,
|
writePendingServerContentInstalls,
|
||||||
} from '#ui/utils/server-content-installing'
|
} from '#ui/utils/server-content-installing'
|
||||||
|
|
||||||
import ServerOnboardingPanelPage from './[id]/onboarding.vue'
|
import ServerOnboardingPanelPage from './onboarding.vue'
|
||||||
|
|
||||||
interface Tab {
|
interface Tab {
|
||||||
label: string
|
label: string
|
||||||
href: string
|
href: string
|
||||||
icon?: object
|
icon?: object
|
||||||
subpages?: string[]
|
subpages?: string[]
|
||||||
|
prompt?: {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
dismissLabel?: string
|
||||||
|
shown?: boolean
|
||||||
|
placement?: string
|
||||||
|
onDismiss?: () => void
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
@@ -422,7 +376,6 @@ const props = withDefaults(
|
|||||||
serverId: string
|
serverId: string
|
||||||
reloadPage: () => void
|
reloadPage: () => void
|
||||||
resolveViewer: () => Promise<{ userId: string | null; userRole: string | null }>
|
resolveViewer: () => Promise<{ userId: string | null; userRole: string | null }>
|
||||||
showCopyIdAction?: boolean
|
|
||||||
showAdvancedDebugInfo?: boolean
|
showAdvancedDebugInfo?: boolean
|
||||||
showUptime?: boolean
|
showUptime?: boolean
|
||||||
additionalTabs?: Tab[]
|
additionalTabs?: Tab[]
|
||||||
@@ -444,7 +397,6 @@ const props = withDefaults(
|
|||||||
}) => void | Promise<void>
|
}) => void | Promise<void>
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
showCopyIdAction: false,
|
|
||||||
showAdvancedDebugInfo: false,
|
showAdvancedDebugInfo: false,
|
||||||
showUptime: true,
|
showUptime: true,
|
||||||
additionalTabs: () => [],
|
additionalTabs: () => [],
|
||||||
@@ -487,6 +439,230 @@ const settingsHintMessages = defineMessages({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const instancesHintMessages = defineMessages({
|
||||||
|
title: {
|
||||||
|
id: 'servers.manage.instances-hint.title',
|
||||||
|
defaultMessage: 'New: Server Instances!',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
id: 'servers.manage.instances-hint.description',
|
||||||
|
defaultMessage: 'Your server state has been converted into an instance.',
|
||||||
|
},
|
||||||
|
dismiss: {
|
||||||
|
id: 'servers.manage.instances-hint.dismiss',
|
||||||
|
defaultMessage: "Don't show again",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
serverPreparingTitle: {
|
||||||
|
id: 'servers.manage.status.preparing.title',
|
||||||
|
defaultMessage: "We're getting your server ready",
|
||||||
|
},
|
||||||
|
serverPreparingDescription: {
|
||||||
|
id: 'servers.manage.status.preparing.description',
|
||||||
|
defaultMessage: "Your server's hardware is being prepared and will be available shortly!",
|
||||||
|
},
|
||||||
|
serverUpgradingTitle: {
|
||||||
|
id: 'servers.manage.status.upgrading.title',
|
||||||
|
defaultMessage: 'Server upgrading',
|
||||||
|
},
|
||||||
|
serverUpgradingDescription: {
|
||||||
|
id: 'servers.manage.status.upgrading.description',
|
||||||
|
defaultMessage:
|
||||||
|
"Your server's hardware is currently being upgraded and will be back online shortly!",
|
||||||
|
},
|
||||||
|
serverSuspendedTitle: {
|
||||||
|
id: 'servers.manage.status.suspended.title',
|
||||||
|
defaultMessage: 'Server suspended',
|
||||||
|
},
|
||||||
|
suspendedCancelledDescription: {
|
||||||
|
id: 'servers.manage.status.suspended.cancelled-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Your subscription has been cancelled.\nContact Modrinth Support if you believe this is an error.',
|
||||||
|
},
|
||||||
|
suspendedReasonDescription: {
|
||||||
|
id: 'servers.manage.status.suspended.reason-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Your server has been suspended: {reason}\nContact Modrinth Support if you believe this is an error.',
|
||||||
|
},
|
||||||
|
suspendedDescription: {
|
||||||
|
id: 'servers.manage.status.suspended.description',
|
||||||
|
defaultMessage:
|
||||||
|
'Your server has been suspended.\nContact Modrinth Support if you believe this is an error.',
|
||||||
|
},
|
||||||
|
generalErrorTitle: {
|
||||||
|
id: 'servers.manage.error.general.title',
|
||||||
|
defaultMessage: 'An error occurred.',
|
||||||
|
},
|
||||||
|
genericErrorMessage: {
|
||||||
|
id: 'servers.manage.error.general.message',
|
||||||
|
defaultMessage: 'An unexpected error occurred.',
|
||||||
|
},
|
||||||
|
contactSupportDescription: {
|
||||||
|
id: 'servers.manage.error.contact-support',
|
||||||
|
defaultMessage: 'Please contact Modrinth Support.',
|
||||||
|
},
|
||||||
|
nodeUnavailableTitle: {
|
||||||
|
id: 'servers.manage.error.node-unavailable.title',
|
||||||
|
defaultMessage: 'Server Node Unavailable',
|
||||||
|
},
|
||||||
|
nodeUnavailableDescription: {
|
||||||
|
id: 'servers.manage.error.node-unavailable.description',
|
||||||
|
defaultMessage:
|
||||||
|
"Your server's node, where your Modrinth Server is physically hosted, is not accessible at the moment. We are working to resolve the issue as quickly as possible.",
|
||||||
|
},
|
||||||
|
nodeUnavailableDataDescription: {
|
||||||
|
id: 'servers.manage.error.node-unavailable.data-description',
|
||||||
|
defaultMessage:
|
||||||
|
'Your data is safe and will not be lost, and your server will be back online as soon as the issue is resolved.',
|
||||||
|
},
|
||||||
|
nodeUnavailableSupportDescription: {
|
||||||
|
id: 'servers.manage.error.node-unavailable.support-description',
|
||||||
|
defaultMessage:
|
||||||
|
"If reloading does not work initially, please contact Modrinth Support via the chat bubble in the bottom right corner and we'll be happy to help.",
|
||||||
|
},
|
||||||
|
installInvalidVersionDescription: {
|
||||||
|
id: 'servers.manage.error.install.invalid-version.description',
|
||||||
|
defaultMessage:
|
||||||
|
'An invalid loader or Minecraft version was specified and could not be installed.',
|
||||||
|
},
|
||||||
|
installRecentMinecraftVersionNotice: {
|
||||||
|
id: 'servers.manage.error.install.invalid-version.recent-minecraft',
|
||||||
|
defaultMessage:
|
||||||
|
'If this version of Minecraft was released recently, please check if Modrinth Hosting supports it.',
|
||||||
|
},
|
||||||
|
installModpackCompatibilityNotice: {
|
||||||
|
id: 'servers.manage.error.install.invalid-version.modpack-compatibility',
|
||||||
|
defaultMessage:
|
||||||
|
"If you've installed a modpack, it may have been packaged incorrectly or may not be compatible with the loader.",
|
||||||
|
},
|
||||||
|
installChangeLoaderNotice: {
|
||||||
|
id: 'servers.manage.error.install.invalid-version.change-loader',
|
||||||
|
defaultMessage:
|
||||||
|
'Your server may need to be reinstalled with a valid mod loader and version. You can change the loader by clicking the "Change Loader" button.',
|
||||||
|
},
|
||||||
|
installSupportNotice: {
|
||||||
|
id: 'servers.manage.error.install.invalid-version.support',
|
||||||
|
defaultMessage: "If you're stuck, please contact Modrinth Support with the information below:",
|
||||||
|
},
|
||||||
|
installInternalErrorDescription: {
|
||||||
|
id: 'servers.manage.error.install.internal.description',
|
||||||
|
defaultMessage:
|
||||||
|
"An internal error occurred while installing your server. Don't fret - try reinstalling your server, and if the problem persists, please contact Modrinth support with your server's debug information.",
|
||||||
|
},
|
||||||
|
installUnsupportedVersionDescription: {
|
||||||
|
id: 'servers.manage.error.install.unsupported-version.description',
|
||||||
|
defaultMessage:
|
||||||
|
"An error occurred while installing your server because Modrinth Hosting does not support the version of Minecraft or the loader you specified. Try reinstalling your server with a different version or loader, and if the problem persists, please contact Modrinth Support with your server's debug information.",
|
||||||
|
},
|
||||||
|
installationErrorTitle: {
|
||||||
|
id: 'servers.manage.error.install.title',
|
||||||
|
defaultMessage: 'Installation error',
|
||||||
|
},
|
||||||
|
unknownError: {
|
||||||
|
id: 'servers.manage.error.unknown',
|
||||||
|
defaultMessage: 'Unknown error',
|
||||||
|
},
|
||||||
|
copyDebugInfo: {
|
||||||
|
id: 'servers.manage.error.copy-debug-info',
|
||||||
|
defaultMessage: 'Copy Debug Info',
|
||||||
|
},
|
||||||
|
openInstallationLog: {
|
||||||
|
id: 'servers.manage.error.open-installation-log',
|
||||||
|
defaultMessage: 'Open Installation Log',
|
||||||
|
},
|
||||||
|
changeLoader: {
|
||||||
|
id: 'servers.manage.error.change-loader',
|
||||||
|
defaultMessage: 'Change Loader',
|
||||||
|
},
|
||||||
|
websocketError: {
|
||||||
|
id: 'servers.manage.websocket.error',
|
||||||
|
defaultMessage: 'Something went wrong...',
|
||||||
|
},
|
||||||
|
websocketReconnecting: {
|
||||||
|
id: 'servers.manage.websocket.reconnecting',
|
||||||
|
defaultMessage: "Hang on, we're reconnecting to your server.",
|
||||||
|
},
|
||||||
|
serverDataTitle: {
|
||||||
|
id: 'servers.manage.debug.server-data',
|
||||||
|
defaultMessage: 'Server data',
|
||||||
|
},
|
||||||
|
serverSettings: {
|
||||||
|
id: 'servers.manage.server-settings',
|
||||||
|
defaultMessage: 'Server settings',
|
||||||
|
},
|
||||||
|
overviewNav: {
|
||||||
|
id: 'servers.manage.nav.overview',
|
||||||
|
defaultMessage: 'Overview',
|
||||||
|
},
|
||||||
|
instancesNav: {
|
||||||
|
id: 'servers.manage.nav.instances',
|
||||||
|
defaultMessage: 'Instances',
|
||||||
|
},
|
||||||
|
errorDismissingNotice: {
|
||||||
|
id: 'servers.manage.notice.dismiss-error',
|
||||||
|
defaultMessage: 'Error dismissing notice',
|
||||||
|
},
|
||||||
|
failedToRetryInstallation: {
|
||||||
|
id: 'servers.manage.install.retry-error',
|
||||||
|
defaultMessage: 'Failed to retry installation',
|
||||||
|
},
|
||||||
|
serverIdLabel: {
|
||||||
|
id: 'servers.manage.error-details.server-id',
|
||||||
|
defaultMessage: 'Server ID',
|
||||||
|
},
|
||||||
|
nodeLabel: {
|
||||||
|
id: 'servers.manage.error-details.node',
|
||||||
|
defaultMessage: 'Node',
|
||||||
|
},
|
||||||
|
errorMessageLabel: {
|
||||||
|
id: 'servers.manage.error-details.error-message',
|
||||||
|
defaultMessage: 'Error message',
|
||||||
|
},
|
||||||
|
timestampLabel: {
|
||||||
|
id: 'servers.manage.error-details.timestamp',
|
||||||
|
defaultMessage: 'Timestamp',
|
||||||
|
},
|
||||||
|
errorNameLabel: {
|
||||||
|
id: 'servers.manage.error-details.error-name',
|
||||||
|
defaultMessage: 'Error Name',
|
||||||
|
},
|
||||||
|
originalErrorLabel: {
|
||||||
|
id: 'servers.manage.error-details.original-error',
|
||||||
|
defaultMessage: 'Original Error',
|
||||||
|
},
|
||||||
|
stackTraceLabel: {
|
||||||
|
id: 'servers.manage.error-details.stack-trace',
|
||||||
|
defaultMessage: 'Stack Trace',
|
||||||
|
},
|
||||||
|
unknownLabel: {
|
||||||
|
id: 'servers.manage.error-details.unknown',
|
||||||
|
defaultMessage: 'Unknown',
|
||||||
|
},
|
||||||
|
nodePingFailed: {
|
||||||
|
id: 'servers.manage.error.node-ping-failed',
|
||||||
|
defaultMessage: 'Unable to reach node. Ping test failed.',
|
||||||
|
},
|
||||||
|
goToBillingSettings: {
|
||||||
|
id: 'servers.manage.action.go-to-billing-settings',
|
||||||
|
defaultMessage: 'Go to billing settings',
|
||||||
|
},
|
||||||
|
goBackToAllServers: {
|
||||||
|
id: 'servers.manage.action.go-back-to-all-servers',
|
||||||
|
defaultMessage: 'Go back to all servers',
|
||||||
|
},
|
||||||
|
reload: {
|
||||||
|
id: 'servers.manage.action.reload',
|
||||||
|
defaultMessage: 'Reload',
|
||||||
|
},
|
||||||
|
debugInfo: {
|
||||||
|
id: 'servers.manage.error.debug-info',
|
||||||
|
defaultMessage:
|
||||||
|
'Server ID: {serverId}\nError: {error}\nKind: {kind}\nProject ID: {projectId}\nVersion ID: {versionId}\nLog: {log}',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
// disabled, keeping the animation logic cos it's really nice and we might want to re-enable in future
|
// disabled, keeping the animation logic cos it's really nice and we might want to re-enable in future
|
||||||
const DISABLE_LOADING_ANIM = true
|
const DISABLE_LOADING_ANIM = true
|
||||||
|
|
||||||
@@ -503,8 +679,14 @@ const isLoading = ref(true)
|
|||||||
const isMounted = ref(true)
|
const isMounted = ref(true)
|
||||||
const copied = ref(false)
|
const copied = ref(false)
|
||||||
const installError = ref<Error | null>(null)
|
const installError = ref<Error | null>(null)
|
||||||
const errorTitle = ref('Error')
|
type InstallErrorTitle = 'generic' | 'installation'
|
||||||
const errorMessage = ref('An unexpected error occurred.')
|
const errorTitle = ref<InstallErrorTitle>('generic')
|
||||||
|
const errorTitleLabel = computed(() =>
|
||||||
|
errorTitle.value === 'installation'
|
||||||
|
? formatMessage(messages.installationErrorTitle)
|
||||||
|
: formatMessage(messages.generalErrorTitle),
|
||||||
|
)
|
||||||
|
const errorMessage = ref(formatMessage(messages.genericErrorMessage))
|
||||||
const errorLog = ref('')
|
const errorLog = ref('')
|
||||||
const errorLogFile = ref('')
|
const errorLogFile = ref('')
|
||||||
const isOnboarding = computed(() => serverData.value?.flows?.intro)
|
const isOnboarding = computed(() => serverData.value?.flows?.intro)
|
||||||
@@ -517,6 +699,14 @@ function dismissSettingsHint() {
|
|||||||
settingsHintDismissed.value = true
|
settingsHintDismissed.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INSTANCES_HINT_KEY = 'server-panel-instances-hint-dismissed'
|
||||||
|
const instancesHintDismissed = useStorage(INSTANCES_HINT_KEY, false)
|
||||||
|
const showInstancesHint = ref(!instancesHintDismissed.value)
|
||||||
|
function dismissInstancesHint() {
|
||||||
|
showInstancesHint.value = false
|
||||||
|
instancesHintDismissed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const serverSettingsModal = ref<InstanceType<typeof ServerSettingsModal> | null>(null)
|
const serverSettingsModal = ref<InstanceType<typeof ServerSettingsModal> | null>(null)
|
||||||
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
||||||
|
|
||||||
@@ -550,12 +740,47 @@ const { data: serverFull } = useQuery({
|
|||||||
queryFn: () => client.archon.servers_v1.get(props.serverId),
|
queryFn: () => client.archon.servers_v1.get(props.serverId),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const routeInstanceId = ref<string | null>(getRouteParam(route.params.instance_id))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [route.path, route.params.id, route.params.instance_id, props.serverId] as const,
|
||||||
|
() => {
|
||||||
|
if (!isRouteForManagedServer()) return
|
||||||
|
routeInstanceId.value = getRouteParam(route.params.instance_id)
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
const worldId = computed(() => {
|
const worldId = computed(() => {
|
||||||
|
if (routeInstanceId.value) return routeInstanceId.value
|
||||||
if (!serverFull.value) return null
|
if (!serverFull.value) return null
|
||||||
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
|
const activeWorld = serverFull.value.worlds.find((w) => w.is_active)
|
||||||
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
|
return activeWorld?.id ?? serverFull.value.worlds[0]?.id ?? null
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const isInstanceDetailRoute = computed(() => !!routeInstanceId.value)
|
||||||
|
const activeWorldName = computed(() => {
|
||||||
|
if (!serverFull.value) return null
|
||||||
|
const activeWorld = serverFull.value.worlds.find((world) => world.is_active)
|
||||||
|
return activeWorld?.name ?? serverFull.value.worlds[0]?.name ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
function isRouteForManagedServer() {
|
||||||
|
const routeServerId = getRouteParam(route.params.id)
|
||||||
|
if (!routeServerId || routeServerId !== props.serverId) return false
|
||||||
|
const basePath = `/hosting/manage/${encodeURIComponent(routeServerId)}`
|
||||||
|
return route.path === basePath || route.path.startsWith(`${basePath}/`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRouteParam(param: string | string[] | undefined): string | null {
|
||||||
|
if (Array.isArray(param)) return param[0] ?? null
|
||||||
|
return param ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorldPath(targetWorldId: string) {
|
||||||
|
return `/hosting/manage/${encodeURIComponent(props.serverId)}/instances/${encodeURIComponent(targetWorldId)}`
|
||||||
|
}
|
||||||
|
|
||||||
const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQueue(
|
const { handleWsBackupProgress, busyReasons: backupsBusy } = useServerBackupsQueue(
|
||||||
computed(() => props.serverId),
|
computed(() => props.serverId),
|
||||||
worldId,
|
worldId,
|
||||||
@@ -799,28 +1024,24 @@ watch(serverData, (data) => {
|
|||||||
|
|
||||||
const navLinks = computed<Tab[]>(() => [
|
const navLinks = computed<Tab[]>(() => [
|
||||||
{
|
{
|
||||||
label: 'Overview',
|
label: formatMessage(messages.overviewNav),
|
||||||
href: `/hosting/manage/${props.serverId}`,
|
href: `/hosting/manage/${encodeURIComponent(props.serverId)}`,
|
||||||
icon: LayoutTemplateIcon,
|
icon: LayoutTemplateIcon,
|
||||||
subpages: [],
|
subpages: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Content',
|
label: formatMessage(messages.instancesNav),
|
||||||
href: `/hosting/manage/${props.serverId}/content`,
|
href: `/hosting/manage/${encodeURIComponent(props.serverId)}/instances`,
|
||||||
icon: BoxesIcon,
|
icon: GlobeIcon,
|
||||||
subpages: ['mods', 'datapacks'],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Files',
|
|
||||||
href: `/hosting/manage/${props.serverId}/files`,
|
|
||||||
icon: FolderOpenIcon,
|
|
||||||
subpages: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Backups',
|
|
||||||
href: `/hosting/manage/${props.serverId}/backups`,
|
|
||||||
icon: DatabaseBackupIcon,
|
|
||||||
subpages: [],
|
subpages: [],
|
||||||
|
prompt: {
|
||||||
|
title: formatMessage(instancesHintMessages.title),
|
||||||
|
description: formatMessage(instancesHintMessages.description),
|
||||||
|
dismissLabel: formatMessage(instancesHintMessages.dismiss),
|
||||||
|
shown: showInstancesHint.value,
|
||||||
|
placement: 'bottom',
|
||||||
|
onDismiss: dismissInstancesHint,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
...props.additionalTabs,
|
...props.additionalTabs,
|
||||||
])
|
])
|
||||||
@@ -833,7 +1054,7 @@ const surveyNotice = computed(() => serverData.value?.notices?.find((n) => n.lev
|
|||||||
async function dismissNotice(noticeId: number) {
|
async function dismissNotice(noticeId: number) {
|
||||||
await client.archon.servers_v0.dismissNotice(props.serverId, noticeId).catch((err) => {
|
await client.archon.servers_v0.dismissNotice(props.serverId, noticeId).catch((err) => {
|
||||||
addNotification({
|
addNotification({
|
||||||
title: 'Error dismissing notice',
|
title: formatMessage(messages.errorDismissingNotice),
|
||||||
text: err,
|
text: err,
|
||||||
type: 'error',
|
type: 'error',
|
||||||
})
|
})
|
||||||
@@ -937,7 +1158,7 @@ async function handleContentRetry() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
addNotification({
|
addNotification({
|
||||||
type: 'error',
|
type: 'error',
|
||||||
text: err instanceof Error ? err.message : 'Failed to retry installation',
|
text: err instanceof Error ? err.message : formatMessage(messages.failedToRetryInstallation),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -968,7 +1189,7 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleNewMod = () => {
|
const handleNewMod = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
|
const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallationResultEvent) => {
|
||||||
@@ -987,9 +1208,9 @@ const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallation
|
|||||||
case 'err': {
|
case 'err': {
|
||||||
console.log('failed to install')
|
console.log('failed to install')
|
||||||
console.log(data)
|
console.log(data)
|
||||||
errorTitle.value = 'Installation error'
|
errorTitle.value = 'installation'
|
||||||
errorMessage.value = data.reason ?? 'Unknown error'
|
errorMessage.value = data.reason ?? formatMessage(messages.unknownError)
|
||||||
installError.value = new Error(data.reason ?? 'Unknown error')
|
installError.value = new Error(errorMessage.value)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let files = await client.kyros.files_v0.listDirectory('/', 1, 100)
|
let files = await client.kyros.files_v0.listDirectory('/', 1, 100)
|
||||||
@@ -1047,14 +1268,14 @@ const onReinstall = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
installError.value = null
|
installError.value = null
|
||||||
errorTitle.value = 'Error'
|
errorTitle.value = 'generic'
|
||||||
errorMessage.value = 'An unexpected error occurred.'
|
errorMessage.value = formatMessage(messages.genericErrorMessage)
|
||||||
|
|
||||||
modrinthServersConsole.clear()
|
modrinthServersConsole.clear()
|
||||||
|
|
||||||
debug('[root.vue] onReinstall: triggering immediate invalidation')
|
debug('[root.vue] onReinstall: triggering immediate invalidation')
|
||||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] })
|
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', props.serverId] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] })
|
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] })
|
||||||
}
|
}
|
||||||
|
|
||||||
const onReinstallFailed = () => {
|
const onReinstallFailed = () => {
|
||||||
@@ -1103,7 +1324,7 @@ async function invalidateAfterInstall() {
|
|||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ['servers', 'startup', 'v1', props.serverId],
|
queryKey: ['servers', 'startup', 'v1', props.serverId],
|
||||||
}),
|
}),
|
||||||
queryClient.invalidateQueries({ queryKey: ['content', 'list'] }),
|
queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', props.serverId] }),
|
||||||
])
|
])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
console.error('Error refreshing data after installation:', err)
|
console.error('Error refreshing data after installation:', err)
|
||||||
@@ -1117,62 +1338,64 @@ const nodeAccessible = ref(true)
|
|||||||
|
|
||||||
const nodeUnavailableDetails = computed(() => [
|
const nodeUnavailableDetails = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Server ID',
|
label: formatMessage(messages.serverIdLabel),
|
||||||
value: props.serverId,
|
value: props.serverId,
|
||||||
type: 'inline' as const,
|
type: 'inline' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Node',
|
label: formatMessage(messages.nodeLabel),
|
||||||
value:
|
value:
|
||||||
(serverError.value?.responseData as { hostname?: string } | undefined)?.hostname ??
|
(serverError.value?.responseData as { hostname?: string } | undefined)?.hostname ??
|
||||||
serverData.value?.datacenter ??
|
serverData.value?.datacenter ??
|
||||||
'Unknown',
|
formatMessage(messages.unknownLabel),
|
||||||
type: 'inline' as const,
|
type: 'inline' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Error message',
|
label: formatMessage(messages.errorMessageLabel),
|
||||||
value: nodeAccessible.value
|
value: nodeAccessible.value
|
||||||
? (serverError.value?.message ?? 'Unknown')
|
? (serverError.value?.message ?? formatMessage(messages.unknownLabel))
|
||||||
: 'Unable to reach node. Ping test failed.',
|
: formatMessage(messages.nodePingFailed),
|
||||||
type: 'block' as const,
|
type: 'block' as const,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
const suspendedDescription = computed(() => {
|
const suspendedDescription = computed(() => {
|
||||||
if (serverData.value?.suspension_reason === 'cancelled') {
|
if (serverData.value?.suspension_reason === 'cancelled') {
|
||||||
return 'Your subscription has been cancelled.\nContact Modrinth Support if you believe this is an error.'
|
return formatMessage(messages.suspendedCancelledDescription)
|
||||||
}
|
}
|
||||||
if (serverData.value?.suspension_reason) {
|
if (serverData.value?.suspension_reason) {
|
||||||
return `Your server has been suspended: ${serverData.value.suspension_reason}\nContact Modrinth Support if you believe this is an error.`
|
return formatMessage(messages.suspendedReasonDescription, {
|
||||||
|
reason: serverData.value.suspension_reason,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return 'Your server has been suspended.\nContact Modrinth Support if you believe this is an error.'
|
return formatMessage(messages.suspendedDescription)
|
||||||
})
|
})
|
||||||
|
|
||||||
const generalErrorDetails = computed(() => [
|
const generalErrorDetails = computed(() => [
|
||||||
{
|
{
|
||||||
label: 'Server ID',
|
label: formatMessage(messages.serverIdLabel),
|
||||||
value: props.serverId,
|
value: props.serverId,
|
||||||
type: 'inline' as const,
|
type: 'inline' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Timestamp',
|
label: formatMessage(messages.timestampLabel),
|
||||||
value: String(new Date().toISOString()),
|
value: String(new Date().toISOString()),
|
||||||
type: 'inline' as const,
|
type: 'inline' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Error Name',
|
label: formatMessage(messages.errorNameLabel),
|
||||||
value: serverError.value?.name,
|
value: serverError.value?.name,
|
||||||
type: 'inline' as const,
|
type: 'inline' as const,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Error Message',
|
label: formatMessage(messages.errorMessageLabel),
|
||||||
value: serverError.value?.message,
|
value: serverError.value?.message,
|
||||||
type: 'block' as const,
|
type: 'block' as const,
|
||||||
},
|
},
|
||||||
...(serverError.value?.originalError
|
...(serverError.value?.originalError
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: 'Original Error',
|
label: formatMessage(messages.originalErrorLabel),
|
||||||
value: String(serverError.value.originalError),
|
value: String(serverError.value.originalError),
|
||||||
type: 'hidden' as const,
|
type: 'hidden' as const,
|
||||||
},
|
},
|
||||||
@@ -1181,7 +1404,7 @@ const generalErrorDetails = computed(() => [
|
|||||||
...(serverError.value?.stack
|
...(serverError.value?.stack
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: 'Stack Trace',
|
label: formatMessage(messages.stackTraceLabel),
|
||||||
value: serverError.value.stack,
|
value: serverError.value.stack,
|
||||||
type: 'hidden' as const,
|
type: 'hidden' as const,
|
||||||
},
|
},
|
||||||
@@ -1190,26 +1413,33 @@ const generalErrorDetails = computed(() => [
|
|||||||
])
|
])
|
||||||
|
|
||||||
const suspendedAction = computed(() => ({
|
const suspendedAction = computed(() => ({
|
||||||
label: 'Go to billing settings',
|
label: formatMessage(messages.goToBillingSettings),
|
||||||
onClick: () => props.navigateToBilling?.(),
|
onClick: () => props.navigateToBilling?.(),
|
||||||
color: 'brand' as const,
|
color: 'brand' as const,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const generalErrorAction = computed(() => ({
|
const generalErrorAction = computed(() => ({
|
||||||
label: 'Go back to all servers',
|
label: formatMessage(messages.goBackToAllServers),
|
||||||
onClick: () => props.navigateToServers?.(),
|
onClick: () => props.navigateToServers?.(),
|
||||||
color: 'brand' as const,
|
color: 'brand' as const,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const nodeUnavailableAction = computed(() => ({
|
const nodeUnavailableAction = computed(() => ({
|
||||||
label: 'Reload',
|
label: formatMessage(messages.reload),
|
||||||
onClick: () => props.reloadPage(),
|
onClick: () => props.reloadPage(),
|
||||||
color: 'brand' as const,
|
color: 'brand' as const,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const copyServerDebugInfo = () => {
|
const copyServerDebugInfo = () => {
|
||||||
const debugInfo = `Server ID: ${serverData.value?.server_id}\nError: ${errorMessage.value}\nKind: ${serverData.value?.upstream?.kind}\nProject ID: ${serverData.value?.upstream?.project_id}\nVersion ID: ${serverData.value?.upstream?.version_id}\nLog: ${errorLog.value}`
|
const debugInfo = formatMessage(messages.debugInfo, {
|
||||||
|
serverId: serverData.value?.server_id ?? '',
|
||||||
|
error: errorMessage.value,
|
||||||
|
kind: serverData.value?.upstream?.kind ?? '',
|
||||||
|
projectId: serverData.value?.upstream?.project_id ?? '',
|
||||||
|
versionId: serverData.value?.upstream?.version_id ?? '',
|
||||||
|
log: errorLog.value,
|
||||||
|
})
|
||||||
navigator.clipboard.writeText(debugInfo)
|
navigator.clipboard.writeText(debugInfo)
|
||||||
copied.value = true
|
copied.value = true
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1218,7 +1448,10 @@ const copyServerDebugInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openInstallLog = () => {
|
const openInstallLog = () => {
|
||||||
const url = `/hosting/manage/${props.serverId}/files?editing=${encodeURIComponent(errorLogFile.value)}`
|
const filesPath = worldId.value
|
||||||
|
? `${getWorldPath(worldId.value)}/files`
|
||||||
|
: `/hosting/manage/${encodeURIComponent(props.serverId)}/instances`
|
||||||
|
const url = `${filesPath}?editing=${encodeURIComponent(errorLogFile.value)}`
|
||||||
window.history.pushState({}, '', url)
|
window.history.pushState({}, '', url)
|
||||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||||
}
|
}
|
||||||
+4
-8
@@ -245,7 +245,6 @@ import { useMutation, useQueryClient } from '@tanstack/vue-query'
|
|||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||||
@@ -258,9 +257,9 @@ import BackupDeleteModal from '#ui/components/servers/backups/BackupDeleteModal.
|
|||||||
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
|
import BackupItem from '#ui/components/servers/backups/BackupItem.vue'
|
||||||
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
|
import BackupRenameModal from '#ui/components/servers/backups/BackupRenameModal.vue'
|
||||||
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
|
import BackupRestoreModal from '#ui/components/servers/backups/BackupRestoreModal.vue'
|
||||||
import { useBackupsSelection } from '#ui/composables/hosting/backups-selection'
|
import { useBackupsSelection } from '#ui/composables/servers/backups-selection'
|
||||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
|
||||||
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
|
import { useBulkOperation } from '#ui/layouts/shared/content-tab/composables/bulk-operations'
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
@@ -334,7 +333,7 @@ const filterPillOptions = computed<FilterPillOption[]>(() => [
|
|||||||
])
|
])
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const { server, worldId, busyReasons } = injectModrinthServerContext()
|
const { server, serverId, worldId, busyReasons } = injectModrinthServerContext()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
isServerRunning: boolean
|
isServerRunning: boolean
|
||||||
@@ -342,9 +341,6 @@ const props = defineProps<{
|
|||||||
showDebugInfo?: boolean
|
showDebugInfo?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const serverId = route.params.id as string
|
|
||||||
|
|
||||||
defineEmits(['onDownload'])
|
defineEmits(['onDownload'])
|
||||||
|
|
||||||
const { backups, invalidate, hasActiveCreate, hasActiveRestore, query } = useServerBackupsQueue(
|
const { backups, invalidate, hasActiveCreate, hasActiveRestore, query } = useServerBackupsQueue(
|
||||||
@@ -358,7 +354,7 @@ const error = computed(() => {
|
|||||||
})
|
})
|
||||||
const refetch = () => query.refetch()
|
const refetch = () => query.refetch()
|
||||||
|
|
||||||
/** Until world exists we cannot fetch; `isLoading` is false while the query is disabled, which would flash empty state. */
|
/** Until the instance exists we cannot fetch; `isLoading` is false while the query is disabled, which would flash empty state. */
|
||||||
const backupsReadyPending = computed(
|
const backupsReadyPending = computed(
|
||||||
() => !worldId.value || (query.data.value === undefined && !query.error.value),
|
() => !worldId.value || (query.data.value === undefined && !query.error.value),
|
||||||
)
|
)
|
||||||
+20
-23
@@ -4,11 +4,28 @@ import { ClipboardCopyIcon } from '@modrinth/assets'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||||
import { useIntervalFn } from '@vueuse/core'
|
import { useIntervalFn } from '@vueuse/core'
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
||||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
|
import {
|
||||||
|
flushStoredServerAddonInstallQueue,
|
||||||
|
getStoredServerAddonInstallQueue,
|
||||||
|
} from '#ui/layouts/shared/browse-tab/composables/install-logic'
|
||||||
|
import ConfirmModpackUpdateModal from '#ui/layouts/shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||||
|
import ConfirmUnlinkModal from '#ui/layouts/shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
|
||||||
|
import ContentUpdaterModal from '#ui/layouts/shared/content-tab/components/modals/ContentUpdaterModal.vue'
|
||||||
|
import ModpackContentModal from '#ui/layouts/shared/content-tab/components/modals/ModpackContentModal.vue'
|
||||||
|
import ContentPageLayout from '#ui/layouts/shared/content-tab/layout.vue'
|
||||||
|
import type { ContentModpackData } from '#ui/layouts/shared/content-tab/providers/content-manager'
|
||||||
|
import { provideContentManager } from '#ui/layouts/shared/content-tab/providers/content-manager'
|
||||||
|
import type {
|
||||||
|
ContentItem,
|
||||||
|
ContentModpackCardCategory,
|
||||||
|
ContentModpackCardProject,
|
||||||
|
ContentModpackCardVersion,
|
||||||
|
} from '#ui/layouts/shared/content-tab/types'
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectModrinthServerContext,
|
injectModrinthServerContext,
|
||||||
@@ -25,24 +42,6 @@ import {
|
|||||||
} from '#ui/utils/server-content-installing'
|
} from '#ui/utils/server-content-installing'
|
||||||
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
|
import { versionChangesGameVersion } from '#ui/utils/version-compatibility'
|
||||||
|
|
||||||
import {
|
|
||||||
flushStoredServerAddonInstallQueue,
|
|
||||||
getStoredServerAddonInstallQueue,
|
|
||||||
} from '../../../shared/browse-tab/composables/install-logic'
|
|
||||||
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
|
||||||
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
|
|
||||||
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
|
|
||||||
import ModpackContentModal from '../../../shared/content-tab/components/modals/ModpackContentModal.vue'
|
|
||||||
import ContentPageLayout from '../../../shared/content-tab/layout.vue'
|
|
||||||
import type { ContentModpackData } from '../../../shared/content-tab/providers/content-manager'
|
|
||||||
import { provideContentManager } from '../../../shared/content-tab/providers/content-manager'
|
|
||||||
import type {
|
|
||||||
ContentItem,
|
|
||||||
ContentModpackCardCategory,
|
|
||||||
ContentModpackCardProject,
|
|
||||||
ContentModpackCardVersion,
|
|
||||||
} from '../../../shared/content-tab/types'
|
|
||||||
|
|
||||||
type AddonWithUiState = Archon.Content.v1.Addon & { installing?: boolean }
|
type AddonWithUiState = Archon.Content.v1.Addon & { installing?: boolean }
|
||||||
type ContentOwnerAvatarSource = {
|
type ContentOwnerAvatarSource = {
|
||||||
id: string
|
id: string
|
||||||
@@ -112,7 +111,7 @@ const messages = defineMessages({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const client = injectModrinthClient()
|
const client = injectModrinthClient()
|
||||||
const { server, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
|
const { server, serverId, worldId, busyReasons, isSyncingContent, uploadState, cancelUpload } =
|
||||||
injectModrinthServerContext()
|
injectModrinthServerContext()
|
||||||
const contentUploadSession = useUploadSessionUpload({
|
const contentUploadSession = useUploadSessionUpload({
|
||||||
client,
|
client,
|
||||||
@@ -123,10 +122,8 @@ const contentUploadSession = useUploadSessionUpload({
|
|||||||
})
|
})
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
const { openServerSettings, browseServerContent } = injectServerSettingsModal()
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const serverId = route.params.id as string
|
|
||||||
|
|
||||||
const type = computed(() => {
|
const type = computed(() => {
|
||||||
const loader = server.value?.loader?.toLowerCase()
|
const loader = server.value?.loader?.toLowerCase()
|
||||||
@@ -135,7 +132,7 @@ const type = computed(() => {
|
|||||||
return 'mod'
|
return 'mod'
|
||||||
})
|
})
|
||||||
|
|
||||||
const queryKey = computed(() => ['content', 'list', 'v1', serverId])
|
const queryKey = computed(() => ['content', 'list', 'v1', serverId, worldId.value])
|
||||||
|
|
||||||
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
|
function getContentOwnerAvatarUrl(owner: ContentOwnerAvatarSource) {
|
||||||
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
|
const ownerId = owner.type === 'user' ? owner.name || owner.id : owner.id
|
||||||
+2
-4
@@ -8,6 +8,8 @@ import ReadyTransition from '#ui/components/base/ReadyTransition.vue'
|
|||||||
import { useReadyState } from '#ui/composables'
|
import { useReadyState } from '#ui/composables'
|
||||||
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
import { useUploadSessionUpload } from '#ui/composables/hosting/kyros-session-upload'
|
||||||
import { useVIntl } from '#ui/composables/i18n'
|
import { useVIntl } from '#ui/composables/i18n'
|
||||||
|
import type { EditingFile, FileItem } from '#ui/layouts/shared/files-tab/index.ts'
|
||||||
|
import { FilePageLayout, provideFileManager } from '#ui/layouts/shared/files-tab/index.ts'
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
injectModrinthServerContext,
|
injectModrinthServerContext,
|
||||||
@@ -15,10 +17,6 @@ import {
|
|||||||
} from '#ui/providers'
|
} from '#ui/providers'
|
||||||
import { commonMessages } from '#ui/utils/common-messages'
|
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 } from '../../../shared/files-tab/types'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
showDebugInfo?: boolean
|
showDebugInfo?: boolean
|
||||||
showRefreshButton?: boolean
|
showRefreshButton?: boolean
|
||||||
+302
@@ -0,0 +1,302 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex min-h-[36rem] flex-col gap-4 text-primary">
|
||||||
|
<WorldManageHeader
|
||||||
|
:name="worldName"
|
||||||
|
:game-version="gameVersion"
|
||||||
|
:loader="loader"
|
||||||
|
:loader-version="loaderVersion"
|
||||||
|
:last-active="lastActiveLabel"
|
||||||
|
:back-href="instancesPath"
|
||||||
|
:back-label="formatMessage(messages.allInstances)"
|
||||||
|
:fallback-name="formatMessage(messages.worldFallbackName)"
|
||||||
|
:actions="headerActions"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NavTabs :links="worldTabLinks" replace />
|
||||||
|
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Archon } from '@modrinth/api-client'
|
||||||
|
import {
|
||||||
|
BoxesIcon,
|
||||||
|
DatabaseBackupIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LoaderCircleIcon,
|
||||||
|
PlayIcon,
|
||||||
|
Settings2Icon,
|
||||||
|
SlashIcon,
|
||||||
|
StopCircleIcon,
|
||||||
|
UpdatedIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import type { JoinedButtonAction } from '#ui/components/base/JoinedButtons.vue'
|
||||||
|
import NavTabs from '#ui/components/base/NavTabs.vue'
|
||||||
|
import { WorldManageHeader } from '#ui/components/servers/server-header'
|
||||||
|
import { useServerPowerAction } from '#ui/components/servers/server-header/use-server-power-action'
|
||||||
|
import { useRelativeTime } from '#ui/composables'
|
||||||
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
|
import {
|
||||||
|
injectModrinthClient,
|
||||||
|
injectModrinthServerContext,
|
||||||
|
injectServerSettingsModal,
|
||||||
|
} from '#ui/providers'
|
||||||
|
|
||||||
|
interface Tab {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
icon?: object
|
||||||
|
subpages?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
allInstances: {
|
||||||
|
id: 'servers.manage.instance.all-instances',
|
||||||
|
defaultMessage: 'All instances',
|
||||||
|
},
|
||||||
|
contentNav: {
|
||||||
|
id: 'servers.manage.nav.content',
|
||||||
|
defaultMessage: 'Content',
|
||||||
|
},
|
||||||
|
filesNav: {
|
||||||
|
id: 'servers.manage.nav.files',
|
||||||
|
defaultMessage: 'Files',
|
||||||
|
},
|
||||||
|
backupsNav: {
|
||||||
|
id: 'servers.manage.nav.backups',
|
||||||
|
defaultMessage: 'Backups',
|
||||||
|
},
|
||||||
|
worldFallbackName: {
|
||||||
|
id: 'servers.manage.instance.fallback-name',
|
||||||
|
defaultMessage: 'Instance',
|
||||||
|
},
|
||||||
|
lastActive: {
|
||||||
|
id: 'servers.manage.instance.last-active',
|
||||||
|
defaultMessage: 'Last active {time}',
|
||||||
|
},
|
||||||
|
instanceSettings: {
|
||||||
|
id: 'servers.manage.instance.settings',
|
||||||
|
defaultMessage: 'Instance settings',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const { serverId, server, worldId, isServerRunning } = injectModrinthServerContext()
|
||||||
|
const { openServerSettings } = injectServerSettingsModal()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const formatRelativeTime = useRelativeTime()
|
||||||
|
const router = useRouter()
|
||||||
|
const {
|
||||||
|
isInstalling,
|
||||||
|
isStopping,
|
||||||
|
showRestartButton,
|
||||||
|
busyTooltip,
|
||||||
|
canTakeAction,
|
||||||
|
canKill,
|
||||||
|
primaryActionText,
|
||||||
|
initiateAction,
|
||||||
|
handlePrimaryAction,
|
||||||
|
} = useServerPowerAction()
|
||||||
|
|
||||||
|
const { data: serverFull } = useQuery({
|
||||||
|
queryKey: computed(() => ['servers', 'v1', 'detail', serverId]),
|
||||||
|
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const instancesPath = computed(() => `/hosting/manage/${encodeURIComponent(serverId)}/instances`)
|
||||||
|
const worldPath = computed(() =>
|
||||||
|
worldId.value
|
||||||
|
? `${instancesPath.value}/${encodeURIComponent(worldId.value)}`
|
||||||
|
: instancesPath.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentWorld = computed(() => {
|
||||||
|
const id = worldId.value
|
||||||
|
if (!id) return null
|
||||||
|
return serverFull.value?.worlds.find((world) => world.id === id) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const worldName = computed(
|
||||||
|
() => currentWorld.value?.name ?? formatMessage(messages.worldFallbackName),
|
||||||
|
)
|
||||||
|
|
||||||
|
const gameVersion = computed(() => {
|
||||||
|
const version = currentWorld.value?.content?.game_version ?? server.value?.mc_version
|
||||||
|
return version ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const loader = computed(
|
||||||
|
() => currentWorld.value?.content?.modloader ?? server.value?.loader ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const loaderVersion = computed(
|
||||||
|
() => currentWorld.value?.content?.modloader_version ?? server.value?.loader_version ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const lastActiveLabel = computed(() => {
|
||||||
|
const latestBackup = currentWorld.value ? latestDate(currentWorld.value.backups) : null
|
||||||
|
const lastActiveAt =
|
||||||
|
latestBackup ??
|
||||||
|
(currentWorld.value?.is_active && isServerRunning.value
|
||||||
|
? new Date().toISOString()
|
||||||
|
: currentWorld.value?.created_at)
|
||||||
|
|
||||||
|
return lastActiveAt
|
||||||
|
? formatMessage(messages.lastActive, { time: formatRelativeTime(lastActiveAt) })
|
||||||
|
: null
|
||||||
|
})
|
||||||
|
const startActionText = computed(() =>
|
||||||
|
primaryActionText.value === 'Start' ? 'Start instance' : primaryActionText.value,
|
||||||
|
)
|
||||||
|
const stopSplitActions = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'stop',
|
||||||
|
label: isStopping.value ? 'Stopping' : 'Stop',
|
||||||
|
icon: StopCircleIcon,
|
||||||
|
action: () => initiateAction('Stop'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kill_server',
|
||||||
|
label: 'Kill server',
|
||||||
|
icon: SlashIcon,
|
||||||
|
action: () => initiateAction('Kill'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
const restartableWorlds = computed(() =>
|
||||||
|
(serverFull.value?.worlds ?? []).filter((world) => world.id !== worldId.value),
|
||||||
|
)
|
||||||
|
const restartSplitActions = computed<JoinedButtonAction[]>(() => [
|
||||||
|
{
|
||||||
|
id: 'restart',
|
||||||
|
label: primaryActionText.value,
|
||||||
|
icon: UpdatedIcon,
|
||||||
|
action: () => initiateAction('Restart'),
|
||||||
|
},
|
||||||
|
// TODO: Implement world scoping when Archon/Kyros support target worlds in power requests.
|
||||||
|
...restartableWorlds.value.map((world) => ({
|
||||||
|
id: `restart-${world.id}`,
|
||||||
|
label: `Restart with ${world.name}`,
|
||||||
|
icon: GlobeIcon,
|
||||||
|
action: () => initiateAction('Restart'),
|
||||||
|
})),
|
||||||
|
])
|
||||||
|
const powerActions = computed(() => {
|
||||||
|
if (isInstalling.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'installing',
|
||||||
|
label: 'Installing...',
|
||||||
|
icon: LoaderCircleIcon,
|
||||||
|
iconClass: 'animate-spin',
|
||||||
|
color: 'brand' as const,
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (showRestartButton.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'restart',
|
||||||
|
label: primaryActionText.value,
|
||||||
|
color: 'orange' as const,
|
||||||
|
joinedActions: restartSplitActions.value,
|
||||||
|
primaryDisabled: !canTakeAction.value,
|
||||||
|
dropdownDisabled: !canTakeAction.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stop',
|
||||||
|
label: 'Stop instance',
|
||||||
|
color: 'red' as const,
|
||||||
|
joinedActions: stopSplitActions.value,
|
||||||
|
primaryDisabled: !canTakeAction.value,
|
||||||
|
dropdownDisabled: !canKill.value,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
if (isStopping.value) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'stop',
|
||||||
|
label: 'Stop instance',
|
||||||
|
color: 'red' as const,
|
||||||
|
joinedActions: stopSplitActions.value,
|
||||||
|
primaryDisabled: true,
|
||||||
|
dropdownDisabled: !canKill.value,
|
||||||
|
primaryMuted: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'start',
|
||||||
|
label: startActionText.value,
|
||||||
|
icon: PlayIcon,
|
||||||
|
color: 'brand' as const,
|
||||||
|
tooltip: busyTooltip.value,
|
||||||
|
disabled: !canTakeAction.value,
|
||||||
|
onClick: handlePrimaryAction,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
const headerActions = computed(() => [
|
||||||
|
...powerActions.value,
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: formatMessage(messages.instanceSettings),
|
||||||
|
icon: Settings2Icon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: formatMessage(messages.instanceSettings),
|
||||||
|
onClick: () => openServerSettings({ tabId: 'installation' }),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const worldTabLinks = computed<Tab[]>(() => [
|
||||||
|
{
|
||||||
|
label: formatMessage(messages.contentNav),
|
||||||
|
href: worldPath.value,
|
||||||
|
icon: BoxesIcon,
|
||||||
|
subpages: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(messages.filesNav),
|
||||||
|
href: `${worldPath.value}/files`,
|
||||||
|
icon: FolderOpenIcon,
|
||||||
|
subpages: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: formatMessage(messages.backupsNav),
|
||||||
|
href: `${worldPath.value}/backups`,
|
||||||
|
icon: DatabaseBackupIcon,
|
||||||
|
subpages: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [serverFull.value, currentWorld.value, worldId.value] as const,
|
||||||
|
([full, world, id]) => {
|
||||||
|
if (full && id && !world) {
|
||||||
|
router.replace(instancesPath.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function latestDate(backups: Archon.Servers.v1.WorldFull['backups']): string | null {
|
||||||
|
let latest = 0
|
||||||
|
let latestIso: string | null = null
|
||||||
|
for (const backup of backups) {
|
||||||
|
const timestamp = new Date(backup.created_at).getTime()
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= latest) continue
|
||||||
|
latest = timestamp
|
||||||
|
latestIso = backup.created_at
|
||||||
|
}
|
||||||
|
return latestIso
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,336 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div
|
||||||
|
v-if="!instanceInfoAdmonitionDismissed"
|
||||||
|
class="grid grid-cols-[1.5rem_minmax(0,1fr)_auto] items-start gap-x-3 rounded-2xl border border-solid border-brand-blue bg-bg-blue p-5 pr-4 text-contrast"
|
||||||
|
>
|
||||||
|
<InfoIcon class="mt-0.5 size-6 text-brand-blue" aria-hidden="true" />
|
||||||
|
<div class="flex min-w-0 flex-col gap-1">
|
||||||
|
<h2 class="m-0 text-xl font-bold leading-7">
|
||||||
|
{{ formatMessage(messages.instanceInfoHeader) }}
|
||||||
|
</h2>
|
||||||
|
<p class="m-0 text-lg leading-7 text-contrast/85">
|
||||||
|
{{ formatMessage(messages.instanceInfoBody) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ButtonStyled circular type="transparent" color="blue" hover-color-fill="background">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mt-0.5"
|
||||||
|
:aria-label="formatMessage(messages.instanceInfoDismiss)"
|
||||||
|
@click="dismissInstanceInfoAdmonition"
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="worldsPending"
|
||||||
|
class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,20.25rem),1fr))] gap-6"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="slot in WORLD_SLOT_COUNT"
|
||||||
|
:key="`instance-card-skeleton-${slot}`"
|
||||||
|
class="min-h-[19.75rem] animate-pulse rounded-2xl border border-solid border-surface-5 bg-bg-raised shadow-xl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="grid grid-cols-[repeat(auto-fit,minmax(min(100%,20.25rem),1fr))] gap-6">
|
||||||
|
<InstanceCard
|
||||||
|
v-for="world in worldSlots"
|
||||||
|
:key="world.id"
|
||||||
|
:world="world"
|
||||||
|
@create="handleCreateWorld"
|
||||||
|
@edit="handleEditWorld"
|
||||||
|
@settings="handleWorldSettings"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Archon } from '@modrinth/api-client'
|
||||||
|
import { InfoIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { useQuery } from '@tanstack/vue-query'
|
||||||
|
import { useStorage } from '@vueuse/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||||
|
import InstanceCard from '#ui/components/servers/instances/InstanceCard.vue'
|
||||||
|
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||||
|
import {
|
||||||
|
injectModrinthClient,
|
||||||
|
injectModrinthServerContext,
|
||||||
|
injectServerSettingsModal,
|
||||||
|
} from '#ui/providers'
|
||||||
|
import { formatLoaderLabel } from '#ui/utils/loaders'
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
instanceSlotName: {
|
||||||
|
id: 'servers.manage.instances.slot-name',
|
||||||
|
defaultMessage: 'Instance #{index}',
|
||||||
|
},
|
||||||
|
instanceInfoHeader: {
|
||||||
|
id: 'servers.manage.instances.info.header',
|
||||||
|
defaultMessage: 'What is a server instance?',
|
||||||
|
},
|
||||||
|
instanceInfoBody: {
|
||||||
|
id: 'servers.manage.instances.info.body',
|
||||||
|
defaultMessage:
|
||||||
|
'An instance is a separate setup of your server with its own content, files, worlds, and settings. You can switch which instance your server runs at any time.',
|
||||||
|
},
|
||||||
|
instanceInfoDismiss: {
|
||||||
|
id: 'servers.manage.instances.info.dismiss',
|
||||||
|
defaultMessage: "Don't show this again",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type LinkedModpack = {
|
||||||
|
name: string
|
||||||
|
iconUrl: string | null
|
||||||
|
link: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorldSlot =
|
||||||
|
| {
|
||||||
|
type: 'world'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
active: boolean
|
||||||
|
gameVersion: string | null
|
||||||
|
loaderLabel: string | null
|
||||||
|
linkedModpack: LinkedModpack | null
|
||||||
|
installedContentCount: number | null
|
||||||
|
lastActiveAt: string | null
|
||||||
|
createdAt: string | null
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'empty'
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentSummary = {
|
||||||
|
gameVersion: string | null
|
||||||
|
loader: string | null
|
||||||
|
loaderVersion: string | null
|
||||||
|
linkedModpack: LinkedModpack | null
|
||||||
|
installedContentCount: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const WORLD_SLOT_COUNT = 3
|
||||||
|
const INSTANCE_INFO_ADMONITION_KEY = 'server-instances-info-admonition-dismissed'
|
||||||
|
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const { serverId, server, isServerRunning } = injectModrinthServerContext()
|
||||||
|
const { openServerSettings } = injectServerSettingsModal()
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const router = useRouter()
|
||||||
|
const instanceInfoAdmonitionDismissed = useStorage(INSTANCE_INFO_ADMONITION_KEY, false)
|
||||||
|
|
||||||
|
const worldsQuery = useQuery({
|
||||||
|
queryKey: computed(() => ['servers', 'worlds', 'summary', 'v1', serverId]),
|
||||||
|
queryFn: loadWorldSlots,
|
||||||
|
staleTime: 30_000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const worldsPending = computed(() => worldsQuery.isLoading.value && !worldsQuery.data.value)
|
||||||
|
const worldSlots = computed(() => worldsQuery.data.value ?? [])
|
||||||
|
|
||||||
|
async function loadWorldSlots(): Promise<WorldSlot[]> {
|
||||||
|
try {
|
||||||
|
const serverFull = await client.archon.servers_v1.get(serverId)
|
||||||
|
const slots = await Promise.all(
|
||||||
|
serverFull.worlds.map(async (world, index) => {
|
||||||
|
const content = await loadContentSummary(world, index)
|
||||||
|
return toWorldSlot(world, content)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return padWorldSlots(slots)
|
||||||
|
} catch {
|
||||||
|
return createDummyWorldSlots()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContentSummary(
|
||||||
|
world: Archon.Servers.v1.WorldFull,
|
||||||
|
index: number,
|
||||||
|
): Promise<ContentSummary> {
|
||||||
|
try {
|
||||||
|
const content = await client.archon.content_v1.getAddons(serverId, world.id, {
|
||||||
|
addons: true,
|
||||||
|
updates: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameVersion: content.game_version ?? world.content?.game_version ?? null,
|
||||||
|
loader: content.modloader ?? world.content?.modloader ?? null,
|
||||||
|
loaderVersion: content.modloader_version ?? world.content?.modloader_version ?? null,
|
||||||
|
linkedModpack: getLinkedModpack(content.modpack),
|
||||||
|
installedContentCount: content.addons?.length ?? 0,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return createDummyContentSummary(world, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toWorldSlot(world: Archon.Servers.v1.WorldFull, content: ContentSummary): WorldSlot {
|
||||||
|
return {
|
||||||
|
type: 'world',
|
||||||
|
id: world.id,
|
||||||
|
name: world.name,
|
||||||
|
active: world.is_active,
|
||||||
|
gameVersion: content.gameVersion,
|
||||||
|
loaderLabel: getLoaderLabel(content.loader, content.loaderVersion),
|
||||||
|
linkedModpack: content.linkedModpack,
|
||||||
|
installedContentCount: content.installedContentCount,
|
||||||
|
lastActiveAt: getLatestKnownActivity(world),
|
||||||
|
createdAt: world.created_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function padWorldSlots(slots: WorldSlot[]): WorldSlot[] {
|
||||||
|
const padded = [...slots]
|
||||||
|
for (let i = padded.length; i < WORLD_SLOT_COUNT; i++) {
|
||||||
|
padded.push({
|
||||||
|
type: 'empty',
|
||||||
|
id: `empty-world-slot-${i + 1}`,
|
||||||
|
name: formatMessage(messages.instanceSlotName, { index: i + 1 }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return padded
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLinkedModpack(modpack: Archon.Content.v1.ModpackFields | null): LinkedModpack | null {
|
||||||
|
if (!modpack) return null
|
||||||
|
|
||||||
|
const name =
|
||||||
|
modpack.title ??
|
||||||
|
(modpack.spec.platform === 'local_file' ? modpack.spec.name : modpack.spec.project_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
iconUrl: modpack.icon_url ?? null,
|
||||||
|
link: modpack.spec.platform === 'modrinth' ? `/project/${modpack.spec.project_id}` : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLoaderLabel(loader: string | null, loaderVersion: string | null): string | null {
|
||||||
|
if (!loader) return null
|
||||||
|
const normalizedLoader = loader.toLowerCase()
|
||||||
|
return [formatLoaderLabel(normalizedLoader), loaderVersion].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestKnownActivity(world: Archon.Servers.v1.WorldFull): string | null {
|
||||||
|
const latestBackup = latestDate(world.backups.map((backup) => backup.created_at))
|
||||||
|
if (latestBackup) return latestBackup
|
||||||
|
if (world.is_active && isServerRunning.value) return new Date().toISOString()
|
||||||
|
return world.created_at ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestDate(dates: string[]): string | null {
|
||||||
|
let latest = 0
|
||||||
|
let latestIso: string | null = null
|
||||||
|
for (const date of dates) {
|
||||||
|
const timestamp = new Date(date).getTime()
|
||||||
|
if (!Number.isFinite(timestamp) || timestamp <= latest) continue
|
||||||
|
latest = timestamp
|
||||||
|
latestIso = date
|
||||||
|
}
|
||||||
|
return latestIso
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDummyContentSummary(
|
||||||
|
world: Archon.Servers.v1.WorldFull,
|
||||||
|
index: number,
|
||||||
|
): ContentSummary {
|
||||||
|
const gameVersion = world.content?.game_version ?? server.value?.mc_version ?? '1.20.4'
|
||||||
|
const loader = world.content?.modloader ?? server.value?.loader?.toLowerCase() ?? 'fabric'
|
||||||
|
const loaderVersion = world.content?.modloader_version ?? server.value?.loader_version ?? '0.16.6'
|
||||||
|
|
||||||
|
if (index === 0) {
|
||||||
|
return {
|
||||||
|
gameVersion,
|
||||||
|
loader,
|
||||||
|
loaderVersion,
|
||||||
|
linkedModpack: {
|
||||||
|
name: 'Cobblemon Official',
|
||||||
|
iconUrl: null,
|
||||||
|
link: null,
|
||||||
|
},
|
||||||
|
installedContentCount: 47,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
gameVersion,
|
||||||
|
loader,
|
||||||
|
loaderVersion,
|
||||||
|
linkedModpack: null,
|
||||||
|
installedContentCount: 13,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDummyWorldSlots(): WorldSlot[] {
|
||||||
|
const now = Date.now()
|
||||||
|
const twoHoursAgo = new Date(now - 2 * 60 * 60 * 1000).toISOString()
|
||||||
|
const sixteenDaysAgo = new Date(now - 16 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
const createdAt = new Date(now - 197 * 24 * 60 * 60 * 1000).toISOString()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'world',
|
||||||
|
id: 'dummy-world-1',
|
||||||
|
name: 'Cobbletown',
|
||||||
|
active: true,
|
||||||
|
gameVersion: '1.20.4',
|
||||||
|
loaderLabel: 'Fabric 0.16.6',
|
||||||
|
linkedModpack: {
|
||||||
|
name: 'Cobblemon Official',
|
||||||
|
iconUrl: null,
|
||||||
|
link: null,
|
||||||
|
},
|
||||||
|
installedContentCount: 47,
|
||||||
|
lastActiveAt: twoHoursAgo,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'world',
|
||||||
|
id: 'dummy-world-2',
|
||||||
|
name: 'SMP Season 4',
|
||||||
|
active: false,
|
||||||
|
gameVersion: '1.20.4',
|
||||||
|
loaderLabel: 'Fabric 0.16.6',
|
||||||
|
linkedModpack: null,
|
||||||
|
installedContentCount: 13,
|
||||||
|
lastActiveAt: sixteenDaysAgo,
|
||||||
|
createdAt,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'empty',
|
||||||
|
id: 'empty-world-slot-3',
|
||||||
|
name: formatMessage(messages.instanceSlotName, { index: 3 }),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEditWorld(worldId: string) {
|
||||||
|
router.push(
|
||||||
|
`/hosting/manage/${encodeURIComponent(serverId)}/instances/${encodeURIComponent(worldId)}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleWorldSettings() {
|
||||||
|
openServerSettings({ tabId: 'installation' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateWorld() {
|
||||||
|
openServerSettings({ tabId: 'installation' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismissInstanceInfoAdmonition() {
|
||||||
|
instanceInfoAdmonitionDismissed.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -137,14 +137,14 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
|
'Pick your favorite modpack from Modrinth, or choose a loader and add the mods you want.',
|
||||||
},
|
},
|
||||||
configureWorldTitle: {
|
configureInstanceTitle: {
|
||||||
id: 'servers.setup.onboarding.step.configure-world.title',
|
id: 'servers.setup.onboarding.step.configure-instance.title',
|
||||||
defaultMessage: 'Configure your world',
|
defaultMessage: 'Configure your instance',
|
||||||
},
|
},
|
||||||
configureWorldDescription: {
|
configureInstanceDescription: {
|
||||||
id: 'servers.setup.onboarding.step.configure-world.description',
|
id: 'servers.setup.onboarding.step.configure-instance.description',
|
||||||
defaultMessage:
|
defaultMessage:
|
||||||
'Set up your world just like singleplayer. Choose your gamemode and world seed.',
|
'Set up your instance just like singleplayer. Choose your gamemode and world seed.',
|
||||||
},
|
},
|
||||||
inviteFriendsTitle: {
|
inviteFriendsTitle: {
|
||||||
id: 'servers.setup.onboarding.step.invite-friends.title',
|
id: 'servers.setup.onboarding.step.invite-friends.title',
|
||||||
@@ -253,7 +253,11 @@ async function finalizeSetup() {
|
|||||||
client.archon.servers_v1.endIntro(serverId).then(() => {
|
client.archon.servers_v1.endIntro(serverId).then(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||||
})
|
})
|
||||||
await router.push(`/hosting/manage/${serverId}/`)
|
await router.push(
|
||||||
|
worldId.value
|
||||||
|
? `/hosting/manage/${encodeURIComponent(serverId)}/instances/${encodeURIComponent(worldId.value)}`
|
||||||
|
: `/hosting/manage/${encodeURIComponent(serverId)}/instances`,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Map UI loader names to API Modloader values */
|
/** Map UI loader names to API Modloader values */
|
||||||
@@ -347,8 +351,8 @@ const steps = computed(() => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: GlobeIcon,
|
icon: GlobeIcon,
|
||||||
title: formatMessage(messages.configureWorldTitle),
|
title: formatMessage(messages.configureInstanceTitle),
|
||||||
description: formatMessage(messages.configureWorldDescription),
|
description: formatMessage(messages.configureInstanceDescription),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: UsersIcon,
|
icon: UsersIcon,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
export { default as ServersManageRootLayout } from './hosting/manage/[id]/index.vue'
|
||||||
|
export { default as ServersManageBackupsPage } from './hosting/manage/[id]/instances/[instance-id]/backups.vue'
|
||||||
|
export { default as ServersManageContentPage } from './hosting/manage/[id]/instances/[instance-id]/content.vue'
|
||||||
|
export { default as ServersManageFilesPage } from './hosting/manage/[id]/instances/[instance-id]/files.vue'
|
||||||
|
export { default as ServersManageInstanceRootLayout } from './hosting/manage/[id]/instances/[instance-id]/index.vue'
|
||||||
|
export { default as ServersManageInstancesPage } from './hosting/manage/[id]/instances/index.vue'
|
||||||
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
|
export { default as ServerOnboardingPanelPage } from './hosting/manage/[id]/onboarding.vue'
|
||||||
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
|
export { default as ServersManageOverviewPage } from './hosting/manage/[id]/overview.vue'
|
||||||
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 ServersManagePageIndex } from './hosting/manage/index.vue'
|
||||||
export { default as ServersManageOverviewPage } from './hosting/manage/overview.vue'
|
|
||||||
export { default as ServersManageRootLayout } from './hosting/manage/root.vue'
|
|
||||||
|
|||||||
@@ -3515,6 +3515,15 @@
|
|||||||
"servers.listing.using-project-label": {
|
"servers.listing.using-project-label": {
|
||||||
"defaultMessage": "Using {projectTitle}"
|
"defaultMessage": "Using {projectTitle}"
|
||||||
},
|
},
|
||||||
|
"servers.manage.action.go-back-to-all-servers": {
|
||||||
|
"defaultMessage": "Go back to all servers"
|
||||||
|
},
|
||||||
|
"servers.manage.action.go-to-billing-settings": {
|
||||||
|
"defaultMessage": "Go to billing settings"
|
||||||
|
},
|
||||||
|
"servers.manage.action.reload": {
|
||||||
|
"defaultMessage": "Reload"
|
||||||
|
},
|
||||||
"servers.manage.checking-for-new-servers": {
|
"servers.manage.checking-for-new-servers": {
|
||||||
"defaultMessage": "Checking for new servers..."
|
"defaultMessage": "Checking for new servers..."
|
||||||
},
|
},
|
||||||
@@ -3527,15 +3536,102 @@
|
|||||||
"servers.manage.contact-support-button": {
|
"servers.manage.contact-support-button": {
|
||||||
"defaultMessage": "Contact Modrinth Support"
|
"defaultMessage": "Contact Modrinth Support"
|
||||||
},
|
},
|
||||||
|
"servers.manage.debug.server-data": {
|
||||||
|
"defaultMessage": "Server data"
|
||||||
|
},
|
||||||
|
"servers.manage.error-details.error-message": {
|
||||||
|
"defaultMessage": "Error message"
|
||||||
|
},
|
||||||
|
"servers.manage.error-details.error-name": {
|
||||||
|
"defaultMessage": "Error Name"
|
||||||
|
},
|
||||||
|
"servers.manage.error-details.node": {
|
||||||
|
"defaultMessage": "Node"
|
||||||
|
},
|
||||||
|
"servers.manage.error-details.original-error": {
|
||||||
|
"defaultMessage": "Original Error"
|
||||||
|
},
|
||||||
|
"servers.manage.error-details.server-id": {
|
||||||
|
"defaultMessage": "Server ID"
|
||||||
|
},
|
||||||
|
"servers.manage.error-details.stack-trace": {
|
||||||
|
"defaultMessage": "Stack Trace"
|
||||||
|
},
|
||||||
|
"servers.manage.error-details.timestamp": {
|
||||||
|
"defaultMessage": "Timestamp"
|
||||||
|
},
|
||||||
|
"servers.manage.error-details.unknown": {
|
||||||
|
"defaultMessage": "Unknown"
|
||||||
|
},
|
||||||
"servers.manage.error.alert-notice": {
|
"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."
|
"defaultMessage": "Our systems automatically alert our team when there's an issue. We are already working on getting them back online."
|
||||||
},
|
},
|
||||||
|
"servers.manage.error.change-loader": {
|
||||||
|
"defaultMessage": "Change Loader"
|
||||||
|
},
|
||||||
|
"servers.manage.error.contact-support": {
|
||||||
|
"defaultMessage": "Please contact Modrinth Support."
|
||||||
|
},
|
||||||
|
"servers.manage.error.copy-debug-info": {
|
||||||
|
"defaultMessage": "Copy Debug Info"
|
||||||
|
},
|
||||||
|
"servers.manage.error.debug-info": {
|
||||||
|
"defaultMessage": "Server ID: {serverId}\nError: {error}\nKind: {kind}\nProject ID: {projectId}\nVersion ID: {versionId}\nLog: {log}"
|
||||||
|
},
|
||||||
"servers.manage.error.description": {
|
"servers.manage.error.description": {
|
||||||
"defaultMessage": "We may have temporary issues with our servers."
|
"defaultMessage": "We may have temporary issues with our servers."
|
||||||
},
|
},
|
||||||
"servers.manage.error.details": {
|
"servers.manage.error.details": {
|
||||||
"defaultMessage": "Error details:"
|
"defaultMessage": "Error details:"
|
||||||
},
|
},
|
||||||
|
"servers.manage.error.general.message": {
|
||||||
|
"defaultMessage": "An unexpected error occurred."
|
||||||
|
},
|
||||||
|
"servers.manage.error.general.title": {
|
||||||
|
"defaultMessage": "An error occurred."
|
||||||
|
},
|
||||||
|
"servers.manage.error.install.internal.description": {
|
||||||
|
"defaultMessage": "An internal error occurred while installing your server. Don't fret - try reinstalling your server, and if the problem persists, please contact Modrinth support with your server's debug information."
|
||||||
|
},
|
||||||
|
"servers.manage.error.install.invalid-version.change-loader": {
|
||||||
|
"defaultMessage": "Your server may need to be reinstalled with a valid mod loader and version. You can change the loader by clicking the \"Change Loader\" button."
|
||||||
|
},
|
||||||
|
"servers.manage.error.install.invalid-version.description": {
|
||||||
|
"defaultMessage": "An invalid loader or Minecraft version was specified and could not be installed."
|
||||||
|
},
|
||||||
|
"servers.manage.error.install.invalid-version.modpack-compatibility": {
|
||||||
|
"defaultMessage": "If you've installed a modpack, it may have been packaged incorrectly or may not be compatible with the loader."
|
||||||
|
},
|
||||||
|
"servers.manage.error.install.invalid-version.recent-minecraft": {
|
||||||
|
"defaultMessage": "If this version of Minecraft was released recently, please check if Modrinth Hosting supports it."
|
||||||
|
},
|
||||||
|
"servers.manage.error.install.invalid-version.support": {
|
||||||
|
"defaultMessage": "If you're stuck, please contact Modrinth Support with the information below:"
|
||||||
|
},
|
||||||
|
"servers.manage.error.install.title": {
|
||||||
|
"defaultMessage": "Installation error"
|
||||||
|
},
|
||||||
|
"servers.manage.error.install.unsupported-version.description": {
|
||||||
|
"defaultMessage": "An error occurred while installing your server because Modrinth Hosting does not support the version of Minecraft or the loader you specified. Try reinstalling your server with a different version or loader, and if the problem persists, please contact Modrinth Support with your server's debug information."
|
||||||
|
},
|
||||||
|
"servers.manage.error.node-ping-failed": {
|
||||||
|
"defaultMessage": "Unable to reach node. Ping test failed."
|
||||||
|
},
|
||||||
|
"servers.manage.error.node-unavailable.data-description": {
|
||||||
|
"defaultMessage": "Your data is safe and will not be lost, and your server will be back online as soon as the issue is resolved."
|
||||||
|
},
|
||||||
|
"servers.manage.error.node-unavailable.description": {
|
||||||
|
"defaultMessage": "Your server's node, where your Modrinth Server is physically hosted, is not accessible at the moment. We are working to resolve the issue as quickly as possible."
|
||||||
|
},
|
||||||
|
"servers.manage.error.node-unavailable.support-description": {
|
||||||
|
"defaultMessage": "If reloading does not work initially, please contact Modrinth Support via the chat bubble in the bottom right corner and we'll be happy to help."
|
||||||
|
},
|
||||||
|
"servers.manage.error.node-unavailable.title": {
|
||||||
|
"defaultMessage": "Server Node Unavailable"
|
||||||
|
},
|
||||||
|
"servers.manage.error.open-installation-log": {
|
||||||
|
"defaultMessage": "Open Installation Log"
|
||||||
|
},
|
||||||
"servers.manage.error.queue-notice": {
|
"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>"
|
"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>"
|
||||||
},
|
},
|
||||||
@@ -3545,15 +3641,105 @@
|
|||||||
"servers.manage.error.title": {
|
"servers.manage.error.title": {
|
||||||
"defaultMessage": "Servers could not be loaded"
|
"defaultMessage": "Servers could not be loaded"
|
||||||
},
|
},
|
||||||
|
"servers.manage.error.unknown": {
|
||||||
|
"defaultMessage": "Unknown error"
|
||||||
|
},
|
||||||
"servers.manage.handle-error.title": {
|
"servers.manage.handle-error.title": {
|
||||||
"defaultMessage": "An error occurred"
|
"defaultMessage": "An error occurred"
|
||||||
},
|
},
|
||||||
|
"servers.manage.install.retry-error": {
|
||||||
|
"defaultMessage": "Failed to retry installation"
|
||||||
|
},
|
||||||
|
"servers.manage.instance.all-instances": {
|
||||||
|
"defaultMessage": "All instances"
|
||||||
|
},
|
||||||
|
"servers.manage.instance.fallback-name": {
|
||||||
|
"defaultMessage": "Instance"
|
||||||
|
},
|
||||||
|
"servers.manage.instance.last-active": {
|
||||||
|
"defaultMessage": "Last active {time}"
|
||||||
|
},
|
||||||
|
"servers.manage.instance.settings": {
|
||||||
|
"defaultMessage": "Instance settings"
|
||||||
|
},
|
||||||
|
"servers.manage.instances-hint.description": {
|
||||||
|
"defaultMessage": "Your server state has been converted into an instance."
|
||||||
|
},
|
||||||
|
"servers.manage.instances-hint.dismiss": {
|
||||||
|
"defaultMessage": "Don't show again"
|
||||||
|
},
|
||||||
|
"servers.manage.instances-hint.title": {
|
||||||
|
"defaultMessage": "New: Server Instances!"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.active": {
|
||||||
|
"defaultMessage": "Active"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.create": {
|
||||||
|
"defaultMessage": "Create instance"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.created": {
|
||||||
|
"defaultMessage": "Created"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.edit": {
|
||||||
|
"defaultMessage": "Edit instance"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.empty-description": {
|
||||||
|
"defaultMessage": "New instance"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.installed-content": {
|
||||||
|
"defaultMessage": "Installed content"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.last-active": {
|
||||||
|
"defaultMessage": "Last active"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.none": {
|
||||||
|
"defaultMessage": "None"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.not-tracked-yet": {
|
||||||
|
"defaultMessage": "Not tracked yet"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.card.settings": {
|
||||||
|
"defaultMessage": "Instance settings"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.info.definition": {
|
||||||
|
"defaultMessage": "An instance is a separate server setup."
|
||||||
|
},
|
||||||
|
"servers.manage.instances.info.files": {
|
||||||
|
"defaultMessage": "Each instance has its own server files, worlds, installed content, and settings."
|
||||||
|
},
|
||||||
|
"servers.manage.instances.info.header": {
|
||||||
|
"defaultMessage": "What is an instance?"
|
||||||
|
},
|
||||||
|
"servers.manage.instances.info.switching": {
|
||||||
|
"defaultMessage": "You can switch which instance your server runs."
|
||||||
|
},
|
||||||
|
"servers.manage.instances.slot-name": {
|
||||||
|
"defaultMessage": "Instance #{index}"
|
||||||
|
},
|
||||||
|
"servers.manage.nav.backups": {
|
||||||
|
"defaultMessage": "Backups"
|
||||||
|
},
|
||||||
|
"servers.manage.nav.content": {
|
||||||
|
"defaultMessage": "Content"
|
||||||
|
},
|
||||||
|
"servers.manage.nav.files": {
|
||||||
|
"defaultMessage": "Files"
|
||||||
|
},
|
||||||
|
"servers.manage.nav.instances": {
|
||||||
|
"defaultMessage": "Instances"
|
||||||
|
},
|
||||||
|
"servers.manage.nav.overview": {
|
||||||
|
"defaultMessage": "Overview"
|
||||||
|
},
|
||||||
"servers.manage.new-server-button": {
|
"servers.manage.new-server-button": {
|
||||||
"defaultMessage": "New server"
|
"defaultMessage": "New server"
|
||||||
},
|
},
|
||||||
"servers.manage.no-servers-found": {
|
"servers.manage.no-servers-found": {
|
||||||
"defaultMessage": "No servers found."
|
"defaultMessage": "No servers found."
|
||||||
},
|
},
|
||||||
|
"servers.manage.notice.dismiss-error": {
|
||||||
|
"defaultMessage": "Error dismissing notice"
|
||||||
|
},
|
||||||
"servers.manage.purchase-unavailable.text": {
|
"servers.manage.purchase-unavailable.text": {
|
||||||
"defaultMessage": "Payment information is still loading. Opening checkout as soon as it is ready."
|
"defaultMessage": "Payment information is still loading. Opening checkout as soon as it is ready."
|
||||||
},
|
},
|
||||||
@@ -3584,6 +3770,9 @@
|
|||||||
"servers.manage.search-placeholder": {
|
"servers.manage.search-placeholder": {
|
||||||
"defaultMessage": "Search {count} {count, plural, one {server} other {servers}}..."
|
"defaultMessage": "Search {count} {count, plural, one {server} other {servers}}..."
|
||||||
},
|
},
|
||||||
|
"servers.manage.server-settings": {
|
||||||
|
"defaultMessage": "Server settings"
|
||||||
|
},
|
||||||
"servers.manage.servers-title": {
|
"servers.manage.servers-title": {
|
||||||
"defaultMessage": "Modrinth Hosting"
|
"defaultMessage": "Modrinth Hosting"
|
||||||
},
|
},
|
||||||
@@ -3596,6 +3785,36 @@
|
|||||||
"servers.manage.settings-hint.title": {
|
"servers.manage.settings-hint.title": {
|
||||||
"defaultMessage": "Your server settings have moved"
|
"defaultMessage": "Your server settings have moved"
|
||||||
},
|
},
|
||||||
|
"servers.manage.status.preparing.description": {
|
||||||
|
"defaultMessage": "Your server's hardware is being prepared and will be available shortly!"
|
||||||
|
},
|
||||||
|
"servers.manage.status.preparing.title": {
|
||||||
|
"defaultMessage": "We're getting your server ready"
|
||||||
|
},
|
||||||
|
"servers.manage.status.suspended.cancelled-description": {
|
||||||
|
"defaultMessage": "Your subscription has been cancelled.\nContact Modrinth Support if you believe this is an error."
|
||||||
|
},
|
||||||
|
"servers.manage.status.suspended.description": {
|
||||||
|
"defaultMessage": "Your server has been suspended.\nContact Modrinth Support if you believe this is an error."
|
||||||
|
},
|
||||||
|
"servers.manage.status.suspended.reason-description": {
|
||||||
|
"defaultMessage": "Your server has been suspended: {reason}\nContact Modrinth Support if you believe this is an error."
|
||||||
|
},
|
||||||
|
"servers.manage.status.suspended.title": {
|
||||||
|
"defaultMessage": "Server suspended"
|
||||||
|
},
|
||||||
|
"servers.manage.status.upgrading.description": {
|
||||||
|
"defaultMessage": "Your server's hardware is currently being upgraded and will be back online shortly!"
|
||||||
|
},
|
||||||
|
"servers.manage.status.upgrading.title": {
|
||||||
|
"defaultMessage": "Server upgrading"
|
||||||
|
},
|
||||||
|
"servers.manage.websocket.error": {
|
||||||
|
"defaultMessage": "Something went wrong..."
|
||||||
|
},
|
||||||
|
"servers.manage.websocket.reconnecting": {
|
||||||
|
"defaultMessage": "Hang on, we're reconnecting to your server."
|
||||||
|
},
|
||||||
"servers.medal-listing.countdown.remaining": {
|
"servers.medal-listing.countdown.remaining": {
|
||||||
"defaultMessage": "<days-count>{days}</days-count> {days, plural, one {day} other {days}} <hours-count>{hours}</hours-count> {hours, plural, one {hour} other {hours}} <minutes-count>{minutes}</minutes-count> {minutes, plural, one {minute} other {minutes}} <seconds-count>{seconds}</seconds-count> {seconds, plural, one {second} other {seconds}} remaining..."
|
"defaultMessage": "<days-count>{days}</days-count> {days, plural, one {day} other {days}} <hours-count>{hours}</hours-count> {hours, plural, one {hour} other {hours}} <minutes-count>{minutes}</minutes-count> {minutes, plural, one {minute} other {minutes}} <seconds-count>{seconds}</seconds-count> {seconds, plural, one {second} other {seconds}} remaining..."
|
||||||
},
|
},
|
||||||
@@ -3764,11 +3983,11 @@
|
|||||||
"servers.setup.onboarding.step.choose.title": {
|
"servers.setup.onboarding.step.choose.title": {
|
||||||
"defaultMessage": "Choose what to play"
|
"defaultMessage": "Choose what to play"
|
||||||
},
|
},
|
||||||
"servers.setup.onboarding.step.configure-world.description": {
|
"servers.setup.onboarding.step.configure-instance.description": {
|
||||||
"defaultMessage": "Set up your world just like singleplayer. Choose your gamemode and world seed."
|
"defaultMessage": "Set up your instance just like singleplayer. Choose your gamemode and world seed."
|
||||||
},
|
},
|
||||||
"servers.setup.onboarding.step.configure-world.title": {
|
"servers.setup.onboarding.step.configure-instance.title": {
|
||||||
"defaultMessage": "Configure your world"
|
"defaultMessage": "Configure your instance"
|
||||||
},
|
},
|
||||||
"servers.setup.onboarding.step.invite-friends.description": {
|
"servers.setup.onboarding.step.invite-friends.description": {
|
||||||
"defaultMessage": "Share your server with friends by copying the address and letting them know which mods they'll need to join."
|
"defaultMessage": "Share your server with friends by copying the address and letting them know which mods they'll need to join."
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
|
||||||
|
|
||||||
import Avatar from '../../components/base/Avatar.vue'
|
|
||||||
import ButtonStyled from '../../components/base/ButtonStyled.vue'
|
|
||||||
import ContentPageHeader from '../../components/base/ContentPageHeader.vue'
|
|
||||||
|
|
||||||
const meta = {
|
|
||||||
title: 'Base/ContentPageHeader',
|
|
||||||
component: ContentPageHeader,
|
|
||||||
} satisfies Meta<typeof ContentPageHeader>
|
|
||||||
|
|
||||||
export default meta
|
|
||||||
type Story = StoryObj<typeof meta>
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
render: () => ({
|
|
||||||
components: { ContentPageHeader, Avatar, ButtonStyled },
|
|
||||||
template: `
|
|
||||||
<ContentPageHeader>
|
|
||||||
<template #icon>
|
|
||||||
<Avatar size="64px" />
|
|
||||||
</template>
|
|
||||||
<template #title>Project Name</template>
|
|
||||||
<template #summary>A brief description of the project goes here.</template>
|
|
||||||
<template #stats>
|
|
||||||
<span>1.2M downloads</span>
|
|
||||||
<span>50K followers</span>
|
|
||||||
</template>
|
|
||||||
<template #actions>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button>Follow</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</ContentPageHeader>
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
|
|
||||||
export const WithTitleSuffix: Story = {
|
|
||||||
render: () => ({
|
|
||||||
components: { ContentPageHeader, Avatar, ButtonStyled },
|
|
||||||
template: `
|
|
||||||
<ContentPageHeader>
|
|
||||||
<template #icon>
|
|
||||||
<Avatar size="64px" />
|
|
||||||
</template>
|
|
||||||
<template #title>Featured Project</template>
|
|
||||||
<template #title-suffix>
|
|
||||||
<span class="px-2 py-1 bg-brand-highlight text-brand rounded-full text-sm">Featured</span>
|
|
||||||
</template>
|
|
||||||
<template #summary>This project has been featured by the Modrinth team.</template>
|
|
||||||
<template #actions>
|
|
||||||
<ButtonStyled color="brand">
|
|
||||||
<button>Download</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
<ButtonStyled type="transparent">
|
|
||||||
<button>Share</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
</ContentPageHeader>
|
|
||||||
`,
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
import {
|
||||||
|
AffiliateIcon,
|
||||||
|
ClipboardCopyIcon,
|
||||||
|
DownloadIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
HeartIcon,
|
||||||
|
LeftArrowIcon,
|
||||||
|
LinkIcon,
|
||||||
|
MoreVerticalIcon,
|
||||||
|
PlayIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
SlashIcon,
|
||||||
|
StopCircleIcon,
|
||||||
|
TagCategoryGamepad2Icon as Gamepad2Icon,
|
||||||
|
TimerIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import PageHeader from '../../components/base/PageHeader.vue'
|
||||||
|
import LoaderIcon from '../../components/servers/icons/LoaderIcon.vue'
|
||||||
|
import ServerIcon from '../../components/servers/icons/ServerIcon.vue'
|
||||||
|
|
||||||
|
const noop = () => undefined
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Base/PageHeader',
|
||||||
|
component: PageHeader,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(story) => ({
|
||||||
|
components: { story },
|
||||||
|
template: '<div class="w-full"><story /></div>',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
} satisfies Meta<typeof PageHeader>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const AppInstanceHeader: Story = {
|
||||||
|
args: {
|
||||||
|
header: 'Create: Astral',
|
||||||
|
leading: {
|
||||||
|
type: 'avatar',
|
||||||
|
src: null,
|
||||||
|
alt: 'Create: Astral',
|
||||||
|
tintBy: '/Users/calum/Library/Application Support/com.modrinth.theseus/profiles/astral',
|
||||||
|
},
|
||||||
|
metadata: [
|
||||||
|
{
|
||||||
|
id: 'game-version',
|
||||||
|
label: '1.20.1',
|
||||||
|
icon: Gamepad2Icon,
|
||||||
|
tooltip: 'Minecraft version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loader',
|
||||||
|
label: 'Fabric 0.16.14',
|
||||||
|
icon: LoaderIcon,
|
||||||
|
iconProps: { loader: 'Fabric' },
|
||||||
|
tooltip: 'Mod loader',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'playtime',
|
||||||
|
label: '12 hours',
|
||||||
|
icon: TimerIcon,
|
||||||
|
tooltip: 'Total playtime',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'play',
|
||||||
|
label: 'Play',
|
||||||
|
icon: PlayIcon,
|
||||||
|
color: 'brand',
|
||||||
|
onClick: noop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Instance settings',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: 'Instance settings',
|
||||||
|
onClick: noop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'more',
|
||||||
|
label: 'More actions',
|
||||||
|
icon: MoreVerticalIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
type: 'transparent',
|
||||||
|
tooltip: 'More actions',
|
||||||
|
menuActions: [
|
||||||
|
{
|
||||||
|
id: 'open-folder',
|
||||||
|
label: 'Open folder',
|
||||||
|
icon: GlobeIcon,
|
||||||
|
action: noop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'copy-id',
|
||||||
|
label: 'Copy ID',
|
||||||
|
icon: ClipboardCopyIcon,
|
||||||
|
action: noop,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CreatorHeader: Story = {
|
||||||
|
args: {
|
||||||
|
header: 'Prospector',
|
||||||
|
summary: 'A Modrinth creator with a handful of popular projects.',
|
||||||
|
leading: {
|
||||||
|
type: 'avatar',
|
||||||
|
src: null,
|
||||||
|
alt: 'Prospector',
|
||||||
|
avatarSize: '96px',
|
||||||
|
circle: true,
|
||||||
|
},
|
||||||
|
badges: [
|
||||||
|
{
|
||||||
|
id: 'affiliate',
|
||||||
|
label: 'Affiliate',
|
||||||
|
icon: AffiliateIcon,
|
||||||
|
class: 'border-brand-highlight bg-brand-highlight text-brand',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: [
|
||||||
|
{
|
||||||
|
id: 'projects',
|
||||||
|
label: '12 projects',
|
||||||
|
icon: Gamepad2Icon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'downloads',
|
||||||
|
label: '4.2M downloads',
|
||||||
|
icon: DownloadIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'followers',
|
||||||
|
label: '82K followers',
|
||||||
|
icon: HeartIcon,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'follow',
|
||||||
|
label: 'Follow',
|
||||||
|
icon: HeartIcon,
|
||||||
|
color: 'brand',
|
||||||
|
onClick: noop,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BrowseHeader: Story = {
|
||||||
|
args: {
|
||||||
|
header: 'Survival SMP',
|
||||||
|
leading: [
|
||||||
|
{
|
||||||
|
id: 'back',
|
||||||
|
type: 'button',
|
||||||
|
icon: LeftArrowIcon,
|
||||||
|
to: '/instance/my-world',
|
||||||
|
ariaLabel: 'Back to instance',
|
||||||
|
tooltip: 'Back to instance',
|
||||||
|
wrapperClass: 'flex size-12 shrink-0 items-center justify-center',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'target',
|
||||||
|
type: 'avatar',
|
||||||
|
src: null,
|
||||||
|
alt: 'Survival SMP',
|
||||||
|
avatarSize: '48px',
|
||||||
|
tintBy: 'survival-smp',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
metadata: [
|
||||||
|
{
|
||||||
|
id: 'heading',
|
||||||
|
label: 'Installing content',
|
||||||
|
class: '!text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'game-version',
|
||||||
|
label: '1.20.1',
|
||||||
|
icon: Gamepad2Icon,
|
||||||
|
tooltip: 'Minecraft version',
|
||||||
|
class: '!text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loader',
|
||||||
|
label: 'Fabric',
|
||||||
|
icon: LoaderIcon,
|
||||||
|
iconProps: { loader: 'Fabric' },
|
||||||
|
tooltip: 'Mod loader',
|
||||||
|
class: '!text-primary',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
divider: false,
|
||||||
|
bottomPadding: false,
|
||||||
|
mainClass: 'items-center',
|
||||||
|
titleClass: 'leading-8',
|
||||||
|
truncateTitle: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerPanelRootHeader: Story = {
|
||||||
|
args: {
|
||||||
|
header: 'Survival SMP',
|
||||||
|
leading: {
|
||||||
|
type: 'component',
|
||||||
|
component: ServerIcon,
|
||||||
|
componentProps: { image: undefined },
|
||||||
|
class: 'size-15 !rounded-xl',
|
||||||
|
},
|
||||||
|
metadata: [
|
||||||
|
{
|
||||||
|
id: 'active-world',
|
||||||
|
label: 'My World',
|
||||||
|
icon: GlobeIcon,
|
||||||
|
tooltip: 'Active instance',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
label: 'play.modrinth.gg',
|
||||||
|
icon: LinkIcon,
|
||||||
|
tooltip: 'Copy server address',
|
||||||
|
onClick: noop,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'start',
|
||||||
|
label: 'Start server',
|
||||||
|
icon: PlayIcon,
|
||||||
|
color: 'brand',
|
||||||
|
onClick: noop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Server settings',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: 'Server settings',
|
||||||
|
onClick: noop,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServerPanelInstanceHeader: Story = {
|
||||||
|
args: {
|
||||||
|
header: 'My World',
|
||||||
|
leading: {
|
||||||
|
type: 'button',
|
||||||
|
icon: LeftArrowIcon,
|
||||||
|
to: '/hosting/manage/demo-server/instances',
|
||||||
|
ariaLabel: 'All instances',
|
||||||
|
tooltip: 'All instances',
|
||||||
|
},
|
||||||
|
metadata: [
|
||||||
|
{
|
||||||
|
id: 'game-version',
|
||||||
|
label: '1.20.1',
|
||||||
|
icon: Gamepad2Icon,
|
||||||
|
tooltip: 'Minecraft version',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'loader',
|
||||||
|
label: 'Fabric 0.19.2',
|
||||||
|
icon: LoaderIcon,
|
||||||
|
iconProps: { loader: 'Fabric' },
|
||||||
|
tooltip: 'Mod loader',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'last-active',
|
||||||
|
label: 'Last active 2 weeks ago',
|
||||||
|
icon: TimerIcon,
|
||||||
|
tooltip: 'Last activity',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'start',
|
||||||
|
label: 'Start instance',
|
||||||
|
icon: PlayIcon,
|
||||||
|
color: 'brand',
|
||||||
|
onClick: noop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stop',
|
||||||
|
label: 'Stop instance',
|
||||||
|
color: 'red',
|
||||||
|
joinedActions: [
|
||||||
|
{
|
||||||
|
id: 'stop',
|
||||||
|
label: 'Stop',
|
||||||
|
icon: StopCircleIcon,
|
||||||
|
action: noop,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kill_server',
|
||||||
|
label: 'Kill server',
|
||||||
|
icon: SlashIcon,
|
||||||
|
action: noop,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Instance settings',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: 'Instance settings',
|
||||||
|
onClick: noop,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CustomMetadata: Story = {
|
||||||
|
render: () => ({
|
||||||
|
components: { PageHeader, DownloadIcon, HeartIcon },
|
||||||
|
template: `
|
||||||
|
<PageHeader
|
||||||
|
header="Custom Metadata Project"
|
||||||
|
summary="Custom metadata is reserved for rich stat rows that cannot be represented by icon and label data."
|
||||||
|
:leading="{ type: 'avatar', src: null, alt: 'Custom Metadata Project', avatarSize: '96px' }"
|
||||||
|
:metadata="[{ id: 'project-stats', type: 'custom', class: 'contents' }]"
|
||||||
|
>
|
||||||
|
<template #metadata-project-stats>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex items-center gap-2 font-semibold">
|
||||||
|
<DownloadIcon class="h-6 w-6 text-secondary" />
|
||||||
|
1.2M downloads
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 font-semibold">
|
||||||
|
<HeartIcon class="h-6 w-6 text-secondary" />
|
||||||
|
50K followers
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</PageHeader>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import InstanceCard from '../../components/servers/instances/InstanceCard.vue'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Servers/InstanceCard',
|
||||||
|
component: InstanceCard,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
render: (args) => ({
|
||||||
|
components: { InstanceCard },
|
||||||
|
setup() {
|
||||||
|
return { args }
|
||||||
|
},
|
||||||
|
template: '<div style="max-width: 360px"><InstanceCard v-bind="args" /></div>',
|
||||||
|
}),
|
||||||
|
} satisfies Meta<typeof InstanceCard>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Active: Story = {
|
||||||
|
args: {
|
||||||
|
world: {
|
||||||
|
type: 'world',
|
||||||
|
id: 'demo-world',
|
||||||
|
name: 'Cobbletown',
|
||||||
|
active: true,
|
||||||
|
gameVersion: '1.20.4',
|
||||||
|
loaderLabel: 'Fabric 0.16.6',
|
||||||
|
linkedModpack: {
|
||||||
|
name: 'Cobblemon Official',
|
||||||
|
iconUrl: null,
|
||||||
|
link: null,
|
||||||
|
},
|
||||||
|
installedContentCount: 47,
|
||||||
|
lastActiveAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||||
|
createdAt: new Date(Date.now() - 197 * 24 * 60 * 60 * 1000).toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
args: {
|
||||||
|
world: {
|
||||||
|
type: 'empty',
|
||||||
|
id: 'empty-instance-slot',
|
||||||
|
name: 'Instance #2',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ const meta = {
|
|||||||
setup() {
|
setup() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
router.replace('/hosting/manage/demo-server/content')
|
router.replace('/hosting/manage/demo-server/instances/demo-world')
|
||||||
})
|
})
|
||||||
|
|
||||||
const server = ref({
|
const server = ref({
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { PlayIcon, SettingsIcon } from '@modrinth/assets'
|
||||||
|
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||||
|
|
||||||
|
import WorldManageHeader from '../../components/servers/server-header/WorldManageHeader.vue'
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: 'Servers/WorldManageHeader',
|
||||||
|
component: WorldManageHeader,
|
||||||
|
parameters: {
|
||||||
|
layout: 'padded',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(story) => ({
|
||||||
|
components: { story },
|
||||||
|
template: '<div style="max-width: 920px;"><story /></div>',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
} satisfies Meta<typeof WorldManageHeader>
|
||||||
|
|
||||||
|
export default meta
|
||||||
|
type Story = StoryObj<typeof meta>
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
name: 'My World',
|
||||||
|
gameVersion: '1.20.1',
|
||||||
|
loader: 'fabric',
|
||||||
|
loaderVersion: '0.19.2',
|
||||||
|
lastActive: 'Last active 2 weeks ago',
|
||||||
|
backHref: '/hosting/manage/demo-server/instances',
|
||||||
|
backLabel: 'All instances',
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 'start',
|
||||||
|
label: 'Start instance',
|
||||||
|
icon: PlayIcon,
|
||||||
|
color: 'brand',
|
||||||
|
onClick: () => undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'settings',
|
||||||
|
label: 'Instance settings',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
labelHidden: true,
|
||||||
|
tooltip: 'Instance settings',
|
||||||
|
onClick: () => undefined,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user