mirror of
https://github.com/modrinth/code.git
synced 2026-08-02 22:25:52 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe64b6c76d | ||
|
|
485ca623ab | ||
|
|
46763861d9 | ||
|
|
4986bc194d | ||
|
|
417c6964f6 | ||
|
|
ef07172006 | ||
|
|
2e08fe7950 | ||
|
|
af6aec8dec | ||
|
|
e5f91eadb9 | ||
|
|
8196d622d6 | ||
|
|
ea94bcc00f | ||
|
|
c77175176b | ||
|
|
32ca8d549b | ||
|
|
7618f9bc09 | ||
|
|
77e74f840a | ||
|
|
8490718e79 | ||
|
|
2021c221cc | ||
|
|
2c1b8a2326 | ||
|
|
ad7a4d7d76 | ||
|
|
54e6fe09cd | ||
|
|
0a451d9f9a | ||
|
|
8381482cbb | ||
|
|
db225b757d | ||
|
|
9ab9fe994f | ||
|
|
27f629a7a3 | ||
|
|
c0d839c7f6 | ||
|
|
45d02bd5e6 | ||
|
|
de007985c1 | ||
|
|
8493e66137 | ||
|
|
53ca40079f | ||
|
|
d1a26ee97a | ||
|
|
e3c50d19ed | ||
|
|
69ba34a8ad | ||
|
|
b72c319569 | ||
|
|
8fca23e66a | ||
|
|
946b9d3765 | ||
|
|
f097409b92 | ||
|
|
f799e6f67e | ||
|
|
70887e8617 | ||
|
|
546a207ea1 | ||
|
|
110a83ca1c | ||
|
|
003c8305a2 | ||
|
|
f922145230 | ||
|
|
5d49e9fa53 | ||
|
|
d73d7ca7d0 | ||
|
|
a94e15e278 |
@@ -146,6 +146,7 @@ const APP_LEFT_NAV_WIDTH = '4rem'
|
||||
const APP_SIDEBAR_WIDTH = 300
|
||||
const INTERCOM_BUBBLE_DEFAULT_PADDING = 20
|
||||
const PRIDE_FUNDRAISER_END_DATE = new Date('2026-07-01T00:00:00Z').getTime()
|
||||
const ROUTE_SUSPENSE_TIMEOUT_MS = 60_000
|
||||
const credentials = ref()
|
||||
const sidebarToggled = ref(true)
|
||||
const unsubscribeSidebarToggle = themeStore.$subscribe(() => {
|
||||
@@ -155,6 +156,22 @@ const forceSidebar = computed(
|
||||
() => route.path.startsWith('/browse') || route.path.startsWith('/project'),
|
||||
)
|
||||
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 prideFundraiserEnabled = computed(
|
||||
() => themeStore.getFeatureFlag('pride_fundraiser') && Date.now() < PRIDE_FUNDRAISER_END_DATE,
|
||||
@@ -495,6 +512,11 @@ const sidebarOverlayScrollbarsOptions = Object.freeze({
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach(async (to) => {
|
||||
const redirect = await resolveLegacyServerInstanceTabRedirect(to)
|
||||
if (redirect) return redirect
|
||||
})
|
||||
|
||||
router.beforeEach(() => {
|
||||
suspensePending = false
|
||||
if (routerToken) loading.end(routerToken)
|
||||
@@ -526,6 +548,50 @@ function onSuspensePending() {
|
||||
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() {
|
||||
if (suspenseToken) {
|
||||
loading.end(suspenseToken)
|
||||
@@ -1624,11 +1690,17 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
>
|
||||
{{ formatMessage(messages.authUnreachableBody) }}
|
||||
</Admonition>
|
||||
<RouterView v-slot="{ Component }">
|
||||
<RouterView v-slot="{ Component, route: viewRoute }">
|
||||
<template v-if="Component">
|
||||
<Suspense @pending="onSuspensePending" @resolve="onSuspenseResolve">
|
||||
<component :is="Component"></component>
|
||||
</Suspense>
|
||||
<KeepAlive :include="keepAliveRouteComponents" :max="3">
|
||||
<Suspense
|
||||
:timeout="ROUTE_SUSPENSE_TIMEOUT_MS"
|
||||
@pending="onSuspensePending"
|
||||
@resolve="onSuspenseResolve"
|
||||
>
|
||||
<component :is="Component" :key="getRouteViewKey(viewRoute)"></component>
|
||||
</Suspense>
|
||||
</KeepAlive>
|
||||
</template>
|
||||
</RouterView>
|
||||
</div>
|
||||
|
||||
@@ -8,13 +8,18 @@ import type { ComputedRef, Ref } from 'vue'
|
||||
import { onUnmounted, ref, shallowRef } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { process_listener } from '@/helpers/events'
|
||||
import { kill, list as listInstances } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { add_server_to_instance, getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
|
||||
|
||||
interface BrowseServerInstance {
|
||||
id: string
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
@@ -68,12 +73,10 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
const queryClient = useQueryClient()
|
||||
const debugLog = useDebugLogger('BrowseServer')
|
||||
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 lastServerHits = shallowRef<Labrinth.Search.v3.ResultSearchProject[]>([])
|
||||
const contextMenuRef = ref<ContextMenuHandle | null>(null)
|
||||
let serverPingCacheActive = true
|
||||
let serverPingsActive = true
|
||||
let unlistenProcesses: (() => void) | null = null
|
||||
|
||||
async function checkServerRunningStates(hits: Labrinth.Search.v3.ResultSearchProject[]) {
|
||||
@@ -146,37 +149,26 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
})
|
||||
const nextPings = { ...serverPings.value }
|
||||
for (const { hit, address } of pingsToFetch) {
|
||||
if (serverPingCache.has(address)) {
|
||||
nextPings[hit.project_id] = serverPingCache.get(address)
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
|
||||
if (cachedStatus) {
|
||||
nextPings[hit.project_id] = cachedStatus.ping
|
||||
}
|
||||
}
|
||||
serverPings.value = nextPings
|
||||
|
||||
await Promise.all(
|
||||
pingsToFetch.map(async ({ hit, address }) => {
|
||||
if (serverPingCache.has(address)) return
|
||||
if (getFreshCachedServerStatus(queryClient, address)) return
|
||||
|
||||
let pending = pendingServerPings.get(address)
|
||||
if (!pending) {
|
||||
pending = getServerLatency(address)
|
||||
.then((latency) => {
|
||||
if (serverPingCacheActive) serverPingCache.set(address, latency)
|
||||
return latency
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (serverPingCacheActive) serverPingCache.set(address, undefined)
|
||||
return undefined
|
||||
})
|
||||
.finally(() => {
|
||||
pendingServerPings.delete(address)
|
||||
})
|
||||
pendingServerPings.set(address, pending)
|
||||
try {
|
||||
const status = await fetchCachedServerStatus(queryClient, address)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: status.ping }
|
||||
} catch (error) {
|
||||
console.error(`Failed to ping server ${address}:`, error)
|
||||
if (!serverPingsActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: undefined }
|
||||
}
|
||||
|
||||
const latency = await pending
|
||||
if (!serverPingCacheActive) return
|
||||
serverPings.value = { ...serverPings.value, [hit.project_id]: latency }
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -308,10 +300,8 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
.catch(options.handleError)
|
||||
|
||||
onUnmounted(() => {
|
||||
serverPingCacheActive = false
|
||||
serverPingsActive = false
|
||||
unlistenProcesses?.()
|
||||
serverPingCache.clear()
|
||||
pendingServerPings.clear()
|
||||
})
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -191,9 +191,18 @@
|
||||
"app.browse.server.installing": {
|
||||
"message": "Installing"
|
||||
},
|
||||
"app.browse.server.world-fallback-name": {
|
||||
"message": "Instance"
|
||||
},
|
||||
"app.content-install.no-compatible-versions": {
|
||||
"message": "No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically."
|
||||
},
|
||||
"app.creation-modal.installing-modpack.description": {
|
||||
"message": "{fileName}"
|
||||
},
|
||||
"app.creation-modal.installing-modpack.title": {
|
||||
"message": "Installing modpack..."
|
||||
},
|
||||
"app.export-modal.description-placeholder": {
|
||||
"message": "Enter modpack description..."
|
||||
},
|
||||
@@ -422,6 +431,9 @@
|
||||
"app.project.install-context.install-content-to-instance": {
|
||||
"message": "Install content to instance"
|
||||
},
|
||||
"app.project.install-context.world-fallback-name": {
|
||||
"message": "Instance"
|
||||
},
|
||||
"app.settings.developer-mode-enabled": {
|
||||
"message": "Developer mode enabled."
|
||||
},
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import type { BrowseInstallContentType, CardAction, ProjectType, Tags } from '@modrinth/ui'
|
||||
import {
|
||||
BrowseInstallHeader,
|
||||
BrowsePageLayout,
|
||||
BrowseSidebar,
|
||||
commonMessages,
|
||||
@@ -22,8 +23,11 @@ import {
|
||||
preferencesDiffer,
|
||||
provideBrowseManager,
|
||||
requestInstall,
|
||||
SelectedProjectsFloatingBar,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useBrowseSearch,
|
||||
useDebugLogger,
|
||||
useStickyObserver,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
@@ -58,19 +62,26 @@ import {
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
defineOptions({
|
||||
name: 'Browse',
|
||||
})
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const { installingServerProjects, playServerProject, showAddServerToInstanceModal } =
|
||||
injectServerInstall()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const queryClient = useQueryClient()
|
||||
const debugLog = useDebugLogger('Browse')
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const themeStore = useTheming()
|
||||
const browseRouteActive = computed(() => route.path.startsWith('/browse/'))
|
||||
const serverSetupModalRef = ref<InstanceType<typeof CreationFlowModal> | null>(null)
|
||||
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
|
||||
const serverInstallContent = createServerInstallContent({
|
||||
serverSetupModalRef,
|
||||
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/browse/'),
|
||||
})
|
||||
provideServerInstallContent(serverInstallContent)
|
||||
const {
|
||||
serverIdQuery,
|
||||
@@ -80,6 +91,10 @@ const {
|
||||
isSetupServerContext,
|
||||
effectiveServerWorldId,
|
||||
serverContextServerData,
|
||||
serverContextWorldName,
|
||||
serverContextWorldGameVersion,
|
||||
serverContextWorldLoader,
|
||||
serverContextWorldLoaderVersion,
|
||||
serverContentProjectIds,
|
||||
queuedServerInstallProjectIds,
|
||||
queuedServerInstallCount,
|
||||
@@ -106,8 +121,6 @@ const {
|
||||
handleServerModpackFlowCreate,
|
||||
markServerProjectInstalled,
|
||||
} = serverInstallContent
|
||||
|
||||
debugLog('fetching tags (categories, loaders, gameVersions)')
|
||||
const [categories, loaders, availableGameVersions] = await Promise.all([
|
||||
get_categories()
|
||||
.catch(handleError)
|
||||
@@ -127,6 +140,7 @@ const tags: Ref<Tags> = computed(() => ({
|
||||
}))
|
||||
|
||||
type Instance = {
|
||||
id: string
|
||||
game_version: string
|
||||
loader: string
|
||||
path: string
|
||||
@@ -194,7 +208,6 @@ async function refreshInstalledProjectIds() {
|
||||
const serverProjectIds = worlds
|
||||
.filter((w) => w.type === 'server' && 'project_id' in w && w.project_id)
|
||||
.map((w) => (w as { project_id: string }).project_id)
|
||||
debugLog('installedServerProjectIds loaded', { count: serverProjectIds.length })
|
||||
installedProjectIds.value = serverProjectIds
|
||||
return
|
||||
}
|
||||
@@ -202,45 +215,29 @@ async function refreshInstalledProjectIds() {
|
||||
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
|
||||
if (!ids) return
|
||||
|
||||
debugLog('installedProjectIds loaded', { count: ids.length })
|
||||
installedProjectIds.value = ids
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
if (route.query.i) {
|
||||
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,
|
||||
})
|
||||
|
||||
await refreshInstalledProjectIds()
|
||||
|
||||
if (instance.value?.link?.project_id) {
|
||||
debugLog('checking linked project for server status', instance.value.link.project_id)
|
||||
const projectV3 = await get_project_v3(
|
||||
instance.value.link.project_id,
|
||||
'must_revalidate',
|
||||
).catch(handleError)
|
||||
if (projectV3?.minecraft_server != null) {
|
||||
debugLog('instance is a server instance')
|
||||
isServerInstance.value = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (route.query.ai && !(route.params.projectType === 'modpack')) {
|
||||
debugLog('setting instanceHideInstalled from query', route.query.ai)
|
||||
instanceHideInstalled.value = route.query.ai === 'true'
|
||||
}
|
||||
}
|
||||
@@ -291,7 +288,7 @@ function syncHiddenServerContentProjectIds() {
|
||||
watch(
|
||||
serverContentProjectIds,
|
||||
() => {
|
||||
if (!hiddenServerContentProjectIdsInitialized.value) {
|
||||
if (!hiddenServerContentProjectIdsInitialized.value || serverHideInstalled.value) {
|
||||
syncHiddenServerContentProjectIds()
|
||||
}
|
||||
},
|
||||
@@ -304,10 +301,10 @@ const serverContextFilters = computed(() => {
|
||||
const pt = projectType.value
|
||||
|
||||
if (pt !== 'modpack') {
|
||||
const gameVersion = serverContextServerData.value.mc_version
|
||||
const gameVersion = serverContextWorldGameVersion.value
|
||||
if (gameVersion) filters.push({ type: 'game_version', option: gameVersion })
|
||||
|
||||
const platform = serverContextServerData.value.loader?.toLowerCase()
|
||||
const platform = serverContextWorldLoader.value?.toLowerCase().replaceAll('_', '')
|
||||
if (platform && ['fabric', 'forge', 'quilt', 'neoforge'].includes(platform))
|
||||
filters.push({ type: 'mod_loader', option: platform })
|
||||
if (platform && ['paper', 'purpur'].includes(platform))
|
||||
@@ -342,6 +339,14 @@ const combinedProvidedFilters = computed(() =>
|
||||
isServerContext.value ? serverContextFilters.value : instanceFilters.value,
|
||||
)
|
||||
|
||||
const serverContentProjectType = computed<ProjectType | null>(() => {
|
||||
const loader = serverContextWorldLoader.value?.toLowerCase()
|
||||
if (!loader) return null
|
||||
if (loader === 'paper' || loader === 'purpur') return 'plugin'
|
||||
if (loader === 'vanilla') return 'datapack'
|
||||
return 'mod'
|
||||
})
|
||||
|
||||
const {
|
||||
serverPings,
|
||||
contextMenuRef,
|
||||
@@ -364,11 +369,9 @@ const {
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
debugLog('went offline')
|
||||
offline.value = true
|
||||
})
|
||||
window.addEventListener('online', () => {
|
||||
debugLog('went online')
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
@@ -413,6 +416,10 @@ const messages = defineMessages({
|
||||
id: 'app.browse.back-to-instance',
|
||||
defaultMessage: 'Back to instance',
|
||||
},
|
||||
worldFallbackName: {
|
||||
id: 'app.browse.server.world-fallback-name',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
serverInstanceContentWarning: {
|
||||
id: 'app.browse.server-instance-content-warning',
|
||||
defaultMessage:
|
||||
@@ -472,7 +479,6 @@ const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
|
||||
function resetInstanceContext() {
|
||||
if (!instance.value) return
|
||||
|
||||
debugLog('instance context removed, resetting')
|
||||
instance.value = null
|
||||
installedProjectIds.value = null
|
||||
instanceHideInstalled.value = false
|
||||
@@ -484,9 +490,33 @@ function resetInstanceContext() {
|
||||
breadcrumbs.setContext(null)
|
||||
}
|
||||
|
||||
watch(
|
||||
[isServerContext, isSetupServerContext, projectType, serverContentProjectType],
|
||||
([serverContext, setupServerContext, currentProjectType, targetProjectType]) => {
|
||||
if (!serverContext || setupServerContext || !targetProjectType) return
|
||||
if (!['mod', 'plugin', 'datapack'].includes(currentProjectType)) return
|
||||
if (currentProjectType === targetProjectType) return
|
||||
|
||||
router.replace({
|
||||
path: `/browse/${targetProjectType}`,
|
||||
query: {
|
||||
sid: route.query.sid,
|
||||
wid: route.query.wid,
|
||||
shi: route.query.shi,
|
||||
from: route.query.from,
|
||||
q: route.query.q,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(
|
||||
() => route.params.projectType as ProjectType,
|
||||
async (newType) => {
|
||||
if (!browseRouteActive.value) {
|
||||
return
|
||||
}
|
||||
if (isSetupServerContext.value) {
|
||||
enforceSetupModpackRoute(newType)
|
||||
if (newType !== 'modpack') return
|
||||
@@ -494,7 +524,6 @@ watch(
|
||||
|
||||
if (!newType || newType === projectType.value) return
|
||||
|
||||
debugLog('projectType route param changed', { from: projectType.value, to: newType })
|
||||
projectType.value = newType
|
||||
},
|
||||
)
|
||||
@@ -566,9 +595,10 @@ const selectableProjectTypes = computed(() => {
|
||||
const installContext = computed(() => {
|
||||
if (isServerContext.value && serverContextServerData.value) {
|
||||
return {
|
||||
name: serverContextServerData.value.name,
|
||||
loader: serverContextServerData.value.loader ?? '',
|
||||
gameVersion: serverContextServerData.value.mc_version ?? '',
|
||||
name: serverContextWorldName.value ?? formatMessage(messages.worldFallbackName),
|
||||
loader: serverContextWorldLoader.value ?? '',
|
||||
loaderVersion: serverContextWorldLoaderVersion.value ?? '',
|
||||
gameVersion: serverContextWorldGameVersion.value ?? '',
|
||||
serverId: serverIdQuery.value,
|
||||
upstream: serverContextServerData.value.upstream,
|
||||
iconSrc: null as string | null,
|
||||
@@ -608,6 +638,12 @@ const installContext = computed(() => {
|
||||
return null
|
||||
})
|
||||
|
||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||
stickyInstallHeaderRef,
|
||||
'BrowseInstallHeader',
|
||||
)
|
||||
|
||||
const installingProjectIds = ref<Set<string>>(new Set())
|
||||
|
||||
function setProjectInstalling(projectId: string, installing: boolean) {
|
||||
@@ -637,8 +673,8 @@ function getCurrentSelectedInstallPreferences(projectTypeValue: string) {
|
||||
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
|
||||
return getTargetInstallPreferences(
|
||||
{
|
||||
gameVersion: serverContextServerData.value?.mc_version,
|
||||
loader: serverContextServerData.value?.loader,
|
||||
gameVersion: serverContextWorldGameVersion.value,
|
||||
loader: serverContextWorldLoader.value,
|
||||
},
|
||||
contentType,
|
||||
)
|
||||
@@ -680,7 +716,6 @@ async function chooseInstanceInstallVersion(
|
||||
const selectedVersion = getLatestMatchingInstallVersion(
|
||||
await getInstallProjectVersions(project.project_id),
|
||||
selectedPreferences,
|
||||
projectTypeValue,
|
||||
)
|
||||
|
||||
if (!selectedVersion) {
|
||||
@@ -704,11 +739,10 @@ function getCardActions(
|
||||
installed?: boolean
|
||||
installing?: boolean
|
||||
}
|
||||
const isInstalled =
|
||||
projectResult.installed ||
|
||||
allInstalledIds.value.has(projectResult.project_id || '') ||
|
||||
serverContentProjectIds.value.has(projectResult.project_id || '') ||
|
||||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
|
||||
const isInstalled = isServerContext.value
|
||||
? serverContentProjectIds.value.has(projectResult.project_id || '') ||
|
||||
serverContextServerData.value?.upstream?.project_id === projectResult.project_id
|
||||
: projectResult.installed || allInstalledIds.value.has(projectResult.project_id || '')
|
||||
const isInstalling = installingProjectIds.value.has(projectResult.project_id)
|
||||
|
||||
if (
|
||||
@@ -763,11 +797,15 @@ function getCardActions(
|
||||
project: projectResult,
|
||||
contentType,
|
||||
mode: isModpack ? 'immediate' : 'queue',
|
||||
selectedFilters: isModpack ? [] : searchState.currentFilters.value,
|
||||
selectedFilters: isModpack
|
||||
? []
|
||||
: stripServerRuntimeInstallFilters(searchState.currentFilters.value),
|
||||
providedFilters: isModpack ? [] : combinedProvidedFilters.value,
|
||||
overriddenProvidedFilterTypes: isModpack
|
||||
? []
|
||||
: searchState.overriddenProvidedFilterTypes.value,
|
||||
: stripServerRuntimeInstallOverrides(
|
||||
searchState.overriddenProvidedFilterTypes.value,
|
||||
),
|
||||
targetPreferences: getServerInstallTargetPreferences(contentType),
|
||||
getProjectVersions: getInstallProjectVersions,
|
||||
queue: serverInstallQueue,
|
||||
@@ -872,7 +910,6 @@ function onSearchResultsInstalled(ids: string[]) {
|
||||
}
|
||||
|
||||
async function search(requestParams: string) {
|
||||
debugLog('searching v3', requestParams)
|
||||
const isServer = projectType.value === 'server'
|
||||
|
||||
const rawResults = await queryClient.fetchQuery({
|
||||
@@ -954,6 +991,7 @@ const lockedFilterMessages = computed(() => ({
|
||||
const searchState = useBrowseSearch({
|
||||
projectType,
|
||||
tags,
|
||||
active: browseRouteActive,
|
||||
providedFilters: combinedProvidedFilters,
|
||||
search,
|
||||
persistentQueryParams: ['i', 'ai', 'shi', 'sid', 'wid', 'from'],
|
||||
@@ -1029,7 +1067,12 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
function getProjectBrowseQuery() {
|
||||
if (!installContext.value) return undefined
|
||||
if (!browseRouteActive.value) {
|
||||
return undefined
|
||||
}
|
||||
if (!installContext.value) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...route.query,
|
||||
b: route.fullPath,
|
||||
@@ -1093,6 +1136,17 @@ provideBrowseManager({
|
||||
|
||||
<template>
|
||||
<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>
|
||||
<template #after>
|
||||
<ContextMenu ref="contextMenuRef" @option-clicked="handleOptionsClick">
|
||||
@@ -1119,7 +1173,8 @@ provideBrowseManager({
|
||||
@browse-modpacks="() => {}"
|
||||
@create="handleServerModpackFlowCreate"
|
||||
/>
|
||||
<Teleport to="#sidebar-teleport-target">
|
||||
|
||||
<Teleport v-if="browseRouteActive" to="#sidebar-teleport-target">
|
||||
<BrowseSidebar />
|
||||
</Teleport>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ const queryClient = useQueryClient()
|
||||
if (worldId.value) {
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['backups', 'list', serverId],
|
||||
queryKey: ['backups', 'list', serverId, worldId.value],
|
||||
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
@@ -13,7 +13,7 @@ const queryClient = useQueryClient()
|
||||
if (worldId.value) {
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['content', 'list', 'v1', serverId],
|
||||
queryKey: ['content', 'list', 'v1', serverId, worldId.value],
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId.value!, { from_modpack: false }),
|
||||
staleTime: 30_000,
|
||||
|
||||
@@ -7,15 +7,17 @@ import {
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
const { worldId } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['files', serverId, '/'],
|
||||
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
if (worldId.value) {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['files', 'v1', worldId.value, '/'],
|
||||
queryFn: () => client.kyros.files_v1.listDescendants(worldId.value!, '/', 1, 200),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
:server-id="serverId"
|
||||
:reload-page="() => router.go(0)"
|
||||
:resolve-viewer="resolveViewer"
|
||||
:show-copy-id-action="themeStore.devMode"
|
||||
:auth-user="authUser"
|
||||
:navigate-to-billing="() => openUrl('https://modrinth.com/settings/billing')"
|
||||
:navigate-to-servers="() => router.push('/hosting/manage')"
|
||||
@@ -26,7 +25,7 @@
|
||||
"
|
||||
>
|
||||
<template #default="{ onReinstall, onReinstallFailed }">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<RouterView v-slot="{ Component }" :route="managedRoute">
|
||||
<template v-if="Component">
|
||||
<Suspense>
|
||||
<component
|
||||
@@ -47,26 +46,37 @@ import type { Archon, Labrinth } from '@modrinth/api-client'
|
||||
import { injectAuth, injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
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 { get_user } from '@/helpers/cache'
|
||||
import { get as getCreds } from '@/helpers/mr_auth'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/theme'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = injectAuth()
|
||||
const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
const themeStore = useTheming()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
|
||||
const serverId = computed(() => {
|
||||
const rawId = route.params.id
|
||||
return Array.isArray(rawId) ? rawId[0] : (rawId ?? '')
|
||||
})
|
||||
const managedRoute = shallowRef(router.currentRoute.value)
|
||||
const serverId = ref(getRouteParam(managedRoute.value.params.id) ?? '')
|
||||
|
||||
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) {
|
||||
try {
|
||||
@@ -93,7 +103,7 @@ watch(
|
||||
breadcrumbs.setName('Server', server.name)
|
||||
breadcrumbs.setContext({
|
||||
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>
|
||||
@@ -3,6 +3,8 @@ import Backups from './Backups.vue'
|
||||
import Content from './Content.vue'
|
||||
import Files from './Files.vue'
|
||||
import Index from './Index.vue'
|
||||
import Instance from './Instance.vue'
|
||||
import Instances from './Instances.vue'
|
||||
import Overview from './Overview.vue'
|
||||
|
||||
export { Access, Backups, Content, Files, Index, Overview }
|
||||
export { Access, Backups, Content, Files, Index, Instance, Instances, Overview }
|
||||
|
||||
@@ -13,207 +13,57 @@
|
||||
@unlinked="fetchInstance"
|
||||
/>
|
||||
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar
|
||||
:src="icon ? icon : undefined"
|
||||
:alt="instance.name"
|
||||
size="64px"
|
||||
:tint-by="instance.id"
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
{{ instance.name }}
|
||||
</template>
|
||||
<template #stats>
|
||||
<PageHeader
|
||||
:header="instance.name"
|
||||
:leading="instanceHeaderLeading"
|
||||
:metadata="instanceHeaderMetadata"
|
||||
:actions="instanceHeaderActions"
|
||||
>
|
||||
<template #metadata-server-details>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<template v-if="!isServerInstance">
|
||||
<div class="flex items-center gap-2 capitalize font-medium">
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</div>
|
||||
|
||||
<template v-if="showInstancePlayTime">
|
||||
<div class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div class="flex items-center gap-2 font-medium">
|
||||
<template v-if="timePlayed > 0">
|
||||
{{ timePlayedHumanized }}
|
||||
</template>
|
||||
<template v-else> Never played </template>
|
||||
</div>
|
||||
</template>
|
||||
</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" />
|
||||
|
||||
<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="minecraftServer?.region || ping"
|
||||
v-if="
|
||||
(playersOnline !== undefined || recentPlays !== undefined) &&
|
||||
(minecraftServer?.region || ping)
|
||||
"
|
||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
|
||||
<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.id"
|
||||
size="24px"
|
||||
/>
|
||||
<router-link
|
||||
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
||||
class="hover:underline text-primary truncate"
|
||||
>
|
||||
{{ linkedProjectV3.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
<ServerPing v-if="ping" :ping="ping" />
|
||||
</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>
|
||||
<PlayIcon />
|
||||
Join server
|
||||
</template>
|
||||
<template #launch_instance>
|
||||
<PlayIcon />
|
||||
Launch instance
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<ButtonStyled
|
||||
v-else-if="loading === true && playing === false"
|
||||
color="brand"
|
||||
size="large"
|
||||
>
|
||||
<button disabled>Starting...</button>
|
||||
</ButtonStyled>
|
||||
<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) showInstanceInFolder(instance.id)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
action: () => exportModal?.show(),
|
||||
},
|
||||
{
|
||||
id: 'create-shortcut',
|
||||
action: () => createShortcut(),
|
||||
},
|
||||
]"
|
||||
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
|
||||
|
||||
<div
|
||||
v-if="minecraftServer?.region || ping"
|
||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
|
||||
<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.id"
|
||||
size="24px"
|
||||
/>
|
||||
<router-link
|
||||
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
||||
class="hover:underline text-primary truncate"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
<template #share-instance> <UserPlusIcon /> Share instance </template>
|
||||
<template #host-a-server> <ServerIcon /> Create a server </template>
|
||||
<template #open-folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #export-mrpack> <PackageIcon /> Export modpack </template>
|
||||
<template #create-shortcut> <ExternalIcon /> Create shortcut </template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
{{ linkedProjectV3.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</PageHeader>
|
||||
</div>
|
||||
<div :class="['px-6', { 'shrink-0': isFixedRender }]">
|
||||
<NavTabs :links="tabs" />
|
||||
@@ -273,7 +123,6 @@ import {
|
||||
CheckCircleIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
@@ -284,39 +133,44 @@ import {
|
||||
PackageIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ServerIcon,
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TagCategoryGamepad2Icon as Gamepad2Icon,
|
||||
TerminalSquareIcon,
|
||||
TimerIcon,
|
||||
UpdatedIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
ContentPageHeader,
|
||||
formatLoaderLabel,
|
||||
injectNotificationManager,
|
||||
LoaderIcon as ServerLoaderIcon,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
PageHeader,
|
||||
ServerOnlinePlayers,
|
||||
ServerPing,
|
||||
ServerRecentPlays,
|
||||
ServerRegion,
|
||||
useLoadingBarToken,
|
||||
} from '@modrinth/ui'
|
||||
import type { Loaders } from '@modrinth/utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import dayjs from 'dayjs'
|
||||
import duration from 'dayjs/plugin/duration'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } 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 ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.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 { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
@@ -327,7 +181,7 @@ import { type InstanceContentData, loadInstanceContentData } from '@/helpers/ins
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { createInstanceShortcut, showInstanceInFolder } 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 { handleSevereError } from '@/store/error.js'
|
||||
import { useBreadcrumbs, useTheming } from '@/store/state'
|
||||
@@ -372,13 +226,16 @@ const selected = ref<unknown[]>([])
|
||||
|
||||
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
|
||||
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(
|
||||
() => linkedProjectV3.value?.minecraft_java_server?.verified_plays_2w ?? undefined,
|
||||
)
|
||||
const playersOnline = ref<number | undefined>(undefined)
|
||||
const ping = ref<number | undefined>(undefined)
|
||||
const loadingServerPing = ref(false)
|
||||
const activeInstanceId = ref<string>()
|
||||
let fetchInstanceRequestId = 0
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
@@ -390,24 +247,68 @@ watch(
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
|
||||
type InstanceRouteContext = {
|
||||
name: unknown
|
||||
path: string
|
||||
query: LocationQuery
|
||||
}
|
||||
|
||||
type InstancePageData = {
|
||||
instanceId: 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 = displayedInstanceRoute.value.name) {
|
||||
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
|
||||
}
|
||||
|
||||
async function fetchInstance() {
|
||||
isServerInstance.value = false
|
||||
linkedProjectV3.value = undefined
|
||||
preloadedContent.value = null
|
||||
function resetServerStatus() {
|
||||
ping.value = undefined
|
||||
playersOnline.value = undefined
|
||||
liveServerStatusOnline.value = false
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
const nextInstance = await get(route.params.id as string).catch(handleError)
|
||||
function isCurrentInstanceRequest(requestId: number, instanceId: string) {
|
||||
return (
|
||||
requestId === fetchInstanceRequestId &&
|
||||
route.path.startsWith('/instance') &&
|
||||
route.params.id === instanceId
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
instanceId: string,
|
||||
routeName: unknown = displayedInstanceRoute.value.name,
|
||||
): Promise<InstancePageData> {
|
||||
const nextInstance = await get(instanceId).catch(handleError)
|
||||
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
||||
let nextIsServerInstance = false
|
||||
|
||||
const contentPreloadPromise =
|
||||
nextInstance && isContentSubpageRoute()
|
||||
nextInstance && isContentSubpageRoute(routeName)
|
||||
? loadInstanceContentData(nextInstance.id, undefined, handleError)
|
||||
: Promise.resolve(null)
|
||||
|
||||
@@ -425,59 +326,113 @@ async function fetchInstance() {
|
||||
|
||||
const nextPreloadedContent = await contentPreloadPromise
|
||||
|
||||
instance.value = nextInstance ?? undefined
|
||||
linkedProjectV3.value = nextLinkedProjectV3
|
||||
isServerInstance.value = nextIsServerInstance
|
||||
preloadedContent.value = nextPreloadedContent
|
||||
return {
|
||||
instanceId,
|
||||
instance: nextInstance ?? undefined,
|
||||
linkedProjectV3: nextLinkedProjectV3,
|
||||
isServerInstance: nextIsServerInstance,
|
||||
preloadedContent: nextPreloadedContent,
|
||||
}
|
||||
}
|
||||
|
||||
fetchDeferredData()
|
||||
function applyInstancePageData(data: InstancePageData, routeContext: InstanceRouteContext) {
|
||||
activeInstanceId.value = data.instanceId
|
||||
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.instanceId)
|
||||
|
||||
if (data.instance) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['worlds', nextInstance.id],
|
||||
queryFn: () => refreshWorlds(nextInstance.id),
|
||||
queryKey: ['worlds', data.instance.id],
|
||||
queryFn: () => refreshWorlds(data.instance!.id),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDeferredData() {
|
||||
async function fetchInstance(instanceId = route.params.id as string) {
|
||||
const requestId = ++fetchInstanceRequestId
|
||||
const data = await loadInstancePageData(instanceId)
|
||||
if (!isCurrentInstanceRequest(requestId, instanceId)) return
|
||||
|
||||
applyInstancePageData(data, {
|
||||
name: route.name,
|
||||
path: route.path,
|
||||
query: route.query,
|
||||
})
|
||||
}
|
||||
|
||||
function fetchDeferredData(instanceId: string) {
|
||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||
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) => {
|
||||
playersOnline.value = status.players?.online
|
||||
ping.value = status.ping
|
||||
if (
|
||||
activeInstanceId.value !== instanceId ||
|
||||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
|
||||
)
|
||||
return
|
||||
applyServerStatus(status)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeInstanceId.value !== instanceId) return
|
||||
loadingServerPing.value = true
|
||||
})
|
||||
} else {
|
||||
loadingServerPing.value = true
|
||||
}
|
||||
|
||||
updatePlayState()
|
||||
updatePlayState(instanceId)
|
||||
}
|
||||
|
||||
async function updatePlayState() {
|
||||
if (!route.params.id) return
|
||||
const runningProcesses = await get_by_instance_id(route.params.id as string).catch(handleError)
|
||||
async function updatePlayState(instanceId = route.params.id as string) {
|
||||
if (!instanceId) return
|
||||
const runningProcesses = await get_by_instance_id(instanceId).catch(handleError)
|
||||
if (activeInstanceId.value !== instanceId) return
|
||||
|
||||
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
|
||||
}
|
||||
|
||||
await fetchInstance()
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
if (route.params.id && route.path.startsWith('/instance')) {
|
||||
await fetchInstance()
|
||||
}
|
||||
},
|
||||
)
|
||||
await fetchInstance(route.params.id as string)
|
||||
|
||||
onBeforeRouteUpdate(async (to) => {
|
||||
if (!to.path.startsWith('/instance')) return
|
||||
const instanceId = Array.isArray(to.params.id) ? to.params.id[0] : to.params.id
|
||||
if (typeof instanceId !== 'string') return false
|
||||
|
||||
const requestId = ++fetchInstanceRequestId
|
||||
const data = await loadInstancePageData(instanceId, to.name)
|
||||
if (requestId !== fetchInstanceRequestId) return false
|
||||
|
||||
displayedInstanceRoute.value = to
|
||||
applyInstancePageData(data, {
|
||||
name: to.name,
|
||||
path: to.path,
|
||||
query: to.query,
|
||||
})
|
||||
})
|
||||
|
||||
const basePath = computed(
|
||||
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
|
||||
@@ -522,20 +477,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: displayedInstanceRoute.value.path,
|
||||
query: displayedInstanceRoute.value.query,
|
||||
})
|
||||
}
|
||||
|
||||
const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
|
||||
const startInstance = async (context: string) => {
|
||||
@@ -725,6 +666,16 @@ const timePlayed = computed(() => {
|
||||
: 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 duration = dayjs.duration(timePlayed.value, 'seconds')
|
||||
const hours = Math.floor(duration.asHours())
|
||||
@@ -741,6 +692,195 @@ const timePlayedHumanized = computed(() => {
|
||||
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?.id,
|
||||
}))
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
...(showInstancePlayTime.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 showInstanceInFolder(instance.value.id)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
label: 'Export modpack',
|
||||
icon: PackageIcon,
|
||||
action: () => exportModal.value?.show(),
|
||||
},
|
||||
{
|
||||
id: 'create-shortcut',
|
||||
label: 'Create shortcut',
|
||||
icon: ExternalIcon,
|
||||
action: () => {
|
||||
void createShortcut()
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
unlistenInstances()
|
||||
|
||||
@@ -47,9 +47,9 @@
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<div
|
||||
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>
|
||||
<InstanceIndicator v-if="instance && !projectInstallContext" :instance="instance" />
|
||||
<template v-if="data">
|
||||
@@ -64,121 +64,9 @@
|
||||
:project="data"
|
||||
:project-v3="projectV3"
|
||||
:ping="serverPing"
|
||||
:actions="projectHeaderActions"
|
||||
@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
|
||||
:links="[
|
||||
{
|
||||
@@ -266,14 +154,12 @@ import {
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
BrowseInstallHeader,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
CreationFlowModal,
|
||||
defineMessages,
|
||||
getTargetInstallPreferences,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectHeader,
|
||||
ProjectSidebarCompatibility,
|
||||
@@ -286,6 +172,7 @@ import {
|
||||
SelectedProjectsFloatingBar,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -295,6 +182,10 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import InstanceIndicator from '@/components/ui/InstanceIndicator.vue'
|
||||
import {
|
||||
fetchCachedServerStatus,
|
||||
getFreshCachedServerStatus,
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import {
|
||||
get_organization,
|
||||
get_project,
|
||||
@@ -313,7 +204,7 @@ import {
|
||||
import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { getServerAddress, getServerLatency } from '@/helpers/worlds'
|
||||
import { getServerAddress } from '@/helpers/worlds'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { createServerInstallContent } from '@/providers/setup/server-install-content'
|
||||
@@ -326,6 +217,7 @@ const { handleError } = injectNotificationManager()
|
||||
const { install: installVersion } = injectContentInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const breadcrumbs = useBreadcrumbs()
|
||||
const themeStore = useTheming()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -339,6 +231,10 @@ const messages = defineMessages({
|
||||
id: 'app.project.install-context.install-content-to-instance',
|
||||
defaultMessage: 'Install content to instance',
|
||||
},
|
||||
worldFallbackName: {
|
||||
id: 'app.project.install-context.world-fallback-name',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
alreadyInstalled: {
|
||||
id: 'app.project.install-button.already-installed',
|
||||
defaultMessage: 'This project is already installed',
|
||||
@@ -369,7 +265,10 @@ const serverStatusOnline = ref(false)
|
||||
const serverInstancePath = ref(null)
|
||||
const serverPlaying = ref(false)
|
||||
const serverSetupModalRef = ref(null)
|
||||
const serverInstallContent = createServerInstallContent({ serverSetupModalRef })
|
||||
const serverInstallContent = createServerInstallContent({
|
||||
serverSetupModalRef,
|
||||
isRouteInContext: (targetRoute) => targetRoute.path.startsWith('/project/'),
|
||||
})
|
||||
|
||||
serverInstallContent.watchServerContextChanges()
|
||||
await serverInstallContent.initServerContext()
|
||||
@@ -436,7 +335,9 @@ const projectInstallContext = computed(() => {
|
||||
const serverData = serverInstallContent.serverContextServerData.value
|
||||
if (serverData) {
|
||||
return {
|
||||
name: serverData.name,
|
||||
name:
|
||||
serverInstallContent.serverContextWorldName.value ??
|
||||
formatMessage(messages.worldFallbackName),
|
||||
loader: serverData.loader ?? '',
|
||||
gameVersion: serverData.mc_version ?? '',
|
||||
serverId: serverInstallContent.serverIdQuery.value,
|
||||
@@ -514,6 +415,131 @@ const installButtonTooltip = computed(() => {
|
||||
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([
|
||||
get_loaders().catch(handleError).then(ref),
|
||||
get_game_versions().catch(handleError).then(ref),
|
||||
@@ -600,10 +626,19 @@ async function fetchProjectData() {
|
||||
function fetchDeferredServerData(project) {
|
||||
const serverAddress = projectV3.value?.minecraft_java_server?.address
|
||||
if (serverAddress) {
|
||||
serverPing.value = undefined
|
||||
getServerLatency(serverAddress)
|
||||
.then((latency) => {
|
||||
serverPing.value = latency
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||
if (cachedStatus) {
|
||||
serverPing.value = cachedStatus.ping
|
||||
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) => {
|
||||
console.error(`Failed to ping server ${serverAddress}:`, error)
|
||||
|
||||
@@ -18,7 +18,18 @@ import {
|
||||
writeStoredServerInstallQueue,
|
||||
} from '@modrinth/ui'
|
||||
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'
|
||||
|
||||
type ServerFlowFrom = 'onboarding' | 'reset-server'
|
||||
@@ -53,6 +64,10 @@ export interface ServerInstallContentContext {
|
||||
isSetupServerContext: ComputedRef<boolean>
|
||||
effectiveServerWorldId: ComputedRef<string | null>
|
||||
serverContextServerData: Ref<Archon.Servers.v0.Server | null>
|
||||
serverContextWorldName: ComputedRef<string | null>
|
||||
serverContextWorldGameVersion: ComputedRef<string | null>
|
||||
serverContextWorldLoader: ComputedRef<string | null>
|
||||
serverContextWorldLoaderVersion: ComputedRef<string | null>
|
||||
serverContentProjectIds: Ref<Set<string>>
|
||||
queuedServerInstallProjectIds: ComputedRef<Set<string>>
|
||||
queuedServerInstallCount: ComputedRef<number>
|
||||
@@ -203,6 +218,7 @@ async function getQueuedInstallPlaceholders(
|
||||
|
||||
export function createServerInstallContent(opts: {
|
||||
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
|
||||
isRouteInContext?: (route: RouteLocationNormalizedLoaded) => boolean
|
||||
}) {
|
||||
const { serverSetupModalRef } = opts
|
||||
const route = useRoute()
|
||||
@@ -211,9 +227,22 @@ export function createServerInstallContent(opts: {
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const serverIdQuery = computed(() => readQueryString(route.query.sid))
|
||||
const worldIdQuery = computed(() => readQueryString(route.query.wid))
|
||||
const browseFrom = computed(() => readQueryString(route.query.from))
|
||||
const routeInContext = computed(() => opts.isRouteInContext?.(route) ?? true)
|
||||
const contextQuery = shallowRef(route.query)
|
||||
|
||||
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>(() =>
|
||||
browseFrom.value === 'onboarding' || browseFrom.value === 'reset-server'
|
||||
? browseFrom.value
|
||||
@@ -226,11 +255,13 @@ export function createServerInstallContent(opts: {
|
||||
|
||||
const serverContextWorldId = ref<string | null>(worldIdQuery.value)
|
||||
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 serverContentInstallKeys = ref<Set<string>>(new Set())
|
||||
const queuedServerInstalls = ref<Map<string, BrowseInstallPlan<InstallableSearchResult>>>(
|
||||
new Map(),
|
||||
)
|
||||
const componentActive = ref(true)
|
||||
const queuedServerInstallProjectIds = computed(() => new Set(queuedServerInstalls.value.keys()))
|
||||
const queuedServerInstallCount = computed(() => queuedServerInstalls.value.size)
|
||||
const selectedServerInstallProjects = computed<BrowseSelectedProject[]>(() =>
|
||||
@@ -243,6 +274,33 @@ export function createServerInstallContent(opts: {
|
||||
const isInstallingQueuedServerInstalls = ref(false)
|
||||
const queuedInstallProgress = ref({ completed: 0, total: 0 })
|
||||
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 serverContextWorldGameVersion = computed(() => {
|
||||
const worldGameVersion = serverContextWorld.value?.content?.game_version
|
||||
if (worldIdQuery.value) return worldGameVersion ?? null
|
||||
return worldGameVersion ?? serverContextServerData.value?.mc_version ?? null
|
||||
})
|
||||
const serverContextWorldLoader = computed(() => {
|
||||
const worldLoader = serverContextWorld.value?.content?.modloader
|
||||
if (worldIdQuery.value) return worldLoader ?? null
|
||||
return worldLoader ?? serverContextServerData.value?.loader ?? null
|
||||
})
|
||||
const serverContextWorldLoaderVersion = computed(() => {
|
||||
const worldLoaderVersion = serverContextWorld.value?.content?.modloader_version
|
||||
if (worldIdQuery.value) return worldLoaderVersion ?? null
|
||||
return worldLoaderVersion ?? serverContextServerData.value?.loader_version ?? null
|
||||
})
|
||||
const serverBackUrl = computed(() => {
|
||||
const sid = serverIdQuery.value
|
||||
if (!sid) return '/hosting/manage'
|
||||
@@ -252,7 +310,7 @@ export function createServerInstallContent(opts: {
|
||||
if (serverFlowFrom.value === 'reset-server') {
|
||||
return `/hosting/manage/${sid}?openSettings=installation`
|
||||
}
|
||||
return `/hosting/manage/${sid}/content`
|
||||
return getServerInstanceContentPath(sid, effectiveServerWorldId.value)
|
||||
})
|
||||
const serverBackLabel = computed(() => {
|
||||
if (serverFlowFrom.value === 'onboarding') return 'Back to setup'
|
||||
@@ -266,9 +324,27 @@ export function createServerInstallContent(opts: {
|
||||
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) {
|
||||
try {
|
||||
const server = await client.archon.servers_v1.get(serverId)
|
||||
const server = await getServerContextServerFull(serverId)
|
||||
const activeWorld = server.worlds.find((world) => world.is_active)
|
||||
return activeWorld?.id ?? server.worlds[0]?.id ?? null
|
||||
} catch (err) {
|
||||
@@ -304,6 +380,11 @@ export function createServerInstallContent(opts: {
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
try {
|
||||
await getServerContextServerFull(sid)
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
}
|
||||
|
||||
let resolvedWorldId = effectiveServerWorldId.value
|
||||
if (!resolvedWorldId) {
|
||||
@@ -320,41 +401,79 @@ export function createServerInstallContent(opts: {
|
||||
}
|
||||
|
||||
function watchServerContextChanges() {
|
||||
watch([serverIdQuery, effectiveServerWorldId], async ([sid, wid], [prevSid, prevWid]) => {
|
||||
if (!sid) {
|
||||
serverContextServerData.value = null
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
setQueuedServerInstallPlans(new Map())
|
||||
return
|
||||
}
|
||||
watch(
|
||||
[componentActive, routeInContext, serverIdQuery, effectiveServerWorldId],
|
||||
async ([active, inContext, sid, wid], [prevActive, prevInContext, prevSid, prevWid]) => {
|
||||
if (!active || !inContext) return
|
||||
|
||||
if (sid !== prevSid) {
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||
try {
|
||||
serverContextServerData.value = await client.archon.servers_v0.get(sid)
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
if (!sid) {
|
||||
serverContextServerData.value = null
|
||||
serverContextServerFull.value = null
|
||||
serverContentProjectIds.value = new Set()
|
||||
serverContentInstallKeys.value = new Set()
|
||||
setQueuedServerInstallPlans(new Map())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (wid !== prevWid) {
|
||||
queuedServerInstalls.value = readStoredServerInstallQueue(sid, wid)
|
||||
}
|
||||
const hasServerDataForRoute = serverContextServerData.value?.server_id === sid
|
||||
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)) {
|
||||
await refreshServerInstalledContent(sid, wid)
|
||||
}
|
||||
})
|
||||
if (!hasServerDataForRoute || !hasServerFullForRoute) {
|
||||
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) {
|
||||
if (!isSetupServerContext.value || currentProjectType === 'modpack') return
|
||||
if (!routeInContext.value || !isSetupServerContext.value || currentProjectType === 'modpack')
|
||||
return
|
||||
router.replace({
|
||||
path: '/browse/modpack',
|
||||
query: route.query,
|
||||
query: contextQuery.value,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -424,7 +543,7 @@ export function createServerInstallContent(opts: {
|
||||
if (isInstallingQueuedServerInstalls.value) return false
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -448,6 +567,7 @@ export function createServerInstallContent(opts: {
|
||||
plans.map((plan) => ({
|
||||
project_id: plan.projectId,
|
||||
version_id: plan.versionId,
|
||||
kind: plan.contentType as Archon.Content.v1.AddonKind,
|
||||
})),
|
||||
),
|
||||
onQueueChange: (plans) => setStoredServerInstallPlans(serverId, worldId, plans),
|
||||
@@ -556,7 +676,7 @@ export function createServerInstallContent(opts: {
|
||||
|
||||
if (serverFlowFrom.value === 'onboarding') {
|
||||
await client.archon.servers_v1.endIntro(sid)
|
||||
await router.push(`/hosting/manage/${sid}/content`)
|
||||
await router.push(getServerInstanceContentPath(sid, wid))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -571,6 +691,11 @@ export function createServerInstallContent(opts: {
|
||||
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 {
|
||||
serverIdQuery,
|
||||
worldIdQuery,
|
||||
@@ -581,6 +706,10 @@ export function createServerInstallContent(opts: {
|
||||
isSetupServerContext,
|
||||
effectiveServerWorldId,
|
||||
serverContextServerData,
|
||||
serverContextWorldName,
|
||||
serverContextWorldGameVersion,
|
||||
serverContextWorldLoader,
|
||||
serverContextWorldLoaderVersion,
|
||||
serverContentProjectIds,
|
||||
queuedServerInstallProjectIds,
|
||||
queuedServerInstallCount,
|
||||
|
||||
@@ -6,6 +6,11 @@ import * as Instance from '@/pages/instance'
|
||||
import * as Library from '@/pages/library'
|
||||
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.
|
||||
*/
|
||||
@@ -57,6 +62,48 @@ export default new createRouter({
|
||||
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',
|
||||
name: 'ServerManageFiles',
|
||||
@@ -89,6 +136,17 @@ export default new createRouter({
|
||||
component: Pages.Browse,
|
||||
meta: {
|
||||
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' }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -49,10 +49,10 @@
|
||||
"url": "http://127.0.0.1:8000/*"
|
||||
},
|
||||
{
|
||||
"url": "http://*.taila228c5.ts.net/*"
|
||||
"url": "http://*.tail029726.ts.net/*"
|
||||
},
|
||||
{
|
||||
"url": "https://*.taila228c5.ts.net/*"
|
||||
"url": "https://*.tail029726.ts.net/*"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -115,7 +115,7 @@
|
||||
],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com https://app.getsentry.com wss://*.nodes.modrinth.com https://*.taila228c5.ts.net https://*.taila228c5.ts.net wss://*.taila228c5.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
|
||||
"connect-src": "ipc: http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.nodes.modrinth.com https://*.posthog.com https://posthog.modrinth.com https://*.sentry.io https://api.mclo.gs http://textures.minecraft.net https://textures.minecraft.net https://js.stripe.com https://*.stripe.com wss://*.stripe.com https://*.intercom.io wss://*.intercom.io https://*.intercomcdn.com https://www.intercom-reporting.com https://app.getsentry.com wss://*.nodes.modrinth.com https://*.tail029726.ts.net https://*.tail029726.ts.net wss://*.tail029726.ts.net https://fill.papermc.io https://api.purpurmc.org 'self' data: blob:",
|
||||
"font-src": [
|
||||
"https://cdn-raw.modrinth.com/fonts/",
|
||||
"https://js.intercomcdn.com"
|
||||
|
||||
@@ -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>
|
||||
@@ -54,7 +54,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showModeratorProjectMemberUi: false,
|
||||
showModeratorPrivateMessageHighlight: true,
|
||||
archonApiStaging: false,
|
||||
showHostingAccessInstanceAuditLog: false,
|
||||
versionDevInfoCollapsed: true,
|
||||
alwaysShowVersionDevInfo: false,
|
||||
} as const)
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
readStoredServerInstallQueue,
|
||||
removePendingServerContentInstall,
|
||||
requestInstall,
|
||||
stripServerRuntimeInstallFilters,
|
||||
stripServerRuntimeInstallOverrides,
|
||||
useVIntl,
|
||||
writePendingServerContentInstallBaseline,
|
||||
writeStoredServerInstallQueue,
|
||||
@@ -65,11 +67,11 @@ const messages = defineMessages({
|
||||
},
|
||||
noServerWorld: {
|
||||
id: 'discover.install.error.no-server-world',
|
||||
defaultMessage: 'No server world is available for install.',
|
||||
defaultMessage: 'No server instance is available for install.',
|
||||
},
|
||||
backToSetup: {
|
||||
id: 'discover.install.back-to-setup',
|
||||
defaultMessage: 'Back to setup',
|
||||
backToCreateInstance: {
|
||||
id: 'discover.install.back-to-create-instance',
|
||||
defaultMessage: 'Back to create instance',
|
||||
},
|
||||
cancelReset: {
|
||||
id: 'discover.install.cancel-reset',
|
||||
@@ -83,6 +85,18 @@ const messages = defineMessages({
|
||||
id: 'discover.install.heading.reset-modpack',
|
||||
defaultMessage: 'Selecting modpack to install after reset',
|
||||
},
|
||||
createInstanceModpackHeading: {
|
||||
id: 'discover.install.heading.create-instance-modpack',
|
||||
defaultMessage: 'Selecting modpack base',
|
||||
},
|
||||
createInstanceName: {
|
||||
id: 'discover.install.create-instance-name',
|
||||
defaultMessage: 'Create instance',
|
||||
},
|
||||
worldFallbackName: {
|
||||
id: 'discover.install.world-fallback-name',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
})
|
||||
|
||||
function getQueuedInstallOwnerFallback(project: ServerInstallSearchResult) {
|
||||
@@ -145,6 +159,11 @@ export function useServerInstallContent({
|
||||
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) =>
|
||||
debug('serverData changed:', val?.server_id, val?.name, val?.loader, val?.mc_version),
|
||||
@@ -187,14 +206,55 @@ 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({
|
||||
queryKey: contentQueryKey,
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!),
|
||||
client.archon.content_v1.getAddons(currentServerId.value!, currentWorldId.value!, {
|
||||
from_modpack: false,
|
||||
}),
|
||||
enabled: computed(() => !!currentServerId.value && !!currentWorldId.value),
|
||||
})
|
||||
|
||||
const currentWorld = computed(() => {
|
||||
if (fromContext.value === 'create-instance') return null
|
||||
|
||||
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 serverContextWorldGameVersion = computed(() => {
|
||||
const worldGameVersion = currentWorld.value?.content?.game_version
|
||||
if (currentWorldId.value) return worldGameVersion ?? null
|
||||
return worldGameVersion ?? serverData.value?.mc_version ?? null
|
||||
})
|
||||
const serverContextWorldLoader = computed(() => {
|
||||
const worldLoader = currentWorld.value?.content?.modloader
|
||||
if (currentWorldId.value) return worldLoader ?? null
|
||||
return worldLoader ?? serverData.value?.loader ?? null
|
||||
})
|
||||
const serverContextWorldLoaderVersion = computed(() => {
|
||||
const worldLoaderVersion = currentWorld.value?.content?.modloader_version
|
||||
if (currentWorldId.value) return worldLoaderVersion ?? null
|
||||
return worldLoaderVersion ?? serverData.value?.loader_version ?? null
|
||||
})
|
||||
const serverContentProjectType = computed(() => {
|
||||
const loader = serverContextWorldLoader.value?.toLowerCase()
|
||||
if (!loader) return null
|
||||
if (loader === 'paper' || loader === 'purpur') return 'plugin'
|
||||
if (loader === 'vanilla') return 'datapack'
|
||||
return 'mod'
|
||||
})
|
||||
|
||||
function setBrowseSearchState(state: ServerInstallBrowseSearchState) {
|
||||
browseSearchState = state
|
||||
}
|
||||
@@ -331,12 +391,12 @@ export function useServerInstallContent({
|
||||
)
|
||||
const filters: FilterValue[] = []
|
||||
if (serverData.value && projectType.value?.id !== 'modpack') {
|
||||
const gameVersion = serverData.value.mc_version
|
||||
const gameVersion = serverContextWorldGameVersion.value
|
||||
if (gameVersion) {
|
||||
filters.push({ type: 'game_version', option: gameVersion })
|
||||
}
|
||||
|
||||
const platform = serverData.value.loader?.toLowerCase()
|
||||
const platform = serverContextWorldLoader.value?.toLowerCase().replaceAll('_', '')
|
||||
|
||||
const modLoaders = ['fabric', 'forge', 'quilt', 'neoforge']
|
||||
if (platform && modLoaders.includes(platform)) {
|
||||
@@ -394,8 +454,8 @@ export function useServerInstallContent({
|
||||
function getServerInstallTargetPreferences(contentType: BrowseInstallContentType) {
|
||||
return getTargetInstallPreferences(
|
||||
{
|
||||
gameVersion: serverData.value?.mc_version,
|
||||
loader: serverData.value?.loader,
|
||||
gameVersion: serverContextWorldGameVersion.value,
|
||||
loader: serverContextWorldLoader.value,
|
||||
},
|
||||
contentType,
|
||||
)
|
||||
@@ -412,7 +472,11 @@ export function useServerInstallContent({
|
||||
|
||||
async function resolveQueuedAddonPlans(plans: BrowseInstallPlan<ServerInstallSearchResult>[]) {
|
||||
const existingProjectIds = getServerInstalledProjectIds()
|
||||
const resolvedAddons: Array<{ project_id: string; version_id: string }> = []
|
||||
const resolvedAddons: Array<{
|
||||
project_id: string
|
||||
version_id: string
|
||||
kind: Archon.Content.v1.AddonKind
|
||||
}> = []
|
||||
|
||||
for (const plan of plans) {
|
||||
const resolved = await client.labrinth.content_v3.resolve({
|
||||
@@ -431,6 +495,7 @@ export function useServerInstallContent({
|
||||
resolvedAddons.push({
|
||||
project_id: item.project_id,
|
||||
version_id: item.version_id,
|
||||
kind: plan.contentType as Archon.Content.v1.AddonKind,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -557,11 +622,6 @@ export function useServerInstallContent({
|
||||
}
|
||||
|
||||
async function serverInstall(project: ServerInstallSearchResult) {
|
||||
if (!serverData.value || !currentServerId.value || !currentWorldId.value) {
|
||||
handleError(new Error('No server to install to.'))
|
||||
return
|
||||
}
|
||||
|
||||
if (!browseSearchState) {
|
||||
handleError(new Error('Search state is not ready.'))
|
||||
return
|
||||
@@ -569,6 +629,17 @@ export function useServerInstallContent({
|
||||
|
||||
const contentType = getCurrentServerInstallType()
|
||||
const isModpack = contentType === 'modpack'
|
||||
const isCreateInstanceFlow =
|
||||
fromContext.value === 'create-instance' || fromContext.value === 'onboarding'
|
||||
|
||||
if (
|
||||
!serverData.value ||
|
||||
!currentServerId.value ||
|
||||
(!currentWorldId.value && (!isCreateInstanceFlow || !isModpack))
|
||||
) {
|
||||
handleError(new Error('No server to install to.'))
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!isModpack && queuedServerInstallProjectIds.value.has(project.project_id)) {
|
||||
@@ -584,11 +655,15 @@ export function useServerInstallContent({
|
||||
project,
|
||||
contentType,
|
||||
mode: isModpack ? 'immediate' : 'queue',
|
||||
selectedFilters: isModpack ? [] : browseSearchState.currentFilters.value,
|
||||
selectedFilters: isModpack
|
||||
? []
|
||||
: stripServerRuntimeInstallFilters(browseSearchState.currentFilters.value),
|
||||
providedFilters: isModpack ? [] : serverFilters.value,
|
||||
overriddenProvidedFilterTypes: isModpack
|
||||
? []
|
||||
: browseSearchState.overriddenProvidedFilterTypes.value,
|
||||
: stripServerRuntimeInstallOverrides(
|
||||
browseSearchState.overriddenProvidedFilterTypes.value,
|
||||
),
|
||||
targetPreferences: getServerInstallTargetPreferences(contentType),
|
||||
getProjectVersions: getInstallProjectVersions,
|
||||
queue: serverInstallQueue,
|
||||
@@ -642,9 +717,41 @@ export function useServerInstallContent({
|
||||
}
|
||||
|
||||
async function onModpackFlowCreate(config: CreationFlowContextValue) {
|
||||
if (!currentServerId.value || !currentWorldId.value || !config.modpackSelection.value) return
|
||||
if (!currentServerId.value || !config.modpackSelection.value) return
|
||||
|
||||
try {
|
||||
if (fromContext.value === 'create-instance' || fromContext.value === 'onboarding') {
|
||||
const createdWorld = await client.archon.servers_v1.createWorld(currentServerId.value, {
|
||||
name: config.worldName.value.trim(),
|
||||
properties: config.buildProperties(),
|
||||
content: {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
platform: 'modrinth',
|
||||
project_id: config.modpackSelection.value.projectId,
|
||||
version_id: config.modpackSelection.value.versionId,
|
||||
},
|
||||
},
|
||||
} satisfies Archon.Servers.v1.CreateWorld)
|
||||
|
||||
if (fromContext.value === 'onboarding') {
|
||||
await client.archon.servers_v1.endIntro(currentServerId.value)
|
||||
}
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['servers', 'worlds', 'summary', 'v1', currentServerId.value],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['servers', 'v1', 'detail', currentServerId.value],
|
||||
}),
|
||||
])
|
||||
navigateTo(getServerInstanceContentPath(currentServerId.value, createdWorld.id))
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentWorldId.value) return
|
||||
|
||||
await client.archon.content_v1.installContent(currentServerId.value, currentWorldId.value, {
|
||||
content_variant: 'modpack',
|
||||
spec: {
|
||||
@@ -656,13 +763,7 @@ export function useServerInstallContent({
|
||||
properties: config.buildProperties(),
|
||||
} satisfies Archon.Content.v1.InstallWorldContent)
|
||||
|
||||
if (fromContext.value === 'onboarding') {
|
||||
await client.archon.servers_v1.endIntro(currentServerId.value)
|
||||
queryClient.invalidateQueries({ queryKey: ['servers', 'detail', currentServerId.value] })
|
||||
navigateTo(`/hosting/manage/${currentServerId.value}/content`)
|
||||
} else {
|
||||
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
|
||||
}
|
||||
navigateTo(`/hosting/manage/${currentServerId.value}?openSettings=installation`)
|
||||
} catch (e) {
|
||||
handleError(new Error(`Error installing modpack: ${e}`))
|
||||
config.loading.value = false
|
||||
@@ -672,14 +773,22 @@ export function useServerInstallContent({
|
||||
const serverBackUrl = computed(() => {
|
||||
if (!serverData.value) return ''
|
||||
const id = serverData.value.server_id
|
||||
if (fromContext.value === 'create-instance')
|
||||
return `/hosting/manage/${id}/instances?resumeModal=create-instance`
|
||||
if (fromContext.value === 'onboarding') return `/hosting/manage/${id}?resumeModal=setup-type`
|
||||
if (fromContext.value === 'reset-server')
|
||||
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(() => {
|
||||
if (fromContext.value === 'onboarding') return formatMessage(messages.backToSetup)
|
||||
if (fromContext.value === 'create-instance') return formatMessage(messages.backToCreateInstance)
|
||||
if (fromContext.value === 'onboarding') return formatMessage(messages.backToCreateInstance)
|
||||
if (fromContext.value === 'reset-server') return formatMessage(messages.cancelReset)
|
||||
return formatMessage(messages.backToServer)
|
||||
})
|
||||
@@ -687,15 +796,20 @@ export function useServerInstallContent({
|
||||
const serverBrowseHeading = computed(() =>
|
||||
fromContext.value === 'reset-server'
|
||||
? formatMessage(messages.resetModpackHeading)
|
||||
: formatMessage(commonMessages.installingContentLabel),
|
||||
: fromContext.value === 'create-instance' || fromContext.value === 'onboarding'
|
||||
? formatMessage(messages.createInstanceModpackHeading)
|
||||
: formatMessage(commonMessages.installingContentLabel),
|
||||
)
|
||||
|
||||
const installContext = computed(() => {
|
||||
if (!serverData.value) return null
|
||||
return {
|
||||
name: serverData.value.name,
|
||||
loader: serverData.value.loader ?? '',
|
||||
gameVersion: serverData.value.mc_version ?? '',
|
||||
name:
|
||||
fromContext.value === 'create-instance'
|
||||
? formatMessage(messages.createInstanceName)
|
||||
: (currentWorld.value?.name ?? formatMessage(messages.worldFallbackName)),
|
||||
loader: serverContextWorldLoader.value ?? '',
|
||||
loaderVersion: serverContextWorldLoaderVersion.value ?? '',
|
||||
gameVersion: serverContextWorldGameVersion.value ?? '',
|
||||
serverId: currentServerId.value,
|
||||
upstream: serverData.value.upstream,
|
||||
iconSrc: serverIcon.value,
|
||||
@@ -709,7 +823,7 @@ export function useServerInstallContent({
|
||||
installProgress: queuedInstallProgress.value,
|
||||
clearQueued: clearQueuedServerInstalls,
|
||||
clearSelected: clearQueuedServerInstalls,
|
||||
onBack: flushQueuedServerInstalls,
|
||||
onBack: fromContext.value === 'create-instance' ? undefined : flushQueuedServerInstalls,
|
||||
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
|
||||
installSelected: installQueuedServerInstallsAndBack,
|
||||
}
|
||||
@@ -761,6 +875,7 @@ export function useServerInstallContent({
|
||||
currentWorldId,
|
||||
serverData,
|
||||
serverContentData,
|
||||
serverContentProjectType,
|
||||
serverFilters,
|
||||
serverHideInstalled,
|
||||
hideSelectedServerInstalls,
|
||||
|
||||
@@ -1805,9 +1805,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Zurück zum Server"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Zurück zur Einrichtung"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Reset abbrechen"
|
||||
},
|
||||
|
||||
@@ -1805,9 +1805,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Zurück zum Server"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Zurück zur Einrichtung"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Zurücksetzung abbrechen"
|
||||
},
|
||||
|
||||
@@ -1946,24 +1946,33 @@
|
||||
"dashboard.withdraw.error.tax-form.title": {
|
||||
"message": "Please complete tax form"
|
||||
},
|
||||
"discover.install.back-to-create-instance": {
|
||||
"message": "Back to create instance"
|
||||
},
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Back to server"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Back to setup"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Cancel reset"
|
||||
},
|
||||
"discover.install.create-instance-name": {
|
||||
"message": "Create instance"
|
||||
},
|
||||
"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": {
|
||||
"message": "This content type cannot be installed to a server from Discover."
|
||||
},
|
||||
"discover.install.heading.create-instance-modpack": {
|
||||
"message": "Selecting modpack base"
|
||||
},
|
||||
"discover.install.heading.reset-modpack": {
|
||||
"message": "Selecting modpack to install after reset"
|
||||
},
|
||||
"discover.install.world-fallback-name": {
|
||||
"message": "Instance"
|
||||
},
|
||||
"discover.seo.description": {
|
||||
"message": "Search and browse thousands of Minecraft {projectType} on Modrinth with instant, accurate search results. Our filters help you quickly find the best Minecraft {projectType}."
|
||||
},
|
||||
@@ -3323,6 +3332,9 @@
|
||||
"project.gallery.title": {
|
||||
"message": "Gallery"
|
||||
},
|
||||
"project.install-context.back-to-discover": {
|
||||
"message": "Back to discover"
|
||||
},
|
||||
"project.license.error": {
|
||||
"message": "License text could not be retrieved."
|
||||
},
|
||||
@@ -3938,6 +3950,12 @@
|
||||
"servers.manage.content.title": {
|
||||
"message": "Content - {serverName} - Modrinth"
|
||||
},
|
||||
"servers.manage.instances.meta.title": {
|
||||
"message": "Instances - {server} - Modrinth"
|
||||
},
|
||||
"servers.manage.instances.slot-name": {
|
||||
"message": "Instance #{index}"
|
||||
},
|
||||
"servers.notice.actions": {
|
||||
"message": "Actions"
|
||||
},
|
||||
|
||||
@@ -1847,9 +1847,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Volver al servidor"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Volver a configuración"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Cancelar reinicio"
|
||||
},
|
||||
|
||||
@@ -1841,9 +1841,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Volver al server"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Volver a configuración"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Cancelar reinicio"
|
||||
},
|
||||
|
||||
@@ -1847,9 +1847,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Retour au serveur"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Retour au setup"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Annuler la réinitialisation"
|
||||
},
|
||||
|
||||
@@ -1688,9 +1688,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Vissza a szerverhez"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Vissza a beállításhoz"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Visszaállítás visszavonása"
|
||||
},
|
||||
|
||||
@@ -1838,9 +1838,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Torna al server"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Torna alla configurazione"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Annulla ripristino"
|
||||
},
|
||||
|
||||
@@ -1319,9 +1319,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "서버로 돌아가기"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "설정으로 돌아가기"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "리셋 취소"
|
||||
},
|
||||
|
||||
@@ -1688,9 +1688,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Kembali ke pelayan"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Kembali ke persediaan"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Batalkan penetapan semula"
|
||||
},
|
||||
|
||||
@@ -1844,9 +1844,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Powróć do serwera"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Powróć do ustawiania"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Anuluj resetowanie"
|
||||
},
|
||||
|
||||
@@ -1844,9 +1844,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Voltar ao servidor"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Voltar à configuração"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Cancelar redefinição"
|
||||
},
|
||||
|
||||
@@ -1829,9 +1829,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Вернуться к серверу"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Вернуться к установке"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Отменить сброс"
|
||||
},
|
||||
|
||||
@@ -1805,9 +1805,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Sunucuya geri dön"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Sunucuya geri dön"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Sıfırlamayı iptal et"
|
||||
},
|
||||
|
||||
@@ -1625,9 +1625,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Повернутися до сервера"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Повернутися до налаштування"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Скасувати скидання"
|
||||
},
|
||||
|
||||
@@ -1805,9 +1805,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "Quay lại máy chủ"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "Quay lại cài đặt"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "Hủy cài lại"
|
||||
},
|
||||
|
||||
@@ -1802,9 +1802,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "返回服务器"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "返回设置"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "取消重置"
|
||||
},
|
||||
|
||||
@@ -1847,9 +1847,6 @@
|
||||
"discover.install.back-to-server": {
|
||||
"message": "回到伺服器"
|
||||
},
|
||||
"discover.install.back-to-setup": {
|
||||
"message": "回到設定"
|
||||
},
|
||||
"discover.install.cancel-reset": {
|
||||
"message": "取消重設"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useGeneratedState } from '~/composables/generated'
|
||||
import { projectQueryOptions } from '~/composables/queries/project'
|
||||
import { useAppQueryClient } from '~/composables/query-client'
|
||||
import { createModrinthClient } from '~/helpers/api.ts'
|
||||
import { getProjectTypeForUrlShorthand } from '~/helpers/projects.js'
|
||||
import { useServerModrinthClient } from '~/server/utils/api-client'
|
||||
|
||||
@@ -18,9 +19,6 @@ const PROJECT_TYPES = [
|
||||
]
|
||||
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Only run this middleware on the server - it relies on server-only runtime config
|
||||
if (import.meta.client) return
|
||||
|
||||
const routeProjectParam = to.params.project
|
||||
const projectId = Array.isArray(routeProjectParam) ? routeProjectParam[0] : routeProjectParam
|
||||
const routeType = Array.isArray(to.params.type) ? to.params.type[0] : to.params.type
|
||||
@@ -31,10 +29,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
|
||||
const queryClient = useAppQueryClient()
|
||||
const authToken = useCookie('auth-token')
|
||||
const client = useServerModrinthClient({ authToken: authToken.value || undefined })
|
||||
const client = await getProjectMiddlewareClient()
|
||||
const tags = useGeneratedState()
|
||||
|
||||
if (import.meta.client) startLoading()
|
||||
|
||||
try {
|
||||
// Fetch v2 and v3 in parallel — cache both for the page's useQuery calls
|
||||
const [project, projectV3] = await Promise.all([
|
||||
@@ -48,9 +47,11 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
// Cache by slug if we looked up by ID (or vice versa)
|
||||
if (projectId !== project.slug) {
|
||||
queryClient.setQueryData(['project', 'v2', project.slug], project)
|
||||
queryClient.setQueryData(['project', 'v3', project.slug], projectV3)
|
||||
}
|
||||
if (projectId !== project.id) {
|
||||
queryClient.setQueryData(['project', 'v2', project.id], project)
|
||||
queryClient.setQueryData(['project', 'v3', project.id], projectV3)
|
||||
}
|
||||
|
||||
const projectType = projectV3.minecraft_server != null ? 'server' : project.project_type
|
||||
@@ -81,5 +82,23 @@ export default defineNuxtRouteMiddleware(async (to) => {
|
||||
}
|
||||
} catch {
|
||||
// Let the page handle 404s and other errors
|
||||
} finally {
|
||||
if (import.meta.client) stopLoading()
|
||||
}
|
||||
})
|
||||
|
||||
async function getProjectMiddlewareClient() {
|
||||
if (import.meta.server) {
|
||||
const authToken = useCookie('auth-token')
|
||||
return useServerModrinthClient({ authToken: authToken.value || undefined })
|
||||
}
|
||||
|
||||
const auth = await useAuth()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
return createModrinthClient(auth, {
|
||||
apiBaseUrl: config.public.apiBaseUrl.replace('/v2/', '/'),
|
||||
archonBaseUrl: config.public.pyroBaseUrl.replace('/v2/', '/'),
|
||||
rateLimitKey: config.rateLimitKey,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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', 'v1', worldId, path],
|
||||
queryFn: () => client.kyros.files_v1.listDescendants(worldId, path, 1, 200),
|
||||
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 })
|
||||
})
|
||||
@@ -421,6 +421,22 @@
|
||||
</template>
|
||||
</NewModal>
|
||||
<CollectionCreateModal ref="modal_collection" :project-ids="[project.id]" />
|
||||
<div
|
||||
v-if="projectInstallContext && !isSettings"
|
||||
ref="stickyInstallHeaderRef"
|
||||
class="sticky top-0 z-20 mx-auto max-w-[80rem] border-0 border-solid border-divider bg-surface-1 px-6 pt-4"
|
||||
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
||||
>
|
||||
<BrowseInstallHeader
|
||||
:install-context="projectHeaderInstallContext"
|
||||
divider
|
||||
bottom-padding
|
||||
/>
|
||||
</div>
|
||||
<SelectedProjectsFloatingBar
|
||||
v-if="projectInstallContext && !isSettings"
|
||||
:install-context="projectInstallContext"
|
||||
/>
|
||||
<div
|
||||
class="new-page sidebar"
|
||||
:class="{
|
||||
@@ -435,7 +451,10 @@
|
||||
!flags.alwaysShowChecklistAsPopup,
|
||||
}"
|
||||
>
|
||||
<div class="normal-page__header relative my-4">
|
||||
<div
|
||||
class="normal-page__header relative mb-4"
|
||||
:class="projectInstallContext && !isSettings ? 'mt-0' : 'mt-4'"
|
||||
>
|
||||
<div class="mb-6">
|
||||
<ModerationProjectNags
|
||||
v-if="
|
||||
@@ -459,358 +478,8 @@
|
||||
:project="project"
|
||||
:project-v3="projectV3"
|
||||
:member="!!currentMember"
|
||||
>
|
||||
<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>
|
||||
:actions="projectHeaderActions"
|
||||
/>
|
||||
<ProjectMemberHeader
|
||||
v-if="currentMember"
|
||||
:project="project"
|
||||
@@ -1044,7 +713,6 @@
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
BookmarkIcon,
|
||||
BookTextIcon,
|
||||
CalendarIcon,
|
||||
ChartIcon,
|
||||
@@ -1060,7 +728,6 @@ import {
|
||||
ModrinthIcon,
|
||||
MoreVerticalIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
ReportIcon,
|
||||
ScaleIcon,
|
||||
ScanEyeIcon,
|
||||
@@ -1069,11 +736,11 @@ import {
|
||||
SettingsIcon,
|
||||
VersionIcon,
|
||||
WrenchIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
Avatar,
|
||||
BrowseInstallHeader,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
@@ -1081,12 +748,9 @@ import {
|
||||
getTagMessage,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NavTabs,
|
||||
NewModal,
|
||||
OpenInAppModal,
|
||||
OverflowMenu,
|
||||
PopoutMenu,
|
||||
PROJECT_DEP_MARKER_QUERY,
|
||||
ProjectBackgroundGradient,
|
||||
ProjectEnvironmentModal,
|
||||
@@ -1099,13 +763,14 @@ import {
|
||||
ProjectSidebarTags,
|
||||
provideProjectPageContext,
|
||||
ScrollablePanel,
|
||||
SelectedProjectsFloatingBar,
|
||||
ServersPromo,
|
||||
StyledInput,
|
||||
TagItem,
|
||||
useDebugLogger,
|
||||
useFormatDateTime,
|
||||
useFormatPrice,
|
||||
useRelativeTime,
|
||||
useStickyObserver,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import VersionSummary from '@modrinth/ui/src/components/version/VersionSummary.vue'
|
||||
@@ -1113,7 +778,6 @@ import { capitalizeString, formatProjectType, renderString } from '@modrinth/uti
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { nextTick, readonly, ref, useTemplateRef, watch } from 'vue'
|
||||
|
||||
import { navigateTo } from '#app'
|
||||
@@ -1124,11 +788,13 @@ import CollectionCreateModal from '~/components/ui/create/CollectionCreateModal.
|
||||
import MessageBanner from '~/components/ui/MessageBanner.vue'
|
||||
import ModerationChecklist from '~/components/ui/moderation/checklist/ModerationChecklist.vue'
|
||||
import ModerationProjectNags from '~/components/ui/moderation/ModerationProjectNags.vue'
|
||||
import ProjectCollectionSaveButton from '~/components/ui/ProjectCollectionSaveButton.vue'
|
||||
import ProjectMemberHeader from '~/components/ui/ProjectMemberHeader.vue'
|
||||
import { getSignInRouteObj } from '~/composables/auth.ts'
|
||||
import { saveFeatureFlags } from '~/composables/featureFlags.ts'
|
||||
import { STALE_TIME, STALE_TIME_LONG } from '~/composables/queries/project'
|
||||
import { versionQueryOptions } from '~/composables/queries/version'
|
||||
import { useServerInstallContent } from '~/composables/use-server-install-content'
|
||||
import { userCollectProject, userFollowProject } from '~/composables/user.js'
|
||||
import {
|
||||
loadChecklistOpenState,
|
||||
@@ -1200,8 +866,14 @@ const versionFilter = ref('')
|
||||
|
||||
const projectV3Loaded = computed(() => !projectV3Pending.value || projectV3.value != null)
|
||||
const isServerProject = computed(() => projectV3.value?.minecraft_server != null)
|
||||
const stickyInstallHeaderRef = ref(null)
|
||||
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||
stickyInstallHeaderRef,
|
||||
'ProjectInstallHeader',
|
||||
)
|
||||
|
||||
const projectEnvironmentModal = useTemplateRef('projectEnvironmentModal')
|
||||
const modalCollection = useTemplateRef('modal_collection')
|
||||
|
||||
const baseId = useId()
|
||||
|
||||
@@ -1343,6 +1015,10 @@ const messages = defineMessages({
|
||||
id: 'project.navigation.changelog',
|
||||
defaultMessage: 'Changelog',
|
||||
},
|
||||
backToDiscover: {
|
||||
id: 'project.install-context.back-to-discover',
|
||||
defaultMessage: 'Back to discover',
|
||||
},
|
||||
createNewCollection: {
|
||||
id: 'project.collections.create-new',
|
||||
defaultMessage: 'Create new collection',
|
||||
@@ -1632,13 +1308,8 @@ const filteredAlpha = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
const displayCollectionsSearch = ref('')
|
||||
const collections = computed(() =>
|
||||
user.value && user.value.collections
|
||||
? user.value.collections.filter((x) =>
|
||||
x.name.toLowerCase().includes(displayCollectionsSearch.value.toLowerCase()),
|
||||
)
|
||||
: [],
|
||||
user.value && user.value.collections ? user.value.collections : [],
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -1701,6 +1372,55 @@ const project = computed(() => {
|
||||
),
|
||||
}
|
||||
})
|
||||
const routeProjectType = computed(() =>
|
||||
Array.isArray(route.params.type) ? route.params.type[0] : route.params.type,
|
||||
)
|
||||
const projectInstallType = computed(() => ({
|
||||
id: project.value?.actualProjectType ?? routeProjectType.value,
|
||||
}))
|
||||
const serverInstallModalRef = ref(null)
|
||||
const serverInstallDebug = useDebugLogger('ProjectServerInstall')
|
||||
const { installContext: serverBrowseInstallContext } = useServerInstallContent({
|
||||
projectType: projectInstallType,
|
||||
onboardingModalRef: serverInstallModalRef,
|
||||
debug: serverInstallDebug,
|
||||
})
|
||||
const projectDiscoverBackUrl = computed(() => {
|
||||
const browsePath = route.query.b
|
||||
if (typeof browsePath === 'string' && browsePath.startsWith('/discover/')) {
|
||||
return browsePath
|
||||
}
|
||||
|
||||
const discoverType =
|
||||
routeProjectType.value === 'project'
|
||||
? (project.value?.actualProjectType ?? project.value?.project_type ?? 'mod')
|
||||
: (routeProjectType.value ?? project.value?.actualProjectType ?? 'mod')
|
||||
|
||||
return `/discover/${discoverType}s${getInstallContextQueryString(['sid', 'wid', 'from', 'shi'])}`
|
||||
})
|
||||
const projectInstallContext = computed(() => {
|
||||
const context = serverBrowseInstallContext.value
|
||||
if (!context) return null
|
||||
return {
|
||||
...context,
|
||||
backUrl: projectDiscoverBackUrl.value,
|
||||
backLabel: formatMessage(messages.backToDiscover),
|
||||
discardSelectedAndBack: async () => {
|
||||
await (context.clearSelected ?? context.clearQueued)?.()
|
||||
await navigateTo(projectDiscoverBackUrl.value)
|
||||
},
|
||||
}
|
||||
})
|
||||
const projectHeaderInstallContext = computed(() => {
|
||||
const context = projectInstallContext.value
|
||||
if (!context) return null
|
||||
return {
|
||||
...context,
|
||||
onBack: undefined,
|
||||
selectedProjects: [],
|
||||
isInstallingSelected: false,
|
||||
}
|
||||
})
|
||||
|
||||
// Use actual project ID for dependent queries (ensures cache consistency)
|
||||
const projectId = computed(() => projectRaw.value?.id)
|
||||
@@ -2362,6 +2082,184 @@ const canCreateServerFrom = computed(() => {
|
||||
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 = () =>
|
||||
project.value ? `https://modrinth.com/project/${project.value.id}` : undefined
|
||||
|
||||
@@ -2676,6 +2574,32 @@ function onVersionNavigate(url) {
|
||||
})
|
||||
}
|
||||
|
||||
const INSTALL_CONTEXT_QUERY_KEYS = ['sid', 'wid', 'from', 'shi', 'b']
|
||||
|
||||
function getInstallContextQueryString(keys = INSTALL_CONTEXT_QUERY_KEYS) {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
for (const key of keys) {
|
||||
const value = route.query[key]
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
if (item != null) {
|
||||
params.append(key, item)
|
||||
}
|
||||
}
|
||||
} else if (value != null) {
|
||||
params.append(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const queryString = params.toString()
|
||||
return queryString ? `?${queryString}` : ''
|
||||
}
|
||||
|
||||
function withInstallContextQuery(path) {
|
||||
return `${path}${getInstallContextQueryString()}`
|
||||
}
|
||||
|
||||
async function deleteVersion(id) {
|
||||
if (!id) return
|
||||
|
||||
@@ -2700,16 +2624,16 @@ const navLinks = computed(() => {
|
||||
return [
|
||||
{
|
||||
label: formatMessage(messages.descriptionTab),
|
||||
href: projectUrl,
|
||||
href: withInstallContextQuery(projectUrl),
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.galleryTab),
|
||||
href: `${projectUrl}/gallery`,
|
||||
href: withInstallContextQuery(`${projectUrl}/gallery`),
|
||||
shown: galleryCount > 0 || !!currentMember.value,
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.changelogTab),
|
||||
href: `${projectUrl}/changelog`,
|
||||
href: withInstallContextQuery(`${projectUrl}/changelog`),
|
||||
shown:
|
||||
hasVersions.value &&
|
||||
projectV3Loaded.value &&
|
||||
@@ -2718,7 +2642,7 @@ const navLinks = computed(() => {
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.versionsTab),
|
||||
href: `${projectUrl}/versions`,
|
||||
href: withInstallContextQuery(`${projectUrl}/versions`),
|
||||
shown:
|
||||
(hasVersions.value || !!currentMember.value) &&
|
||||
projectV3Loaded.value &&
|
||||
@@ -2728,7 +2652,7 @@ const navLinks = computed(() => {
|
||||
},
|
||||
{
|
||||
label: formatMessage(messages.moderationTab),
|
||||
href: `${projectUrl}/moderation`,
|
||||
href: withInstallContextQuery(`${projectUrl}/moderation`),
|
||||
shown: !!currentMember.value,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -4,7 +4,6 @@ import { commonProjectTypeCategoryMessages, NavTabs, useVIntl } from '@modrinth/
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const flags = useFeatureFlags()
|
||||
const cosmetics = useCosmetics()
|
||||
const route = useRoute()
|
||||
|
||||
const allowTabChanging = computed(() => !route.query.sid)
|
||||
@@ -48,15 +47,13 @@ const selectableProjectTypes = [
|
||||
]
|
||||
</script>
|
||||
<template>
|
||||
<div class="new-page sidebar" :class="{ 'alt-layout': !cosmetics.rightSearchLayout }">
|
||||
<section class="normal-page__header mb-4 flex flex-col gap-4">
|
||||
<NavTabs
|
||||
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
|
||||
:links="selectableProjectTypes"
|
||||
replace
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
</section>
|
||||
<div class="mx-auto box-border flex w-full max-w-[1280px] flex-col gap-4 px-6 pb-6">
|
||||
<NavTabs
|
||||
v-if="!flags.projectTypesPrimaryNav && allowTabChanging"
|
||||
:links="selectableProjectTypes"
|
||||
replace
|
||||
class="hidden md:flex"
|
||||
/>
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { cycleValue } from '@modrinth/utils'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { useTimeoutFn } from '@vueuse/core'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { LocationQueryRaw } from 'vue-router'
|
||||
|
||||
import LogoAnimated from '~/components/brand/LogoAnimated.vue'
|
||||
import AdPlaceholder from '~/components/ui/AdPlaceholder.vue'
|
||||
@@ -55,9 +56,6 @@ const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const filtersMenuOpen = ref(false)
|
||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||
|
||||
useStickyObserver(stickyInstallHeaderRef, 'DiscoverInstallHeader')
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
@@ -117,6 +115,7 @@ debug('initial route.params.type:', route.params.type, '→ currentType:', curre
|
||||
const isServerType = computed(() => currentType.value === 'server')
|
||||
|
||||
const projectType = computed(() => tags.value.projectTypes.find((x) => x.id === currentType.value))
|
||||
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
|
||||
|
||||
watch(
|
||||
() => projectType.value?.id,
|
||||
@@ -164,6 +163,7 @@ const {
|
||||
queuedServerInstallProjectIds,
|
||||
queuedServerInstallCount,
|
||||
isInstallingQueuedServerInstalls,
|
||||
serverContentProjectType,
|
||||
installContext,
|
||||
setBrowseSearchState,
|
||||
syncHiddenInstalledProjectIds,
|
||||
@@ -177,6 +177,33 @@ const {
|
||||
debug,
|
||||
})
|
||||
|
||||
watch(
|
||||
[currentServerId, fromContext, projectTypeId, serverContentProjectType],
|
||||
([serverId, from, currentProjectType, targetProjectType]) => {
|
||||
if (!serverId || from || !targetProjectType) return
|
||||
if (!['mod', 'plugin', 'datapack'].includes(currentProjectType)) return
|
||||
if (currentProjectType === targetProjectType) return
|
||||
|
||||
navigateTo({
|
||||
path: `/discover/${targetProjectType}s`,
|
||||
query: {
|
||||
sid: route.query.sid,
|
||||
wid: route.query.wid,
|
||||
shi: route.query.shi,
|
||||
from: route.query.from,
|
||||
q: route.query.q,
|
||||
},
|
||||
})
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const stickyInstallHeaderRef = ref<HTMLElement | null>(null)
|
||||
const { isStuck: isInstallHeaderStuck } = useStickyObserver(
|
||||
stickyInstallHeaderRef,
|
||||
'DiscoverInstallHeader',
|
||||
)
|
||||
|
||||
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
|
||||
const content = project.minecraft_java_server?.content
|
||||
if (content?.kind === 'modpack') {
|
||||
@@ -199,49 +226,88 @@ function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject
|
||||
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',
|
||||
}
|
||||
}
|
||||
|
||||
const hostingContextQuery = computed(() => {
|
||||
const query: LocationQueryRaw = {}
|
||||
const hasHostingContext = route.query.sid != null
|
||||
|
||||
for (const key of ['sid', 'wid', 'from', 'shi']) {
|
||||
const value = route.query[key]
|
||||
if (value != null) {
|
||||
query[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if (hasHostingContext) {
|
||||
query.b = route.fullPath
|
||||
}
|
||||
|
||||
return Object.keys(query).length > 0 ? query : undefined
|
||||
})
|
||||
|
||||
function withHostingContext(path: string) {
|
||||
return hostingContextQuery.value ? { path, query: hostingContextQuery.value } : path
|
||||
}
|
||||
|
||||
async function fetchSearch(requestParams: string) {
|
||||
debug('search() called', {
|
||||
requestParams: requestParams.substring(0, 100),
|
||||
isServer: isServerType.value,
|
||||
projectTypeId: projectTypeId.value,
|
||||
})
|
||||
const config = useRuntimeConfig()
|
||||
let base = import.meta.server ? config.apiBaseUrl : config.public.apiBaseUrl
|
||||
|
||||
if (isServerType.value) {
|
||||
base = base.replace(/\/v\d\//, '/v3/').replace(/\/v\d$/, '/v3')
|
||||
}
|
||||
|
||||
const url = `${base}search${requestParams}`
|
||||
debug('search() fetching:', url.substring(0, 120))
|
||||
|
||||
const raw = await $fetch<Labrinth.Search.v2.SearchResults | Labrinth.Search.v3.SearchResults>(
|
||||
url,
|
||||
{
|
||||
headers: withLabrinthCanaryHeader(),
|
||||
},
|
||||
)
|
||||
const raw = await client.request<Labrinth.Search.v3.SearchResults>('/search', {
|
||||
api: 'labrinth',
|
||||
version: 3,
|
||||
method: 'GET',
|
||||
params: Object.fromEntries(new URLSearchParams(requestParams.replace(/^\?/, ''))),
|
||||
headers: withLabrinthCanaryHeader(),
|
||||
})
|
||||
|
||||
debug('search() response', { total_hits: raw.total_hits, hitCount: raw.hits?.length })
|
||||
|
||||
if ('hits_per_page' in raw) {
|
||||
// v3 response (servers)
|
||||
if (isServerType.value) {
|
||||
return {
|
||||
projectHits: [],
|
||||
serverHits: raw.hits as Labrinth.Search.v3.ResultSearchProject[],
|
||||
serverHits: raw.hits,
|
||||
total_hits: raw.total_hits,
|
||||
per_page: raw.hits_per_page,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectHits: raw.hits as Labrinth.Search.v2.ResultSearchProject[],
|
||||
projectHits: raw.hits.map(mapV3ProjectHit),
|
||||
serverHits: [],
|
||||
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(
|
||||
result: Labrinth.Search.v2.ResultSearchProject | Labrinth.Search.v3.ResultSearchProject,
|
||||
currentProjectType: string,
|
||||
@@ -369,8 +435,6 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
|
||||
|
||||
debug('projectTypeId:', projectTypeId.value)
|
||||
watch(projectTypeId, (val) => debug('projectTypeId changed:', val))
|
||||
|
||||
@@ -411,7 +475,7 @@ watch(
|
||||
)
|
||||
|
||||
debug('calling initial refreshSearch')
|
||||
searchState.refreshSearch()
|
||||
await searchState.refreshSearch()
|
||||
|
||||
const ogTitle = computed(() =>
|
||||
searchState.query.value
|
||||
@@ -449,9 +513,11 @@ provideBrowseManager({
|
||||
projectType: projectTypeId,
|
||||
...searchState,
|
||||
getProjectLink: (result: Labrinth.Search.v2.ResultSearchProject) =>
|
||||
`/${projectType.value?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`,
|
||||
withHostingContext(
|
||||
`/${projectType.value?.id ?? 'project'}/${result.slug ? result.slug : result.project_id}`,
|
||||
),
|
||||
getServerProjectLink: (result: Labrinth.Search.v3.ResultSearchProject) =>
|
||||
`/server/${result.slug ?? result.project_id}`,
|
||||
withHostingContext(`/server/${result.slug ?? result.project_id}`),
|
||||
selectableProjectTypes: computed(() => []),
|
||||
showProjectTypeTabs: computed(() => false),
|
||||
variant: 'web',
|
||||
@@ -491,20 +557,30 @@ provideBrowseManager({
|
||||
<Teleport v-if="flags.searchBackground" to="#absolute-background-teleport">
|
||||
<div class="search-background"></div>
|
||||
</Teleport>
|
||||
|
||||
<div
|
||||
v-if="installContext"
|
||||
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>
|
||||
|
||||
<SelectedProjectsFloatingBar v-if="installContext" :install-context="installContext" />
|
||||
<aside class="normal-page__sidebar" :aria-label="formatMessage(commonMessages.filtersLabel)">
|
||||
<AdPlaceholder v-if="!auth.user && !serverData" />
|
||||
<BrowseSidebar />
|
||||
</aside>
|
||||
<section class="normal-page__content">
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
<div
|
||||
class="grid min-w-0 gap-3"
|
||||
:class="
|
||||
cosmetics.rightSearchLayout
|
||||
? '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>
|
||||
<template #display-mode-icon>
|
||||
<GridIcon v-if="resultsDisplayMode === 'grid'" />
|
||||
@@ -512,78 +588,40 @@ provideBrowseManager({
|
||||
<ListIcon v-else />
|
||||
</template>
|
||||
</BrowsePageLayout>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside
|
||||
class="min-w-0"
|
||||
:class="cosmetics.rightSearchLayout ? 'lg:order-2' : 'lg:order-1'"
|
||||
:aria-label="formatMessage(commonMessages.filtersLabel)"
|
||||
>
|
||||
<BrowseSidebar>
|
||||
<template #prepend>
|
||||
<AdPlaceholder v-if="!auth.user && !serverData" />
|
||||
</template>
|
||||
</BrowseSidebar>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<CreationFlowModal
|
||||
v-if="currentServerId && projectType?.id === 'modpack'"
|
||||
ref="onboardingModalRef"
|
||||
:type="
|
||||
fromContext === 'reset-server'
|
||||
? 'reset-server'
|
||||
: fromContext === 'create-instance' || fromContext === 'onboarding'
|
||||
? 'world'
|
||||
: '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>
|
||||
<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 {
|
||||
width: 100%;
|
||||
height: 20rem;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
:server-id="serverId"
|
||||
:reload-page="() => reloadNuxtApp({ path: route.path })"
|
||||
:resolve-viewer="resolveViewer"
|
||||
:show-copy-id-action="flags.developerMode"
|
||||
:show-advanced-debug-info="flags.advancedDebugInfo"
|
||||
:stripe-publishable-key="config.public.stripePublishableKey as string"
|
||||
:site-url="config.public.siteUrl as string"
|
||||
@@ -36,6 +35,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { injectModrinthClient, ServersManageRootLayout } from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
@@ -52,14 +52,29 @@ const client = injectModrinthClient()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
if (serverId) {
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v0.get(serverId)!,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
} catch {
|
||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||
const serverDetailPromise = queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v0.get(serverId)!,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const serverFullPromise = queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const [, serverFullResult] = await Promise.allSettled([serverDetailPromise, serverFullPromise])
|
||||
|
||||
if (serverFullResult.status === 'fulfilled') {
|
||||
const worldId = resolveWorldId(route.params.instance_id, serverFullResult.value)
|
||||
if (worldId) {
|
||||
await Promise.allSettled([
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['backups', 'queue', serverId, worldId],
|
||||
queryFn: () => client.archon.backups_queue_v1.list(serverId, worldId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +98,21 @@ async function resolveViewer(): Promise<{ userId: string | null; userRole: strin
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorldId(
|
||||
routeInstanceId: string | string[] | undefined,
|
||||
serverFull: Archon.Servers.v1.ServerFull,
|
||||
) {
|
||||
const instanceId = getRouteParam(routeInstanceId)
|
||||
if (instanceId) return instanceId
|
||||
const activeWorld = serverFull.worlds.find((world) => world.is_active)
|
||||
return activeWorld?.id ?? serverFull.worlds[0]?.id ?? null
|
||||
}
|
||||
|
||||
function getRouteParam(param: string | string[] | undefined): string | null {
|
||||
if (Array.isArray(param)) return param[0] ?? null
|
||||
return param ?? null
|
||||
}
|
||||
|
||||
definePageMeta({
|
||||
middleware: 'auth',
|
||||
})
|
||||
|
||||
@@ -9,74 +9,22 @@ import { useQueryClient } from '@tanstack/vue-query'
|
||||
const client = injectModrinthClient()
|
||||
const { server, serverId } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
const flags = useFeatureFlags()
|
||||
const ACTION_LOG_PAGE_SIZE = 200
|
||||
const ACTION_LOG_SORT_DIRECTION = 'desc'
|
||||
const actionLogDateFilter = defaultActionLogDateFilter()
|
||||
|
||||
await Promise.allSettled([
|
||||
queryClient.ensureQueryData({
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'users', 'v1', serverId],
|
||||
queryFn: () => client.archon.server_users_v1.list(serverId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
queryClient.ensureQueryData({
|
||||
queryKey: ['servers', 'v1', 'detail', serverId],
|
||||
queryFn: () => client.archon.servers_v1.get(serverId),
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
queryClient.prefetchInfiniteQuery({
|
||||
queryKey: [
|
||||
'servers',
|
||||
'action-log',
|
||||
'v1',
|
||||
'infinite',
|
||||
serverId,
|
||||
null,
|
||||
actionLogDateFilter.min_datetime,
|
||||
actionLogDateFilter.max_datetime,
|
||||
ACTION_LOG_SORT_DIRECTION,
|
||||
],
|
||||
queryFn: ({ pageParam = 0 }) => {
|
||||
const offset = typeof pageParam === 'number' ? pageParam : 0
|
||||
return client.archon.actions_v1.list(serverId, {
|
||||
limit: ACTION_LOG_PAGE_SIZE,
|
||||
offset,
|
||||
order: ACTION_LOG_SORT_DIRECTION,
|
||||
...actionLogDateFilter,
|
||||
})
|
||||
},
|
||||
getNextPageParam: (lastPage) =>
|
||||
typeof lastPage.next_offset === 'number' ? lastPage.next_offset : undefined,
|
||||
initialPageParam: 0,
|
||||
staleTime: 30_000,
|
||||
}),
|
||||
])
|
||||
})
|
||||
} catch {
|
||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
|
||||
useHead({
|
||||
title: computed(() => `Access - ${server.value?.name ?? 'Server'} - Modrinth`),
|
||||
})
|
||||
|
||||
function defaultActionLogDateFilter() {
|
||||
const endDate = new Date()
|
||||
const startDate = new Date(endDate)
|
||||
startDate.setDate(startDate.getDate() - 6)
|
||||
|
||||
return {
|
||||
min_datetime: startOfDay(startDate).toISOString(),
|
||||
max_datetime: endOfDay(endDate).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
function startOfDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
}
|
||||
|
||||
function endOfDay(date: Date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59, 999)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ServersManageAccessPage :show-audit-log-instances="flags.showHostingAccessInstanceAuditLog" />
|
||||
<ServersManageAccessPage />
|
||||
</template>
|
||||
|
||||
@@ -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) {
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['backups', 'list', serverId],
|
||||
queryKey: ['backups', 'list', serverId, worldId.value],
|
||||
queryFn: () => client.archon.backups_v1.list(serverId, worldId.value!),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
+10
-6
@@ -7,16 +7,20 @@ import {
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { server, serverId } = injectModrinthServerContext()
|
||||
const { server, worldId } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
const flags = useFeatureFlags()
|
||||
const route = useNativeRoute()
|
||||
const initialPath = typeof route.query.path === 'string' ? route.query.path : '/'
|
||||
|
||||
try {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['files', serverId, '/'],
|
||||
queryFn: () => client.kyros.files_v0.listDirectory('/', 1, 2000),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
if (worldId.value) {
|
||||
await queryClient.ensureQueryData({
|
||||
queryKey: ['files', 'v1', worldId.value, initialPath],
|
||||
queryFn: () => client.kyros.files_v1.listDescendants(worldId.value!, initialPath, 1, 200),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Let mounted layouts' useQuery surface errors; do not fail route setup.
|
||||
}
|
||||
+1
-1
@@ -38,7 +38,7 @@ const contentWorldId = await getContentWorldId()
|
||||
if (contentWorldId) {
|
||||
try {
|
||||
const content = await queryClient.ensureQueryData({
|
||||
queryKey: ['content', 'list', 'v1', serverId],
|
||||
queryKey: ['content', 'list', 'v1', serverId, contentWorldId],
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, contentWorldId, { from_modpack: false }),
|
||||
staleTime: 30_000,
|
||||
@@ -0,0 +1,242 @@
|
||||
<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 queryClient.fetchQuery({
|
||||
queryKey: ['content', 'list', 'v1', serverId, world.id],
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, world.id, {
|
||||
from_modpack: false,
|
||||
}),
|
||||
staleTime: 0,
|
||||
})
|
||||
|
||||
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: await getInstalledContentCount(world.id, content),
|
||||
}
|
||||
} catch {
|
||||
return createDummyContentSummary(world, index)
|
||||
}
|
||||
}
|
||||
|
||||
async function getInstalledContentCount(
|
||||
worldId: string,
|
||||
content: Archon.Content.v1.Addons,
|
||||
): Promise<number> {
|
||||
const addonCount = content.addons?.length ?? 0
|
||||
if (!content.modpack) return addonCount
|
||||
|
||||
try {
|
||||
const modpackContent = await queryClient.fetchQuery({
|
||||
queryKey: ['content', 'list', 'v1', serverId, worldId, 'modpack'],
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(serverId, worldId, {
|
||||
from_modpack: true,
|
||||
}),
|
||||
staleTime: 0,
|
||||
})
|
||||
return addonCount + (modpackContent.addons?.length ?? 0)
|
||||
} catch {
|
||||
return addonCount
|
||||
}
|
||||
}
|
||||
|
||||
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 v-else>
|
||||
<div class="normal-page__header py-4">
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="organization.icon_url" :alt="organization.name" size="96px" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ organization.name }}
|
||||
</template>
|
||||
<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>
|
||||
<PageHeader
|
||||
:header="organization.name"
|
||||
:summary="organization.description"
|
||||
:leading="organizationHeaderLeading"
|
||||
:badges="organizationHeaderBadges"
|
||||
:metadata="organizationHeaderMetadata"
|
||||
:actions="organizationHeaderActions"
|
||||
/>
|
||||
</div>
|
||||
<div class="normal-page__sidebar">
|
||||
<AdPlaceholder v-if="!auth.user" />
|
||||
@@ -307,10 +235,9 @@ import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
ContentPageHeader,
|
||||
injectModrinthClient,
|
||||
NavTabs,
|
||||
OverflowMenu,
|
||||
PageHeader,
|
||||
PROJECT_DEP_MARKER_QUERY,
|
||||
ProjectCard,
|
||||
ProjectCardList,
|
||||
@@ -541,6 +468,93 @@ provideOrganizationContext(organizationContext)
|
||||
|
||||
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(
|
||||
[routeHasSettings, acceptedMembers, currentMember],
|
||||
() => {
|
||||
|
||||
@@ -120,35 +120,14 @@
|
||||
</NewModal>
|
||||
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
|
||||
<div class="normal-page__header py-4">
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar
|
||||
:src="user.avatar_url"
|
||||
:alt="user.username"
|
||||
:size="isModrinthUser ? '64px' : '96px'"
|
||||
circle
|
||||
/>
|
||||
</template>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-2">
|
||||
{{ user.username }}
|
||||
<BadgeCheckIcon
|
||||
v-if="isOfficialAccount"
|
||||
v-tooltip="formatMessage(messages.officialAccount)"
|
||||
class="size-5 text-brand"
|
||||
fill="var(--color-brand-highlight)"
|
||||
/>
|
||||
<TagItem
|
||||
v-if="isAdminViewing && isAffiliate"
|
||||
:style="{
|
||||
'--_color': 'var(--color-brand)',
|
||||
'--_bg-color': 'var(--color-brand-highlight)',
|
||||
}"
|
||||
>
|
||||
<AffiliateIcon /> Affiliate
|
||||
</TagItem>
|
||||
</span>
|
||||
</template>
|
||||
<PageHeader
|
||||
:header="user.username"
|
||||
:summary="isModrinthUser ? null : profileHeaderSummary"
|
||||
:leading="profileHeaderLeading"
|
||||
:badges="profileHeaderBadges"
|
||||
:metadata="profileHeaderMetadata"
|
||||
:actions="profileHeaderActions"
|
||||
>
|
||||
<template v-if="isModrinthUser" #summary>
|
||||
<IntlFormatted :message-id="messages.officialAccountBio">
|
||||
<template #support-link>
|
||||
@@ -173,159 +152,7 @@
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</template>
|
||||
<template v-else #summary>
|
||||
{{
|
||||
user.bio
|
||||
? user.bio
|
||||
: projects?.length > 0
|
||||
? formatMessage(messages.bioFallbackCreator)
|
||||
: formatMessage(messages.bioFallbackUser)
|
||||
}}
|
||||
</template>
|
||||
<template v-if="!isModrinthUser" #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: 'open-analytics',
|
||||
action: () =>
|
||||
navigateTo({
|
||||
path: '/dashboard/analytics',
|
||||
query: { user: user.username || user.id },
|
||||
}),
|
||||
shown: auth.user && isAdmin(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 #open-analytics>
|
||||
<ChartIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.analyticsButton) }}
|
||||
</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>
|
||||
</PageHeader>
|
||||
</div>
|
||||
<div class="normal-page__content">
|
||||
<div v-if="navLinks.length > 2" class="mb-4 max-w-full overflow-x-auto">
|
||||
@@ -535,17 +362,15 @@ import {
|
||||
ButtonStyled,
|
||||
Combobox,
|
||||
commonMessages,
|
||||
ContentPageHeader,
|
||||
defineMessages,
|
||||
injectModrinthClient,
|
||||
injectNotificationManager,
|
||||
IntlFormatted,
|
||||
NavTabs,
|
||||
NewModal,
|
||||
OverflowMenu,
|
||||
PageHeader,
|
||||
ProjectCard,
|
||||
ProjectCardList,
|
||||
TagItem,
|
||||
useCompactNumber,
|
||||
useFormatDateTime,
|
||||
useFormatNumber,
|
||||
@@ -585,8 +410,6 @@ const formatDateTime = useFormatDateTime({
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const baseId = useId()
|
||||
|
||||
const messages = defineMessages({
|
||||
profileProjectsLabel: {
|
||||
id: 'profile.label.projects',
|
||||
@@ -893,12 +716,194 @@ async function copyPermalink() {
|
||||
|
||||
const isAffiliate = computed(() => user.value?.badges & UserBadge.AFFILIATE)
|
||||
const isAdminViewing = computed(() => isAdmin(auth.value.user))
|
||||
const userDetailsModal = useTemplateRef('userDetailsModal')
|
||||
|
||||
async function toggleAffiliate(id) {
|
||||
await client.labrinth.users_v2.patch(id, { badges: user.value.badges ^ (1 << 7) })
|
||||
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: isModrinthUser.value ? '64px' : '96px',
|
||||
circle: true,
|
||||
}))
|
||||
|
||||
const profileHeaderBadges = computed(() => [
|
||||
...(isOfficialAccount.value
|
||||
? [
|
||||
{
|
||||
id: 'official',
|
||||
label: formatMessage(messages.officialAccount),
|
||||
icon: BadgeCheckIcon,
|
||||
iconProps: {
|
||||
fill: 'var(--color-brand-highlight)',
|
||||
},
|
||||
tooltip: formatMessage(messages.officialAccount),
|
||||
class: 'border-brand-highlight bg-brand-highlight text-brand',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(isAdminViewing.value && isAffiliate.value
|
||||
? [
|
||||
{
|
||||
id: 'affiliate',
|
||||
label: 'Affiliate',
|
||||
icon: AffiliateIcon,
|
||||
class: 'border-brand-highlight bg-brand-highlight text-brand',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
])
|
||||
|
||||
const profileHeaderMetadata = computed(() => {
|
||||
if (isModrinthUser.value) return []
|
||||
|
||||
return [
|
||||
{
|
||||
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: 'open-analytics',
|
||||
label: formatMessage(messages.analyticsButton),
|
||||
icon: ChartIcon,
|
||||
action: () =>
|
||||
navigateTo({
|
||||
path: '/dashboard/analytics',
|
||||
query: { user: user.value.username || user.value.id },
|
||||
}),
|
||||
shown: viewer && isAdmin(viewer),
|
||||
},
|
||||
{
|
||||
id: 'edit-role',
|
||||
label: formatMessage(messages.editRoleButton),
|
||||
icon: EditIcon,
|
||||
action: () => openRoleEditModal(),
|
||||
shown: viewer && isAdmin(viewer),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const navLinks = computed(() => [
|
||||
{
|
||||
label: formatMessage(commonMessages.allProjectType),
|
||||
|
||||
@@ -40,7 +40,8 @@ client.archon.servers_v1
|
||||
client.archon.backups_queue_v1
|
||||
client.archon.backups_v1
|
||||
client.archon.content_v0
|
||||
client.kyros.files_v0
|
||||
client.kyros.files_v1
|
||||
client.kyros.upload_sessions_v1
|
||||
client.iso3166.data
|
||||
... etc.
|
||||
```
|
||||
@@ -140,7 +141,8 @@ Uploads go through the feature chain (auth, retry, etc.). Features detect upload
|
||||
### Usage Example (server file upload)
|
||||
|
||||
```ts
|
||||
const uploader = client.kyros.files_v0.uploadFile(path, file, {
|
||||
await client.kyros.files_v1.ensureFile(worldId, path)
|
||||
const uploader = client.kyros.files_v1.uploadFile(worldId, path, file, {
|
||||
onProgress: ({ progress }) => {
|
||||
uploadProgress.value = Math.round(progress * 100)
|
||||
},
|
||||
|
||||
@@ -147,7 +147,8 @@ Built-in features include authentication, node auth, retries, circuit breaking,
|
||||
Upload endpoints return an `UploadHandle<T>` with progress and cancellation support:
|
||||
|
||||
```ts
|
||||
const upload = client.kyros.files_v0.uploadFile(path, file)
|
||||
await client.kyros.files_v1.ensureFile(worldId, path)
|
||||
const upload = client.kyros.files_v1.uploadFile(worldId, path, file)
|
||||
|
||||
upload.onProgress(({ progress }) => {
|
||||
console.log(Math.round(progress * 100))
|
||||
|
||||
@@ -170,6 +170,15 @@ export class ArchonContentV1Module extends AbstractModule {
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/content/reset-world */
|
||||
public async resetWorld(serverId: string, worldId: string): Promise<void> {
|
||||
await this.client.request<void>(`/servers/${serverId}/worlds/${worldId}/content/reset-world`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /v1/:server_id/worlds/:world_id/content/unlink-modpack */
|
||||
public async unlinkModpack(serverId: string, worldId: string): Promise<void> {
|
||||
await this.client.request<void>(
|
||||
|
||||
@@ -97,22 +97,6 @@ export class ArchonServersV0Module extends AbstractModule {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a power action to a server (Start, Stop, Restart, Kill)
|
||||
* POST /modrinth/v0/servers/:id/power
|
||||
*/
|
||||
public async power(
|
||||
serverId: string,
|
||||
action: 'Start' | 'Stop' | 'Restart' | 'Kill',
|
||||
): Promise<void> {
|
||||
await this.client.request(`/servers/${serverId}/power`, {
|
||||
api: 'archon',
|
||||
method: 'POST',
|
||||
version: 'modrinth/v0',
|
||||
body: { action },
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reinstall a server with a new loader or modpack
|
||||
* POST /modrinth/v0/servers/:id/reinstall
|
||||
|
||||
@@ -43,6 +43,54 @@ export class ArchonServersV1Module extends AbstractModule {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a world
|
||||
* POST /v1/servers/:id/worlds
|
||||
*/
|
||||
public async createWorld(
|
||||
serverId: string,
|
||||
request: Archon.Servers.v1.CreateWorld,
|
||||
): Promise<Archon.Servers.v1.CreateWorldResponse> {
|
||||
return this.client.request<Archon.Servers.v1.CreateWorldResponse>(
|
||||
`/servers/${serverId}/worlds`,
|
||||
{
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
body: request,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify a world
|
||||
* PATCH /v1/servers/:id/worlds/:wid
|
||||
*/
|
||||
public async patchWorld(
|
||||
serverId: string,
|
||||
worldId: string,
|
||||
request: Archon.Servers.v1.PatchWorld,
|
||||
): Promise<void> {
|
||||
await this.client.request(`/servers/${serverId}/worlds/${worldId}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'PATCH',
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a world
|
||||
* DELETE /v1/servers/:id/worlds/:wid
|
||||
*/
|
||||
public async deleteWorld(serverId: string, worldId: string): Promise<void> {
|
||||
await this.client.request(`/servers/${serverId}/worlds/${worldId}`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* End the intro flow for a server
|
||||
* DELETE /v1/servers/:id/flows/intro
|
||||
@@ -56,14 +104,36 @@ export class ArchonServersV1Module extends AbstractModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a world to onboarding
|
||||
* POST /v1/servers/:id/worlds/:wid/onboard
|
||||
* Run a power action for a specific world
|
||||
* POST /v1/servers/:id/worlds/:wid/power
|
||||
*/
|
||||
public async resetToOnboarding(serverId: string, worldId: string): Promise<void> {
|
||||
await this.client.request(`/servers/${serverId}/worlds/${worldId}/onboard`, {
|
||||
public async powerWorld(
|
||||
serverId: string,
|
||||
worldId: string,
|
||||
request: Archon.Servers.v1.WorldPowerActionRequest,
|
||||
): Promise<void> {
|
||||
await this.client.request(`/servers/${serverId}/worlds/${worldId}/power`, {
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
body: request,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset a server to onboarding
|
||||
* POST /v1/servers/:id/onboard
|
||||
*/
|
||||
public async resetToOnboarding(
|
||||
serverId: string,
|
||||
): Promise<Archon.Servers.v1.ServerOnboardResponse> {
|
||||
return this.client.request<Archon.Servers.v1.ServerOnboardResponse>(
|
||||
`/servers/${serverId}/onboard`,
|
||||
{
|
||||
api: 'archon',
|
||||
version: 1,
|
||||
method: 'POST',
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,6 +713,13 @@ export namespace Archon {
|
||||
}
|
||||
|
||||
export namespace v1 {
|
||||
export type WorldPowerAction = 'start' | 'stop' | 'restart' | 'kill'
|
||||
|
||||
export type WorldPowerActionRequest = {
|
||||
action: WorldPowerAction
|
||||
shutdown_strategy?: string | null
|
||||
}
|
||||
|
||||
export type ServerFull = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -725,6 +732,10 @@ export namespace Archon {
|
||||
worlds: WorldFull[]
|
||||
}
|
||||
|
||||
export type ServerOnboardResponse = {
|
||||
archived_worlds: string[]
|
||||
}
|
||||
|
||||
export type ServerResources = {
|
||||
cpu: number
|
||||
memory_mb: number
|
||||
@@ -759,6 +770,34 @@ export namespace Archon {
|
||||
readiness: WorldReadiness
|
||||
}
|
||||
|
||||
export type WorldContent =
|
||||
| {
|
||||
content_variant: 'modpack'
|
||||
spec: Archon.Content.v1.ModpackSpec
|
||||
}
|
||||
| {
|
||||
content_variant: 'bare'
|
||||
loader: Archon.Content.v1.Modloader
|
||||
version: string
|
||||
game_version?: string | null
|
||||
}
|
||||
|
||||
export type CreateWorld = {
|
||||
name: string
|
||||
icon_data?: string | null
|
||||
properties?: Archon.Content.v1.PropertiesFields | null
|
||||
content: WorldContent
|
||||
}
|
||||
|
||||
export type CreateWorldResponse = {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type PatchWorld = {
|
||||
name?: string | null
|
||||
icon_data?: string | null
|
||||
}
|
||||
|
||||
export type WorldReadiness = {
|
||||
data_synchronized_fetched: boolean
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import { ArchonServersV1Module } from './archon/servers/v1'
|
||||
import { ArchonTransfersInternalModule } from './archon/transfers/internal'
|
||||
import { ISO3166Module } from './iso3166'
|
||||
import { KyrosContentV1Module } from './kyros/content/v1'
|
||||
import { KyrosFilesV0Module } from './kyros/files/v0'
|
||||
import { KyrosFilesV1Module } from './kyros/files/v1'
|
||||
import { KyrosLogsV1Module } from './kyros/logs/v1'
|
||||
import { KyrosUploadSessionsV1Module } from './kyros/upload-sessions/v1'
|
||||
import { LabrinthVersionsV2Module, LabrinthVersionsV3Module } from './labrinth'
|
||||
@@ -87,7 +87,7 @@ export const MODULE_REGISTRY = {
|
||||
mclogs_logs_v1: MclogsLogsV1Module,
|
||||
launchermeta_manifest_v0: LauncherMetaManifestV0Module,
|
||||
kyros_content_v1: KyrosContentV1Module,
|
||||
kyros_files_v0: KyrosFilesV0Module,
|
||||
kyros_files_v1: KyrosFilesV1Module,
|
||||
kyros_logs_v1: KyrosLogsV1Module,
|
||||
kyros_upload_sessions_v1: KyrosUploadSessionsV1Module,
|
||||
labrinth_affiliate_internal: LabrinthAffiliateInternalModule,
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle, UploadProgress } from '../../../types/upload'
|
||||
import { getNodeBaseUrl } from '../../../utils/node-url'
|
||||
import type { Archon } from '../../archon/types'
|
||||
import type { Kyros } from '../types'
|
||||
|
||||
type NodeFsAuth = Pick<Archon.Servers.v0.JWTAuth, 'url' | 'token'>
|
||||
|
||||
export class KyrosFilesV0Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'kyros_files_v0'
|
||||
}
|
||||
|
||||
private getNodeBaseUrl(auth: NodeFsAuth): string {
|
||||
return getNodeBaseUrl(auth.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* List directory contents with pagination
|
||||
*
|
||||
* @param path - Directory path (e.g., "/")
|
||||
* @param page - Page number (1-indexed)
|
||||
* @param pageSize - Items per page
|
||||
* @returns Directory listing with items and pagination info
|
||||
*/
|
||||
public async listDirectory(
|
||||
path: string,
|
||||
page: number = 1,
|
||||
pageSize: number = 100,
|
||||
): Promise<Kyros.Files.v0.DirectoryResponse> {
|
||||
return this.client.request<Kyros.Files.v0.DirectoryResponse>('/fs/list', {
|
||||
api: '',
|
||||
version: 'modrinth/v0',
|
||||
method: 'GET',
|
||||
params: { path, page, page_size: pageSize },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a file or directory
|
||||
*
|
||||
* @param path - Path for new item (e.g., "/new-folder")
|
||||
* @param type - Type of item to create
|
||||
*/
|
||||
public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
|
||||
return this.client.request<void>('/fs/create', {
|
||||
api: '',
|
||||
version: 'modrinth/v0',
|
||||
method: 'POST',
|
||||
params: { path, type },
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from a server's filesystem
|
||||
*
|
||||
* @param path - File path (e.g., "/server-icon-original.png")
|
||||
* @returns Promise resolving to file Blob
|
||||
*/
|
||||
public async downloadFile(path: string): Promise<Blob> {
|
||||
return this.client.request<Blob>('/fs/download', {
|
||||
api: '',
|
||||
version: 'modrinth/v0',
|
||||
method: 'GET',
|
||||
params: { path },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file using explicit filesystem auth credentials.
|
||||
*
|
||||
* @param auth - Filesystem auth (url + token) from Archon
|
||||
* @param path - File path (e.g., "/server-icon.png")
|
||||
* @returns Promise resolving to file Blob
|
||||
*/
|
||||
public async downloadFileWithAuth(auth: NodeFsAuth, path: string): Promise<Blob> {
|
||||
return this.client.request<Blob>('/fs/download', {
|
||||
api: this.getNodeBaseUrl(auth),
|
||||
version: 'modrinth/v0',
|
||||
method: 'GET',
|
||||
params: { path },
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
skipAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to a server's filesystem with progress tracking
|
||||
*
|
||||
* @param path - Destination path (e.g., "/server-icon.png")
|
||||
* @param file - File to upload
|
||||
* @param options - Optional progress callback and feature overrides
|
||||
* @returns UploadHandle with promise, onProgress, and cancel
|
||||
* @deprecated Use `kyros.upload_sessions_v1` for bulk uploads so cancellation can remove staged files before finalize.
|
||||
*/
|
||||
public uploadFile(
|
||||
path: string,
|
||||
file: File | Blob,
|
||||
options?: {
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
retry?: boolean | number
|
||||
},
|
||||
): UploadHandle<void> {
|
||||
return this.client.upload<void>('/fs/create', {
|
||||
api: '',
|
||||
version: 'modrinth/v0',
|
||||
file,
|
||||
params: { path, type: 'file' },
|
||||
onProgress: options?.onProgress,
|
||||
retry: options?.retry,
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file using explicit filesystem auth credentials.
|
||||
*
|
||||
* @param auth - Filesystem auth (url + token) from Archon
|
||||
* @param path - Destination path (e.g., "/server-icon.png")
|
||||
* @param file - File to upload
|
||||
* @param options - Optional progress callback and feature overrides
|
||||
* @returns UploadHandle with promise, onProgress, and cancel
|
||||
*/
|
||||
public uploadFileWithAuth(
|
||||
auth: NodeFsAuth,
|
||||
path: string,
|
||||
file: File | Blob,
|
||||
options?: {
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
retry?: boolean | number
|
||||
},
|
||||
): UploadHandle<void> {
|
||||
return this.client.upload<void>('/fs/create', {
|
||||
api: this.getNodeBaseUrl(auth),
|
||||
version: 'modrinth/v0',
|
||||
file,
|
||||
params: { path, type: 'file' },
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
onProgress: options?.onProgress,
|
||||
retry: options?.retry,
|
||||
skipAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update file contents
|
||||
*
|
||||
* @param path - File path to update
|
||||
* @param content - New file content (string or Blob)
|
||||
*/
|
||||
public async updateFile(path: string, content: string | Blob): Promise<void> {
|
||||
const blob = typeof content === 'string' ? new Blob([content]) : content
|
||||
|
||||
return this.client.request<void>('/fs/update', {
|
||||
api: '',
|
||||
version: 'modrinth/v0',
|
||||
method: 'PUT',
|
||||
params: { path },
|
||||
body: blob,
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file or folder to a new location
|
||||
*
|
||||
* @param sourcePath - Current path
|
||||
* @param destPath - New path
|
||||
*/
|
||||
public async moveFileOrFolder(sourcePath: string, destPath: string): Promise<void> {
|
||||
return this.client.request<void>('/fs/move', {
|
||||
api: '',
|
||||
version: 'modrinth/v0',
|
||||
method: 'POST',
|
||||
body: { source: sourcePath, destination: destPath },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a file or folder (convenience wrapper around move)
|
||||
*
|
||||
* @param path - Current file/folder path
|
||||
* @param newName - New name (not full path)
|
||||
*/
|
||||
public async renameFileOrFolder(path: string, newName: string): Promise<void> {
|
||||
const newPath = path.split('/').slice(0, -1).join('/') + '/' + newName
|
||||
return this.moveFileOrFolder(path, newPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file or folder
|
||||
*
|
||||
* @param path - Path to delete
|
||||
* @param recursive - If true, delete directory contents recursively
|
||||
*/
|
||||
public async deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
|
||||
return this.client.request<void>('/fs/delete', {
|
||||
api: '',
|
||||
version: 'modrinth/v0',
|
||||
method: 'DELETE',
|
||||
params: { path, recursive },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file or folder using explicit filesystem auth credentials.
|
||||
*
|
||||
* @param auth - Filesystem auth (url + token) from Archon
|
||||
* @param path - Path to delete
|
||||
* @param recursive - If true, delete directory contents recursively
|
||||
*/
|
||||
public async deleteFileOrFolderWithAuth(
|
||||
auth: NodeFsAuth,
|
||||
path: string,
|
||||
recursive: boolean,
|
||||
): Promise<void> {
|
||||
return this.client.request<void>('/fs/delete', {
|
||||
api: this.getNodeBaseUrl(auth),
|
||||
version: 'modrinth/v0',
|
||||
method: 'DELETE',
|
||||
params: { path, recursive },
|
||||
headers: { Authorization: `Bearer ${auth.token}` },
|
||||
skipAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract an archive file (zip, tar, etc.)
|
||||
*
|
||||
* Uses v1 API endpoint.
|
||||
*
|
||||
* @param path - Path to archive file
|
||||
* @param override - If true, overwrite existing files
|
||||
* @param dry - If true, perform dry run (returns conflicts without extracting)
|
||||
* @returns Extract result with modpack name and conflicting files
|
||||
*/
|
||||
public async extractFile(
|
||||
path: string,
|
||||
override: boolean = true,
|
||||
dry: boolean = false,
|
||||
): Promise<Kyros.Files.v0.ExtractResult> {
|
||||
return this.client.request<Kyros.Files.v0.ExtractResult>('/fs/unarchive', {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
params: { src: path, trg: '/', override, dry },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify a filesystem operation (dismiss or cancel)
|
||||
*
|
||||
* Uses v1 API endpoint.
|
||||
*
|
||||
* @param opId - Operation ID (UUID)
|
||||
* @param action - Action to perform
|
||||
*/
|
||||
public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise<void> {
|
||||
return this.client.request<void>(`/fs/ops/${action}`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
params: { id: opId },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle, UploadProgress } from '../../../types/upload'
|
||||
import type { Kyros } from '../types'
|
||||
|
||||
export class KyrosFilesV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'kyros_files_v1'
|
||||
}
|
||||
|
||||
private isConflict(error: unknown): boolean {
|
||||
const err = error as { statusCode?: number; response?: { status?: number } }
|
||||
return (err.statusCode ?? err.response?.status) === 409
|
||||
}
|
||||
|
||||
public async createDownloadSession(
|
||||
worldId: string,
|
||||
path: string,
|
||||
zipped: boolean,
|
||||
): Promise<void> {
|
||||
return this.client.request<void>(`/worlds/${worldId}/files/contents`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
body: { path, zipped },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async listDescendants(
|
||||
worldId: string,
|
||||
path: string,
|
||||
page: number = 1,
|
||||
itemsPerPage: number = 100,
|
||||
): Promise<Kyros.Files.v1.FileListingResponse> {
|
||||
return this.client.request<Kyros.Files.v1.FileListingResponse>(
|
||||
`/worlds/${worldId}/files/list`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
body: {
|
||||
path,
|
||||
page,
|
||||
items_per_page: itemsPerPage,
|
||||
},
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async downloadRawFileContents(worldId: string, path: string): Promise<Blob> {
|
||||
return this.client.request<Blob>(`/worlds/${worldId}/files/contents-raw`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'GET',
|
||||
params: { path },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async downloadTokenizedContents(worldId: string, downloadId: string): Promise<Blob> {
|
||||
return this.client.request<Blob>(`/worlds/${worldId}/files/contents/${downloadId}`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'GET',
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async editFile(worldId: string, path: string, content: string | Blob): Promise<void> {
|
||||
const body = typeof content === 'string' ? new Blob([content]) : content
|
||||
|
||||
return this.client.request<void>(`/worlds/${worldId}/files/edit`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
params: { path },
|
||||
body,
|
||||
headers: { 'Content-Type': 'application/octet-stream' },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public uploadFile(
|
||||
worldId: string,
|
||||
path: string,
|
||||
file: File | Blob,
|
||||
options?: {
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
retry?: boolean | number
|
||||
},
|
||||
): UploadHandle<void> {
|
||||
return this.client.upload<void>(`/worlds/${worldId}/files/edit`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
file,
|
||||
params: { path },
|
||||
onProgress: options?.onProgress,
|
||||
retry: options?.retry,
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async touchFile(worldId: string, path: string): Promise<void> {
|
||||
return this.client.request<void>(`/worlds/${worldId}/files/touch`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
body: { path },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async mkdirFile(worldId: string, path: string): Promise<void> {
|
||||
return this.client.request<void>(`/worlds/${worldId}/files/mkdir`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
body: { path },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async ensureFile(worldId: string, path: string): Promise<void> {
|
||||
try {
|
||||
await this.touchFile(worldId, path)
|
||||
} catch (error) {
|
||||
if (!this.isConflict(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async deleteFile(worldId: string, path: string): Promise<void> {
|
||||
return this.client.request<void>(`/worlds/${worldId}/files/delete`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
body: { path },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async moveFile(
|
||||
worldId: string,
|
||||
source: string,
|
||||
destination: string,
|
||||
): Promise<Kyros.Files.v1.FileMutationResponse> {
|
||||
return this.client.request<Kyros.Files.v1.FileMutationResponse>(
|
||||
`/worlds/${worldId}/files/move`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
body: { source, destination },
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public async renameFile(
|
||||
worldId: string,
|
||||
path: string,
|
||||
name: string,
|
||||
): Promise<Kyros.Files.v1.FileMutationResponse> {
|
||||
return this.client.request<Kyros.Files.v1.FileMutationResponse>(
|
||||
`/worlds/${worldId}/files/rename`,
|
||||
{
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
body: { path, name },
|
||||
useNodeAuth: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
public unzipFile(
|
||||
worldId: string,
|
||||
request: Kyros.Files.v1.UnzipFileRequest,
|
||||
): Promise<ReadableStream<Uint8Array>> {
|
||||
return this.client.stream(`/worlds/${worldId}/files/unzip`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
body: request,
|
||||
headers: { Accept: 'application/json-seq' },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public uploadZip(
|
||||
worldId: string,
|
||||
path: string,
|
||||
file: File | Blob,
|
||||
options?: {
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
retry?: boolean | number
|
||||
},
|
||||
): UploadHandle<void> {
|
||||
return this.client.upload<void>(`/worlds/${worldId}/files/upload-zip`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
file,
|
||||
params: { path },
|
||||
headers: { 'Content-Type': 'application/zip' },
|
||||
onProgress: options?.onProgress,
|
||||
retry: options?.retry,
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
|
||||
public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise<void> {
|
||||
return this.client.request<void>(`/fs/ops/${action}`, {
|
||||
api: '',
|
||||
version: 'v1',
|
||||
method: 'POST',
|
||||
params: { id: opId },
|
||||
useNodeAuth: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,53 +2,101 @@ export namespace Kyros {
|
||||
export namespace UploadSessions {
|
||||
export namespace v1 {
|
||||
export type Scope = 'content' | 'files'
|
||||
export type UploadSessionStatus =
|
||||
| 'active'
|
||||
| 'uploading'
|
||||
| 'finalizing'
|
||||
| 'cancelled'
|
||||
| 'finalized'
|
||||
| 'expired'
|
||||
|
||||
export type UploadSessionFile = {
|
||||
file: File | Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
export interface UploadSessionResponse {
|
||||
upload_id: string
|
||||
status: UploadSessionStatus
|
||||
status: string
|
||||
created_at: number
|
||||
updated_at: number
|
||||
last_upload_at: number | null
|
||||
last_upload_at?: number | null
|
||||
expires_at: number
|
||||
entry_count: number
|
||||
uploaded_byte_count: number
|
||||
}
|
||||
|
||||
export interface GetUploadSessionResponse {
|
||||
session: UploadSessionResponse | null
|
||||
session?: UploadSessionResponse | null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace Files {
|
||||
export namespace v0 {
|
||||
export interface DirectoryItem {
|
||||
name: string
|
||||
type: 'file' | 'directory' | 'symlink'
|
||||
export namespace v1 {
|
||||
export type DescendantType = 'regular' | 'directory' | 'symlink' | 'other'
|
||||
|
||||
export type UnzipSource =
|
||||
| {
|
||||
type: 'zip_url'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
type: 'zip_path'
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface CreateDownloadSessionRequest {
|
||||
path: string
|
||||
modified: number
|
||||
created: number
|
||||
size?: number
|
||||
count?: number
|
||||
target?: string
|
||||
zipped: boolean
|
||||
}
|
||||
|
||||
export interface DirectoryResponse {
|
||||
items: DirectoryItem[]
|
||||
total: number
|
||||
current: number
|
||||
export interface DeleteFileRequest {
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface ExtractResult {
|
||||
modpack_name: string | null
|
||||
conflicting_files: string[]
|
||||
export interface FileListingItem {
|
||||
name: string
|
||||
full_path: string
|
||||
size_bytes: number
|
||||
type: DescendantType
|
||||
mtime: string
|
||||
ctime: string
|
||||
descendants: number
|
||||
}
|
||||
|
||||
export interface FileListingRequest {
|
||||
path: string
|
||||
page: number
|
||||
items_per_page: number
|
||||
}
|
||||
|
||||
export interface FileListingResponse {
|
||||
items: FileListingItem[]
|
||||
page: number
|
||||
items_per_page: number
|
||||
page_total: number
|
||||
items_total: number
|
||||
too_many_descendants: boolean
|
||||
descendants_limit: number
|
||||
digest?: string | null
|
||||
}
|
||||
|
||||
export interface FileMutationResponse {
|
||||
source: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
export interface MoveFileRequest {
|
||||
source: string
|
||||
destination: string
|
||||
}
|
||||
|
||||
export interface RenameFileRequest {
|
||||
path: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface PathMutationRequest {
|
||||
path: string
|
||||
}
|
||||
|
||||
export interface UnzipFileRequest {
|
||||
source: UnzipSource
|
||||
target: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,6 @@ import { AbstractModule } from '../../../core/abstract-module'
|
||||
import type { UploadHandle, UploadProgress } from '../../../types/upload'
|
||||
import type { Kyros } from '../types'
|
||||
|
||||
export type UploadSessionFile = {
|
||||
file: File | Blob
|
||||
filename: string
|
||||
}
|
||||
|
||||
export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
public getModuleID(): string {
|
||||
return 'kyros_upload_sessions_v1'
|
||||
@@ -46,7 +41,7 @@ export class KyrosUploadSessionsV1Module extends AbstractModule {
|
||||
scope: Kyros.UploadSessions.v1.Scope,
|
||||
worldId: string,
|
||||
uploadId: string,
|
||||
files: UploadSessionFile[],
|
||||
files: Kyros.UploadSessions.v1.UploadSessionFile[],
|
||||
options?: {
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
retry?: boolean | number
|
||||
|
||||
@@ -111,6 +111,7 @@ import _DownloadIcon from './icons/download.svg?component'
|
||||
import _DropdownIcon from './icons/dropdown.svg?component'
|
||||
import _EditIcon from './icons/edit.svg?component'
|
||||
import _EllipsisVerticalIcon from './icons/ellipsis-vertical.svg?component'
|
||||
import _EraserIcon from './icons/eraser.svg?component'
|
||||
import _ExpandIcon from './icons/expand.svg?component'
|
||||
import _ExternalIcon from './icons/external.svg?component'
|
||||
import _EyeIcon from './icons/eye.svg?component'
|
||||
@@ -220,6 +221,7 @@ import _RadioButtonIcon from './icons/radio-button.svg?component'
|
||||
import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
|
||||
import _ReceiptTextIcon from './icons/receipt-text.svg?component'
|
||||
import _RedoIcon from './icons/redo.svg?component'
|
||||
import _RefreshCcwIcon from './icons/refresh-ccw.svg?component'
|
||||
import _RefreshCwIcon from './icons/refresh-cw.svg?component'
|
||||
import _ReplyIcon from './icons/reply.svg?component'
|
||||
import _ReportIcon from './icons/report.svg?component'
|
||||
@@ -542,6 +544,7 @@ export const DownloadIcon = _DownloadIcon
|
||||
export const DropdownIcon = _DropdownIcon
|
||||
export const EditIcon = _EditIcon
|
||||
export const EllipsisVerticalIcon = _EllipsisVerticalIcon
|
||||
export const EraserIcon = _EraserIcon
|
||||
export const ExpandIcon = _ExpandIcon
|
||||
export const ExternalIcon = _ExternalIcon
|
||||
export const EyeIcon = _EyeIcon
|
||||
@@ -651,6 +654,7 @@ export const RadioButtonIcon = _RadioButtonIcon
|
||||
export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
|
||||
export const ReceiptTextIcon = _ReceiptTextIcon
|
||||
export const RedoIcon = _RedoIcon
|
||||
export const RefreshCcwIcon = _RefreshCcwIcon
|
||||
export const RefreshCwIcon = _RefreshCwIcon
|
||||
export const ReplyIcon = _ReplyIcon
|
||||
export const ReportIcon = _ReportIcon
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eraser-icon lucide-eraser"><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg>
|
||||
|
After Width: | Height: | Size: 373 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-ccw-icon lucide-refresh-ccw"><path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16"/><path d="M16 16h5v5"/></svg>
|
||||
|
After Width: | Height: | Size: 413 B |
@@ -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>
|
||||
@@ -17,6 +17,7 @@ const props = defineProps<{
|
||||
ariaLabel?: string
|
||||
belowModal?: boolean
|
||||
hideWhenModalOpen?: boolean
|
||||
toolbarMaxWidth?: string
|
||||
}>()
|
||||
|
||||
const INTERCOM_BUBBLE_GAP = 8
|
||||
@@ -46,6 +47,11 @@ const barStyle = computed(() => ({
|
||||
'--floating-action-bar-left-offset': leftOffset.value,
|
||||
'--floating-action-bar-right-offset': rightOffset.value,
|
||||
}))
|
||||
const toolbarStyle = computed(() =>
|
||||
props.toolbarMaxWidth
|
||||
? { '--floating-action-bar-toolbar-max-width': props.toolbarMaxWidth }
|
||||
: undefined,
|
||||
)
|
||||
|
||||
function checkCompact() {
|
||||
const el = toolbarEl.value
|
||||
@@ -203,8 +209,9 @@ onUnmounted(() => {
|
||||
ref="toolbarEl"
|
||||
role="toolbar"
|
||||
:aria-label="ariaLabel"
|
||||
class="relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto md:max-w-[60vw] px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
|
||||
class="floating-action-toolbar relative overflow-clip flex items-center gap-1.5 rounded-[20px] bg-surface-3 border border-surface-5 border-solid mx-auto px-3 py-2.5 shadow-[0px_1px_3px_0px_rgba(0,0,0,0.3),0px_6px_10px_0px_rgba(0,0,0,0.15)]"
|
||||
:class="{ 'bar-compact': compact }"
|
||||
:style="toolbarStyle"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
@@ -220,6 +227,12 @@ onUnmounted(() => {
|
||||
transition: bottom 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.floating-action-toolbar {
|
||||
max-width: var(--floating-action-bar-toolbar-max-width, 60vw);
|
||||
}
|
||||
}
|
||||
|
||||
.floating-action-bar-enter-active {
|
||||
transition:
|
||||
transform 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
|
||||
|
||||
@@ -33,7 +33,8 @@ import { DropdownIcon } from '@modrinth/assets'
|
||||
import type { Component } 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.
|
||||
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
|
||||
@@ -6,40 +6,138 @@
|
||||
:class="{ 'drop-shadow-xl border border-solid border-surface-4': mode === 'navigation' }"
|
||||
>
|
||||
<template v-if="mode === 'navigation'">
|
||||
<RouterLink
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
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 v-for="(link, index) in filteredLinks" :key="link.href">
|
||||
<Tooltip
|
||||
v-if="link.prompt"
|
||||
theme="dismissable-prompt"
|
||||
:triggers="[]"
|
||||
:shown="link.prompt.shown"
|
||||
:auto-hide="false"
|
||||
:placement="link.prompt.placement ?? 'bottom'"
|
||||
>
|
||||
<RouterLink
|
||||
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?.()"
|
||||
@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 v-else>
|
||||
<div
|
||||
v-for="(link, index) in filteredLinks"
|
||||
v-show="link.shown ?? true"
|
||||
:key="link.href"
|
||||
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="emit('tabClick', 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 v-for="(link, index) in filteredLinks" :key="link.href">
|
||||
<Tooltip
|
||||
v-if="link.prompt"
|
||||
theme="dismissable-prompt"
|
||||
:triggers="[]"
|
||||
:shown="link.prompt.shown"
|
||||
:auto-hide="false"
|
||||
:placement="link.prompt.placement ?? 'bottom'"
|
||||
>
|
||||
<div
|
||||
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 #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>
|
||||
|
||||
<!-- Animated slider background -->
|
||||
@@ -57,12 +155,25 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
interface TabPrompt {
|
||||
title: string
|
||||
description: string
|
||||
dismissLabel?: string
|
||||
shown?: boolean
|
||||
placement?: string
|
||||
onDismiss?: () => void
|
||||
}
|
||||
|
||||
interface Tab {
|
||||
label: string
|
||||
href: string
|
||||
@@ -70,6 +181,7 @@ interface Tab {
|
||||
icon?: Component
|
||||
subpages?: string[]
|
||||
onHover?: () => void
|
||||
prompt?: TabPrompt
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -131,6 +243,17 @@ const isActiveAndNotSubpage = computed(
|
||||
() => (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) {
|
||||
if (sliderReady.value) return {}
|
||||
if (currentActiveIndex.value !== index) return {}
|
||||
|
||||
@@ -1,11 +1,679 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[min-content_1fr_auto] gap-4">
|
||||
<slot name="icon" />
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<slot name="title" />
|
||||
<slot name="summary" />
|
||||
<slot name="stats" />
|
||||
<div
|
||||
class="flex flex-col gap-2"
|
||||
:class="[
|
||||
props.divider ? 'border-0 border-b border-solid border-divider' : '',
|
||||
props.bottomPadding ? 'pb-4' : '',
|
||||
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 || $slots.summary"
|
||||
class="m-0 max-w-[44rem] empty:hidden"
|
||||
:class="[props.disableLineClamp ? '' : 'line-clamp-2']"
|
||||
>
|
||||
<slot name="summary">{{ props.summary }}</slot>
|
||||
</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 text-current"
|
||||
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 text-current"
|
||||
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 text-current"
|
||||
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 text-current"
|
||||
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 text-current"
|
||||
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 text-current"
|
||||
aria-hidden="true"
|
||||
v-bind="item.iconProps"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -31,6 +31,7 @@ const props = withDefaults(
|
||||
maxVisibleBehind?: number
|
||||
dismissAllEnabled?: boolean
|
||||
expanded?: boolean
|
||||
animateSingleItem?: boolean
|
||||
}>(),
|
||||
{
|
||||
peek: 8,
|
||||
@@ -41,6 +42,7 @@ const props = withDefaults(
|
||||
maxVisibleBehind: 2,
|
||||
dismissAllEnabled: true,
|
||||
expanded: undefined,
|
||||
animateSingleItem: true,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -71,6 +73,7 @@ const internalExpanded = ref(false)
|
||||
const isHovered = ref(false)
|
||||
const prefersReducedMotion = ref(false)
|
||||
const initialMeasurementSettled = ref(false)
|
||||
const itemEntrancesEnabled = ref(false)
|
||||
const enteringItemIds = ref<Set<string>>(new Set())
|
||||
const actionBarHeight = ref(0)
|
||||
|
||||
@@ -105,6 +108,7 @@ function scheduleHeightFlush() {
|
||||
initialMeasurementHandle = requestAnimationFrame(() => {
|
||||
initialMeasurementHandle = null
|
||||
initialMeasurementSettled.value = true
|
||||
itemEntrancesEnabled.value = true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -126,6 +130,12 @@ const phase = ref<StackPhase>(isExpanded.value ? 'expanded' : 'collapsed')
|
||||
const isSettledCollapsed = computed(() => phase.value === 'collapsed')
|
||||
const containerHeightSettled = ref(true)
|
||||
const singleItemEntrance = ref(false)
|
||||
const singleItemAnimationDisabled = computed(
|
||||
() => !props.animateSingleItem && props.items.length <= 1,
|
||||
)
|
||||
const instantSingleItem = computed(() =>
|
||||
!props.animateSingleItem && props.items.length === 1 ? props.items[0]! : null,
|
||||
)
|
||||
|
||||
// Behind cards morph between a collapsed placeholder and real content. The shell
|
||||
// height owns that morph so mixed-height cards do not swap DOM midway through motion.
|
||||
@@ -181,20 +191,30 @@ const containerOverflow = computed(() => {
|
||||
})
|
||||
|
||||
const springTransition = computed(() =>
|
||||
prefersReducedMotion.value || !initialMeasurementSettled.value
|
||||
prefersReducedMotion.value ||
|
||||
!initialMeasurementSettled.value ||
|
||||
singleItemAnimationDisabled.value
|
||||
? { duration: 0 }
|
||||
: { type: 'spring' as const, stiffness: 260, damping: 32 },
|
||||
)
|
||||
const heightTransition = computed(() =>
|
||||
singleItemEntrance.value ? { duration: 0.12, ease: 'easeOut' as const } : springTransition.value,
|
||||
singleItemAnimationDisabled.value
|
||||
? { duration: 0 }
|
||||
: singleItemEntrance.value
|
||||
? { duration: 0.12, ease: 'easeOut' as const }
|
||||
: springTransition.value,
|
||||
)
|
||||
|
||||
const exitTransition = computed(() =>
|
||||
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.18 },
|
||||
prefersReducedMotion.value || singleItemAnimationDisabled.value
|
||||
? { duration: 0 }
|
||||
: { duration: 0.18 },
|
||||
)
|
||||
|
||||
const shellExitTransition = computed(() =>
|
||||
prefersReducedMotion.value ? { duration: 0 } : { duration: 0.16 },
|
||||
prefersReducedMotion.value || singleItemAnimationDisabled.value
|
||||
? { duration: 0 }
|
||||
: { duration: 0.16 },
|
||||
)
|
||||
|
||||
function collapsedCardPosition(index: number) {
|
||||
@@ -217,7 +237,7 @@ function expandedCardPosition(index: number) {
|
||||
function cardPosition(index: number) {
|
||||
const position = isExpanded.value ? expandedCardPosition(index) : collapsedCardPosition(index)
|
||||
const item = props.items[index]
|
||||
if (index === 0 && singleItemEntrance.value) {
|
||||
if (index === 0 && singleItemEntrance.value && !singleItemAnimationDisabled.value) {
|
||||
return {
|
||||
...position,
|
||||
opacity: 0,
|
||||
@@ -240,7 +260,13 @@ function contentOpacity(index: number) {
|
||||
// Newly inserted cards need an explicit two-frame enter target because Motion's
|
||||
// initial state is disabled to avoid animating from zero-height on first mount.
|
||||
function markEntering(ids: string[]) {
|
||||
if (!initialMeasurementSettled.value || prefersReducedMotion.value || ids.length === 0) return
|
||||
if (
|
||||
!itemEntrancesEnabled.value ||
|
||||
prefersReducedMotion.value ||
|
||||
singleItemAnimationDisabled.value ||
|
||||
ids.length === 0
|
||||
)
|
||||
return
|
||||
|
||||
const next = new Set(enteringItemIds.value)
|
||||
for (const id of ids) next.add(id)
|
||||
@@ -361,9 +387,15 @@ function onCardClick(e: MouseEvent) {
|
||||
watch(
|
||||
() => props.items.length,
|
||||
(n, previousLength) => {
|
||||
if (previousLength === 0 && n === 1 && !prefersReducedMotion.value) {
|
||||
if (
|
||||
previousLength === 0 &&
|
||||
n === 1 &&
|
||||
itemEntrancesEnabled.value &&
|
||||
!prefersReducedMotion.value &&
|
||||
props.animateSingleItem
|
||||
) {
|
||||
singleItemEntrance.value = true
|
||||
} else if (n !== 1) {
|
||||
} else if (n !== 1 || !props.animateSingleItem) {
|
||||
singleItemEntrance.value = false
|
||||
}
|
||||
|
||||
@@ -386,16 +418,23 @@ watch(isExpanded, (expanded, previousExpanded) => {
|
||||
watch(containerHeight, (height, previousHeight) => {
|
||||
if (height !== previousHeight) {
|
||||
const openingSingleItem =
|
||||
previousHeight === 0 && height > 0 && props.items.length === 1 && !prefersReducedMotion.value
|
||||
previousHeight === 0 &&
|
||||
height > 0 &&
|
||||
props.items.length === 1 &&
|
||||
itemEntrancesEnabled.value &&
|
||||
!prefersReducedMotion.value &&
|
||||
props.animateSingleItem
|
||||
|
||||
if (openingSingleItem) {
|
||||
singleItemEntrance.value = true
|
||||
} else if (height === 0 || props.items.length !== 1) {
|
||||
} else if (height === 0 || props.items.length !== 1 || !props.animateSingleItem) {
|
||||
singleItemEntrance.value = false
|
||||
}
|
||||
|
||||
containerHeightSettled.value =
|
||||
prefersReducedMotion.value || (!initialMeasurementSettled.value && !openingSingleItem)
|
||||
prefersReducedMotion.value ||
|
||||
singleItemAnimationDisabled.value ||
|
||||
(!initialMeasurementSettled.value && !openingSingleItem)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -465,7 +504,17 @@ const messages = defineMessages({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AnimatePresence :initial="false">
|
||||
<div v-if="instantSingleItem" v-bind="attrs" class="relative">
|
||||
<slot
|
||||
name="item"
|
||||
:item="instantSingleItem"
|
||||
:index="0"
|
||||
:is-front="true"
|
||||
:expanded="false"
|
||||
:dismissible="itemDismissible(instantSingleItem)"
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence v-else :initial="false">
|
||||
<Motion
|
||||
v-if="items.length > 0"
|
||||
v-bind="attrs"
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
<div data-pyro-telepopover-wrapper class="relative">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
v-tooltip="tooltip"
|
||||
class="teleport-overflow-menu-trigger"
|
||||
:class="btnClass"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@@ -38,9 +41,14 @@
|
||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||
>
|
||||
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||
<ButtonStyled
|
||||
v-else
|
||||
type="transparent"
|
||||
role="menuitem"
|
||||
:color="optionButtonColor(option)"
|
||||
>
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
v-if="typeof option.action === 'function' || option.disabled"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
@@ -51,39 +59,41 @@
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@click="(event) => handleItemClick(option, index, event)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.id }}
|
||||
{{ option.label ?? option.id }}
|
||||
</slot>
|
||||
</button>
|
||||
<AutoLink
|
||||
v-else-if="typeof option.action === 'string'"
|
||||
v-else-if="optionLink(option)"
|
||||
:ref="
|
||||
(el) => {
|
||||
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"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@click="(event) => handleItemClick(option, index, event)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.id }}
|
||||
{{ option.label ?? option.id }}
|
||||
</slot>
|
||||
</AutoLink>
|
||||
<span v-else>
|
||||
<slot :name="option.id">
|
||||
<component :is="option.icon" v-if="option.icon" class="size-5" />
|
||||
{{ option.id }}
|
||||
{{ option.label ?? option.id }}
|
||||
</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
@@ -95,29 +105,48 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AutoLink, ButtonStyled } from '@modrinth/ui'
|
||||
import { onClickOutside, useElementHover } from '@vueuse/core'
|
||||
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
|
||||
label?: string
|
||||
icon?: Component
|
||||
action?: (() => void) | string
|
||||
action?: ((event?: MouseEvent) => void) | string
|
||||
link?: string
|
||||
external?: boolean
|
||||
shown?: boolean
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
color?: OptionColor
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
remainOnClick?: boolean
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
export type Divider = {
|
||||
divider?: boolean
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
type Item = Option | Divider
|
||||
export type Item = Option | Divider
|
||||
|
||||
function isDivider(item: Item): item is Divider {
|
||||
return (item as Divider).divider
|
||||
return !!(item as Divider).divider
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -125,10 +154,16 @@ const props = withDefaults(
|
||||
options: Item[]
|
||||
hoverable?: boolean
|
||||
btnClass?: string | string[] | Record<string, boolean>
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
ariaLabel?: string
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
btnClass: undefined,
|
||||
disabled: false,
|
||||
tooltip: undefined,
|
||||
ariaLabel: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -191,6 +226,7 @@ const calculateMenuPosition = () => {
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (props.disabled) return
|
||||
if (!props.hoverable) {
|
||||
if (isOpen.value) {
|
||||
closeMenu()
|
||||
@@ -201,6 +237,7 @@ const toggleMenu = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
const openMenu = () => {
|
||||
if (props.disabled) return
|
||||
isOpen.value = true
|
||||
emit('open')
|
||||
disableBodyScroll()
|
||||
@@ -218,15 +255,18 @@ const closeMenu = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
|
||||
const selectOption = (option: Option) => {
|
||||
const selectOption = (option: Option, event?: MouseEvent) => {
|
||||
emit('select', option)
|
||||
if (typeof option.action === 'function') {
|
||||
option.action()
|
||||
option.action(event)
|
||||
}
|
||||
if (!option.remainOnClick) {
|
||||
closeMenu()
|
||||
}
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
if (props.disabled) return
|
||||
event.preventDefault()
|
||||
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
|
||||
selectedIndex.value = index
|
||||
selectOption(option)
|
||||
selectOption(option, event)
|
||||
}
|
||||
|
||||
const handleMouseOver = (index: number) => {
|
||||
@@ -432,4 +472,23 @@ onClickOutside(menuRef, (event) => {
|
||||
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>
|
||||
|
||||
@@ -19,7 +19,6 @@ export { default as CollapsibleAdmonition } from './CollapsibleAdmonition.vue'
|
||||
export { default as CollapsibleRegion } from './CollapsibleRegion.vue'
|
||||
export type { ComboboxOption } from './Combobox.vue'
|
||||
export { default as Combobox } from './Combobox.vue'
|
||||
export { default as ContentPageHeader } from './ContentPageHeader.vue'
|
||||
export { default as CopyCode } from './CopyCode.vue'
|
||||
export { default as DatePicker } from './DatePicker.vue'
|
||||
export { default as DoubleIcon } from './DoubleIcon.vue'
|
||||
@@ -63,6 +62,7 @@ export { default as OptionGroup } from './OptionGroup.vue'
|
||||
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
|
||||
export { default as OverflowMenu } from './OverflowMenu.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 PopoutMenu } from './PopoutMenu.vue'
|
||||
export { default as PreviewSelectButton } from './PreviewSelectButton.vue'
|
||||
@@ -87,6 +87,11 @@ export type { TabsTab, TabsValue } from './Tabs.vue'
|
||||
export { default as Tabs } from './Tabs.vue'
|
||||
export { default as TagItem } from './TagItem.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 type {
|
||||
TimeFrameLastUnit,
|
||||
TimeFrameLastUnitOption,
|
||||
|
||||
+6
-4
@@ -4,7 +4,9 @@
|
||||
v-if="ctx.flowType !== 'server-onboarding' && ctx.flowType !== 'reset-server'"
|
||||
class="flex flex-col gap-2"
|
||||
>
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.worldNameLabel) }}</span>
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.worldNameLabel) }}
|
||||
</span>
|
||||
<StyledInput
|
||||
v-model="worldName"
|
||||
:placeholder="formatMessage(messages.worldNamePlaceholder)"
|
||||
@@ -156,11 +158,11 @@ const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
worldNameLabel: {
|
||||
id: 'creation-flow.modal.final-config.world-name.label',
|
||||
defaultMessage: 'World name',
|
||||
defaultMessage: 'Instance name',
|
||||
},
|
||||
worldNamePlaceholder: {
|
||||
id: 'creation-flow.modal.final-config.world-name.placeholder',
|
||||
defaultMessage: 'Enter world name',
|
||||
defaultMessage: 'Enter instance name',
|
||||
},
|
||||
gameVersionPlaceholder: {
|
||||
id: 'creation-flow.modal.final-config.game-version.placeholder',
|
||||
@@ -285,7 +287,7 @@ const messages = defineMessages({
|
||||
},
|
||||
beforeResetServerBackupName: {
|
||||
id: 'creation-flow.modal.final-config.backup.before-reset-server.name',
|
||||
defaultMessage: 'Before reset server',
|
||||
defaultMessage: 'Before reset instance',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ const messages = defineMessages({
|
||||
},
|
||||
worldTypeTitle: {
|
||||
id: 'creation-flow.modal.setup-type.title.world',
|
||||
defaultMessage: 'Select world type',
|
||||
defaultMessage: 'Select instance type',
|
||||
},
|
||||
customSetupTitle: {
|
||||
id: 'creation-flow.modal.setup-type.option.custom-setup.title',
|
||||
@@ -95,11 +95,11 @@ const messages = defineMessages({
|
||||
},
|
||||
modpackBaseTitle: {
|
||||
id: 'creation-flow.modal.setup-type.option.modpack-base.title',
|
||||
defaultMessage: 'Install modpack',
|
||||
defaultMessage: 'Modpack base',
|
||||
},
|
||||
modpackBaseDescription: {
|
||||
id: 'creation-flow.modal.setup-type.option.modpack-base.description',
|
||||
defaultMessage: 'Browse modpacks on Modrinth or import one from a file.',
|
||||
defaultMessage: 'Use a popular modpack as your starting point.',
|
||||
},
|
||||
importInstanceTitle: {
|
||||
id: 'creation-flow.modal.setup-type.option.import-instance.title',
|
||||
@@ -127,7 +127,7 @@ const setupTypeTitle = computed(() => {
|
||||
if (ctx.flowType === 'instance') {
|
||||
return formatMessage(messages.instanceTypeTitle)
|
||||
}
|
||||
if (ctx.flowType === 'server-onboarding' || ctx.flowType === 'reset-server') {
|
||||
if (ctx.flowType === 'reset-server') {
|
||||
return formatMessage(messages.installationTypeTitle)
|
||||
}
|
||||
return formatMessage(messages.worldTypeTitle)
|
||||
|
||||
@@ -38,15 +38,15 @@ const purpurSupportedVersionsQueryKey = ['creation-flow', 'purpur', 'supported-v
|
||||
export const creationFlowMessages = defineMessages({
|
||||
createWorldTitle: {
|
||||
id: 'creation-flow.title.create-world',
|
||||
defaultMessage: 'Create world',
|
||||
defaultMessage: 'Create instance',
|
||||
},
|
||||
setUpServerTitle: {
|
||||
id: 'creation-flow.title.set-up-server',
|
||||
defaultMessage: 'Set up server',
|
||||
defaultMessage: 'Create instance',
|
||||
},
|
||||
resetServerTitle: {
|
||||
id: 'creation-flow.title.reset-server',
|
||||
defaultMessage: 'Reset server',
|
||||
defaultMessage: 'Reset instance',
|
||||
},
|
||||
createInstanceTitle: {
|
||||
id: 'creation-flow.title.create-instance',
|
||||
@@ -54,7 +54,7 @@ export const creationFlowMessages = defineMessages({
|
||||
},
|
||||
createWorldButton: {
|
||||
id: 'creation-flow.button.create-world',
|
||||
defaultMessage: 'Create world',
|
||||
defaultMessage: 'Create instance',
|
||||
},
|
||||
createInstanceButton: {
|
||||
id: 'creation-flow.button.create-instance',
|
||||
@@ -62,7 +62,7 @@ export const creationFlowMessages = defineMessages({
|
||||
},
|
||||
setupServerButton: {
|
||||
id: 'creation-flow.button.setup-server',
|
||||
defaultMessage: 'Setup server',
|
||||
defaultMessage: 'Create instance',
|
||||
},
|
||||
finishButton: {
|
||||
id: 'creation-flow.button.finish',
|
||||
@@ -88,7 +88,7 @@ export const creationFlowMessages = defineMessages({
|
||||
|
||||
export const flowTypeHeadingMessages: Record<FlowType, MessageDescriptor> = {
|
||||
world: creationFlowMessages.createWorldTitle,
|
||||
'server-onboarding': creationFlowMessages.setUpServerTitle,
|
||||
'server-onboarding': creationFlowMessages.createWorldTitle,
|
||||
'reset-server': creationFlowMessages.resetServerTitle,
|
||||
instance: creationFlowMessages.createInstanceTitle,
|
||||
}
|
||||
@@ -219,9 +219,6 @@ export interface CreationFlowContextValue {
|
||||
export const [injectCreationFlowContext, provideCreationFlowContext] =
|
||||
createContext<CreationFlowContextValue>('CreationFlowModal')
|
||||
|
||||
// TODO: replace with actual world count from the world list once available
|
||||
let worldCounter = 0
|
||||
|
||||
export interface CreationFlowOptions {
|
||||
availableLoaders?: string[]
|
||||
showSnapshotToggle?: boolean
|
||||
@@ -431,8 +428,7 @@ export function createCreationFlowContext(
|
||||
}
|
||||
setupType.value = null
|
||||
isImportMode.value = false
|
||||
worldCounter++
|
||||
worldName.value = flowType === 'world' ? `World ${worldCounter}` : ''
|
||||
worldName.value = flowType === 'world' ? 'My instance' : ''
|
||||
gamemode.value = 'survival'
|
||||
difficulty.value = 'normal'
|
||||
worldSeed.value = ''
|
||||
|
||||
@@ -42,9 +42,9 @@ export const stageConfig: StageConfigInput<CreationFlowContextValue> = {
|
||||
const label = isWorld
|
||||
? ctx.formatMessage(creationFlowMessages.createWorldButton)
|
||||
: isReset
|
||||
? ctx.formatMessage(commonMessages.resetServerButton)
|
||||
? ctx.formatMessage(creationFlowMessages.resetServerTitle)
|
||||
: isOnboarding
|
||||
? ctx.formatMessage(creationFlowMessages.setupServerButton)
|
||||
? ctx.formatMessage(creationFlowMessages.createWorldButton)
|
||||
: ctx.formatMessage(commonMessages.continueButton)
|
||||
return {
|
||||
label,
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
<template>
|
||||
<ContentPageHeader>
|
||||
<template #icon>
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="96px" />
|
||||
</template>
|
||||
<template #title>
|
||||
{{ project.title }}
|
||||
</template>
|
||||
<template #title-suffix>
|
||||
<ProjectStatusBadge v-if="member || project.status !== 'approved'" :status="project.status" />
|
||||
</template>
|
||||
<template #summary>
|
||||
{{ project.description }}
|
||||
</template>
|
||||
<template #stats>
|
||||
<PageHeader
|
||||
:header="project.title"
|
||||
:summary="project.description"
|
||||
:leading="leadingItem"
|
||||
:badges="headerBadges"
|
||||
:metadata="headerMetadata"
|
||||
:actions="actions"
|
||||
:disable-line-clamp="disableLineClamp"
|
||||
>
|
||||
<template #metadata-project-stats>
|
||||
<div class="flex items-center gap-3 flex-wrap gap-y-0">
|
||||
<template v-if="isServerProject">
|
||||
<ServerDetails
|
||||
@@ -66,24 +62,24 @@
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<slot name="actions" />
|
||||
</template>
|
||||
</ContentPageHeader>
|
||||
</PageHeader>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { DownloadIcon, HeartIcon } from '@modrinth/assets'
|
||||
import { capitalizeString, type Project } from '@modrinth/utils'
|
||||
import type { Component } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useCompactNumber, useVIntl } from '../../composables'
|
||||
import { commonMessages } from '../../utils'
|
||||
import Avatar from '../base/Avatar.vue'
|
||||
import ContentPageHeader from '../base/ContentPageHeader.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 type { Item as TeleportOverflowMenuItem } from '../base/TeleportOverflowMenu.vue'
|
||||
import ProjectStatusBadge from './ProjectStatusBadge.vue'
|
||||
import ServerDetails from './server/ServerDetails.vue'
|
||||
|
||||
@@ -91,18 +87,77 @@ const router = useRouter()
|
||||
const { formatMessage } = useVIntl()
|
||||
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(
|
||||
defineProps<{
|
||||
project: Project
|
||||
member?: boolean
|
||||
projectV3?: Labrinth.Projects.v3.Project | null
|
||||
ping?: number
|
||||
actions?: HeaderAction[]
|
||||
disableLineClamp?: boolean
|
||||
}>(),
|
||||
{
|
||||
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(
|
||||
() => `/discover/${isServerProject.value ? 'servers' : `${props.project.project_type}s`}`,
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="props.restart" color="brand">
|
||||
<button :disabled="props.isUpdating || isTransitioning" @click="saveAndPower">
|
||||
<button :disabled="props.isUpdating || isTransitioning || !worldId" @click="saveAndPower">
|
||||
<SpinnerIcon v-if="props.isUpdating || isTransitioning" class="animate-spin" />
|
||||
{{ powerButtonLabel }}
|
||||
</button>
|
||||
@@ -43,7 +43,7 @@ const props = defineProps<{
|
||||
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const { powerState } = injectModrinthServerContext()
|
||||
const { powerState, worldId } = injectModrinthServerContext()
|
||||
|
||||
const isStopped = computed(() => powerState.value === 'stopped' || powerState.value === 'crashed')
|
||||
|
||||
@@ -63,6 +63,9 @@ const saveAndPower = async () => {
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await client.archon.servers_v0.power(props.serverId, isStopped.value ? 'Start' : 'Restart')
|
||||
if (!worldId.value) return
|
||||
await client.archon.servers_v1.powerWorld(props.serverId, worldId.value, {
|
||||
action: isStopped.value ? 'start' : 'restart',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { TabbedModalTab } from '#ui/components'
|
||||
import { TabbedModal } from '#ui/components'
|
||||
import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
ServerInstanceSettingsAdvancedPage,
|
||||
ServerInstanceSettingsGeneralPage,
|
||||
serverInstanceSettingsTabDefinitions,
|
||||
type ServerInstanceSettingsTabId,
|
||||
ServerSettingsInstallationPage,
|
||||
ServerSettingsPropertiesPage,
|
||||
} from '#ui/layouts/shared/server-settings'
|
||||
import { provideServerSettings } from '#ui/layouts/shared/server-settings/providers/server-settings'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
provideModrinthServerContext,
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
type ShowOptions = {
|
||||
serverId: string
|
||||
tabIndex?: number
|
||||
tabId?: ServerInstanceSettingsTabId
|
||||
worldId?: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
resolveViewer: () => Promise<{ userId: string | null; userRole: string | null }>
|
||||
browseModpacks?: (args: {
|
||||
serverId: string
|
||||
worldId: string | null
|
||||
from: 'reset-server'
|
||||
}) => void | Promise<void>
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const client = injectModrinthClient()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
failedToLoadServer: {
|
||||
id: 'app.server-instance-settings.failed-to-load-server',
|
||||
defaultMessage: 'Failed to load instance settings',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
const baseServerContext = injectModrinthServerContext()
|
||||
const selectedWorldId = ref<string | null>(baseServerContext.worldId.value)
|
||||
const modalServerContext = {
|
||||
...baseServerContext,
|
||||
worldId: selectedWorldId,
|
||||
} satisfies ReturnType<typeof injectModrinthServerContext>
|
||||
provideModrinthServerContext(modalServerContext)
|
||||
|
||||
const { serverId: currentServerId, worldId, server } = modalServerContext
|
||||
|
||||
const currentUserId = ref<string | null>(null)
|
||||
const currentUserRole = ref<string | null>(null)
|
||||
const serverFull = ref<Archon.Servers.v1.ServerFull | null>(null)
|
||||
|
||||
const isApp = ref(true)
|
||||
|
||||
const serverInstanceSettingsTabComponentMap = {
|
||||
general: ServerInstanceSettingsGeneralPage,
|
||||
installation: ServerSettingsInstallationPage,
|
||||
properties: ServerSettingsPropertiesPage,
|
||||
advanced: ServerInstanceSettingsAdvancedPage,
|
||||
} as const
|
||||
|
||||
provideServerSettings({
|
||||
isApp,
|
||||
currentUserId,
|
||||
currentUserRole,
|
||||
browseModpacks: props.browseModpacks ?? (() => {}),
|
||||
closeModal: () => hide(),
|
||||
})
|
||||
|
||||
const ownerId = computed(() => server.value?.owner_id ?? 'Ghost')
|
||||
const isOwner = computed(() => currentUserId.value != null && currentUserId.value === ownerId.value)
|
||||
const isAdmin = computed(() => currentUserRole.value === 'admin')
|
||||
const currentInstanceName = computed(() => {
|
||||
const id = worldId.value
|
||||
if (!id) return null
|
||||
return serverFull.value?.worlds.find((world) => world.id === id)?.name ?? null
|
||||
})
|
||||
|
||||
const tabs = computed<TabbedModalTab[]>(() =>
|
||||
serverInstanceSettingsTabDefinitions.map((tab) => {
|
||||
const ctx = {
|
||||
serverId: currentServerId,
|
||||
ownerId: ownerId.value,
|
||||
serverStatus: server.value?.status,
|
||||
isOwner: isOwner.value,
|
||||
isAdmin: isAdmin.value,
|
||||
}
|
||||
const name = defineMessage({
|
||||
id: `server.instance-settings.tabs.${tab.id}`,
|
||||
defaultMessage: tab.label,
|
||||
})
|
||||
const shown = tab.shown ? tab.shown(ctx) : true
|
||||
|
||||
return {
|
||||
name,
|
||||
icon: tab.icon,
|
||||
content: serverInstanceSettingsTabComponentMap[tab.id],
|
||||
shown,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
async function fetchViewer() {
|
||||
currentUserId.value = null
|
||||
currentUserRole.value = null
|
||||
|
||||
const result = await props.resolveViewer()
|
||||
currentUserId.value = result.userId
|
||||
currentUserRole.value = result.userRole
|
||||
}
|
||||
|
||||
async function show({ serverId, tabIndex, tabId, worldId: requestedWorldId }: ShowOptions) {
|
||||
try {
|
||||
const targetServerId = currentServerId
|
||||
selectedWorldId.value = requestedWorldId ?? baseServerContext.worldId.value
|
||||
if (serverId !== targetServerId) {
|
||||
console.warn(
|
||||
`[ServerInstanceSettingsModal] Ignoring mismatched serverId "${serverId}" in favor of context "${targetServerId}"`,
|
||||
)
|
||||
}
|
||||
|
||||
const cachedServer = queryClient.getQueryData<Archon.Servers.v0.Server>([
|
||||
'servers',
|
||||
'detail',
|
||||
targetServerId,
|
||||
])
|
||||
const cachedFull = queryClient.getQueryData<Archon.Servers.v1.ServerFull>([
|
||||
'servers',
|
||||
'v1',
|
||||
'detail',
|
||||
targetServerId,
|
||||
])
|
||||
|
||||
serverFull.value = cachedFull ?? null
|
||||
modal.value?.show()
|
||||
const visibleTabs = tabs.value.filter((tab) => tab.shown !== false)
|
||||
let requestedTab = tabIndex ?? 0
|
||||
if (tabId) {
|
||||
const defIndex = serverInstanceSettingsTabDefinitions.findIndex((d) => d.id === tabId)
|
||||
if (defIndex >= 0) {
|
||||
const visibleIndex = visibleTabs.findIndex(
|
||||
(_, i) => tabs.value.indexOf(visibleTabs[i]) === defIndex,
|
||||
)
|
||||
if (visibleIndex >= 0) requestedTab = visibleIndex
|
||||
}
|
||||
}
|
||||
const clampedTab = Math.min(Math.max(requestedTab, 0), Math.max(visibleTabs.length - 1, 0))
|
||||
nextTick(() => modal.value?.setTab(clampedTab))
|
||||
|
||||
const fetchPromises: Promise<unknown>[] = [fetchViewer()]
|
||||
|
||||
if (!cachedServer) {
|
||||
fetchPromises.push(
|
||||
queryClient.fetchQuery({
|
||||
queryKey: ['servers', 'detail', targetServerId],
|
||||
queryFn: () => client.archon.servers_v0.get(targetServerId),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (!cachedFull) {
|
||||
fetchPromises.push(
|
||||
queryClient
|
||||
.fetchQuery({
|
||||
queryKey: ['servers', 'v1', 'detail', targetServerId],
|
||||
queryFn: () => client.archon.servers_v1.get(targetServerId),
|
||||
})
|
||||
.then((data) => {
|
||||
serverFull.value = data
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(fetchPromises)
|
||||
|
||||
if (worldId.value) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['servers', 'properties', 'v1', targetServerId, worldId.value],
|
||||
queryFn: () => client.archon.properties_v1.getProperties(targetServerId, worldId.value!),
|
||||
})
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['content', 'list', 'v1', targetServerId, worldId.value],
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(targetServerId, worldId.value!, {
|
||||
from_modpack: false,
|
||||
}),
|
||||
})
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['servers', 'startup', 'v1', targetServerId, worldId.value],
|
||||
queryFn: () => client.archon.options_v1.getStartup(targetServerId, worldId.value!),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
addNotification({
|
||||
type: 'error',
|
||||
title: formatMessage(messages.failedToLoadServer),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabbedModal
|
||||
ref="modal"
|
||||
:tabs="tabs"
|
||||
:max-width="'min(980px, calc(95vw - 2rem))'"
|
||||
:width="'min(980px, calc(95vw - 2rem))'"
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex min-w-0 items-center gap-2 text-lg font-semibold text-primary">
|
||||
<span class="truncate">{{ server.name || 'Server' }}</span>
|
||||
<ChevronRightIcon class="shrink-0" />
|
||||
<span class="truncate">{{ currentInstanceName || 'Instance' }}</span>
|
||||
<ChevronRightIcon class="shrink-0" />
|
||||
<span class="shrink-0 font-extrabold text-contrast">{{
|
||||
formatMessage(commonMessages.settingsLabel)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</TabbedModal>
|
||||
</template>
|
||||
@@ -407,6 +407,7 @@ export type PendingChange = {
|
||||
|
||||
type ServerListingProps = {
|
||||
server_id: string
|
||||
worldId?: string | null
|
||||
name: string
|
||||
status: Archon.Servers.v0.Status
|
||||
suspension_reason?: Archon.Servers.v0.SuspensionReason | null
|
||||
@@ -546,16 +547,28 @@ async function dataURLToBlob(dataURL: string): Promise<Blob> {
|
||||
return res.blob()
|
||||
}
|
||||
|
||||
async function getActiveWorldId(serverId: string): Promise<string | null> {
|
||||
const server = await archon.servers_v1.get(serverId)
|
||||
const activeWorld = server.worlds.find((world) => world.is_active)
|
||||
return activeWorld?.id ?? server.worlds[0]?.id ?? null
|
||||
}
|
||||
|
||||
async function uploadWorldFile(worldId: string, path: string, file: File | Blob) {
|
||||
await kyros.files_v1.ensureFile(worldId, path)
|
||||
await kyros.files_v1.uploadFile(worldId, path, file).promise
|
||||
}
|
||||
|
||||
const { data: image } = useQuery({
|
||||
queryKey: ['server-icon', props.server_id] as const,
|
||||
queryKey: computed(() => ['server-icon', props.server_id, props.worldId ?? null] as const),
|
||||
queryFn: async (): Promise<string | null> => {
|
||||
if (!props.server_id || props.status !== 'available') return null
|
||||
|
||||
try {
|
||||
const fsAuth = await archon.servers_v0.getFilesystemAuth(props.server_id)
|
||||
const worldId = props.worldId ?? (await getActiveWorldId(props.server_id))
|
||||
if (!worldId) return null
|
||||
|
||||
try {
|
||||
const blob = await kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
|
||||
const blob = await kyros.files_v1.downloadRawFileContents(worldId, '/server-icon.png')
|
||||
return await processImageBlob(blob, 64)
|
||||
} catch (error) {
|
||||
const statusCode = (error as { statusCode?: number })?.statusCode
|
||||
@@ -564,8 +577,8 @@ const { data: image } = useQuery({
|
||||
}
|
||||
|
||||
try {
|
||||
const originalBlob = await kyros.files_v0.downloadFileWithAuth(
|
||||
fsAuth,
|
||||
const originalBlob = await kyros.files_v1.downloadRawFileContents(
|
||||
worldId,
|
||||
'/server-icon-original.png',
|
||||
)
|
||||
return await processImageBlob(originalBlob, 64)
|
||||
@@ -585,13 +598,12 @@ const { data: image } = useQuery({
|
||||
const scaledBlob = await dataURLToBlob(scaledDataUrl)
|
||||
const scaledFile = new File([scaledBlob], 'server-icon.png', { type: 'image/png' })
|
||||
|
||||
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
|
||||
await uploadWorldFile(worldId, '/server-icon.png', scaledFile)
|
||||
|
||||
const originalFile = new File([blob], 'server-icon-original.png', {
|
||||
type: 'image/png',
|
||||
})
|
||||
await kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', originalFile)
|
||||
.promise
|
||||
await uploadWorldFile(worldId, '/server-icon-original.png', originalFile)
|
||||
|
||||
return scaledDataUrl
|
||||
}
|
||||
|
||||
@@ -77,7 +77,7 @@ onMounted(() => {
|
||||
isClient.value = true
|
||||
})
|
||||
|
||||
const { serverId } = injectModrinthServerContext()
|
||||
const { serverId, worldId } = injectModrinthServerContext()
|
||||
const { featureFlags } = injectPageContext()
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -190,7 +190,9 @@ const metrics = computed(() => {
|
||||
showGraph: false,
|
||||
chartOptions: null as ReturnType<typeof buildChartOptions> | 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) {
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { Archon } from '@modrinth/api-client'
|
||||
import { ChevronRightIcon } from '@modrinth/assets'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import type { TabbedModalTab } from '#ui/components'
|
||||
import { TabbedModal } from '#ui/components'
|
||||
import { defineMessage, defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
ServerSettingsAdvancedPage,
|
||||
ServerSettingsGeneralPage,
|
||||
ServerSettingsInstallationPage,
|
||||
ServerSettingsNetworkPage,
|
||||
ServerSettingsPropertiesPage,
|
||||
serverSettingsTabDefinitions,
|
||||
type ServerSettingsTabId,
|
||||
} from '#ui/layouts/shared/server-settings'
|
||||
@@ -53,29 +50,16 @@ const messages = defineMessages({
|
||||
|
||||
const modal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
const { serverId: currentServerId, worldId, server } = injectModrinthServerContext()
|
||||
const { serverId: currentServerId, server } = injectModrinthServerContext()
|
||||
|
||||
const currentUserId = ref<string | null>(null)
|
||||
const currentUserRole = ref<string | null>(null)
|
||||
|
||||
const isApp = ref(true)
|
||||
|
||||
// Preload
|
||||
useQuery({
|
||||
queryKey: computed(() => ['content', 'list', 'v1', currentServerId]),
|
||||
queryFn: () =>
|
||||
client.archon.content_v1.getAddons(currentServerId, worldId.value!, {
|
||||
from_modpack: false,
|
||||
}),
|
||||
enabled: computed(() => !!worldId.value),
|
||||
})
|
||||
|
||||
const serverSettingsTabComponentMap = {
|
||||
general: ServerSettingsGeneralPage,
|
||||
installation: ServerSettingsInstallationPage,
|
||||
network: ServerSettingsNetworkPage,
|
||||
properties: ServerSettingsPropertiesPage,
|
||||
advanced: ServerSettingsAdvancedPage,
|
||||
} as const
|
||||
|
||||
provideServerSettings({
|
||||
@@ -146,12 +130,6 @@ async function show({ serverId, tabIndex, tabId }: ShowOptions) {
|
||||
'detail',
|
||||
targetServerId,
|
||||
])
|
||||
const cachedFull = queryClient.getQueryData<Archon.Servers.v1.ServerFull>([
|
||||
'servers',
|
||||
'v1',
|
||||
'detail',
|
||||
targetServerId,
|
||||
])
|
||||
|
||||
modal.value?.show()
|
||||
const visibleTabs = tabs.value.filter((tab) => tab.shown !== false)
|
||||
@@ -179,27 +157,7 @@ async function show({ serverId, tabIndex, tabId }: ShowOptions) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!cachedFull) {
|
||||
fetchPromises.push(
|
||||
queryClient.fetchQuery({
|
||||
queryKey: ['servers', 'v1', 'detail', targetServerId],
|
||||
queryFn: () => client.archon.servers_v1.get(targetServerId),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.all(fetchPromises)
|
||||
|
||||
if (worldId.value) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['servers', 'properties', 'v1', targetServerId, worldId.value],
|
||||
queryFn: () => client.archon.properties_v1.getProperties(targetServerId, worldId.value!),
|
||||
})
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['servers', 'startup', 'v1', targetServerId, worldId.value],
|
||||
queryFn: () => client.archon.options_v1.getStartup(targetServerId, worldId.value!),
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
addNotification({
|
||||
|
||||
@@ -41,7 +41,7 @@ const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
rateLimitTitle: {
|
||||
id: 'servers.setup.rate-limit.title',
|
||||
defaultMessage: 'Cannot reinstall server',
|
||||
defaultMessage: 'Cannot reinstall instance',
|
||||
},
|
||||
rateLimitText: {
|
||||
id: 'servers.setup.rate-limit.text',
|
||||
|
||||
@@ -31,42 +31,6 @@
|
||||
row-key="id"
|
||||
:row-transition-name="rowTransitionName"
|
||||
>
|
||||
<template #header-world="{ column }">
|
||||
<span class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold">
|
||||
<span class="min-w-0 truncate">{{ column.label }}</span>
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0"
|
||||
:triggers="['hover', 'focus']"
|
||||
:popper-triggers="['hover', 'focus']"
|
||||
popper-class="v-popper--interactive"
|
||||
placement="top"
|
||||
:delay="{ show: 200, hide: 100 }"
|
||||
no-auto-focus
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.instanceTooltipTitle)"
|
||||
class="inline-flex cursor-help items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
|
||||
>
|
||||
<UnknownIcon class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div class="grid !w-64 gap-1">
|
||||
<h3 class="m-0 whitespace-nowrap text-base w-full font-bold text-contrast">
|
||||
{{ formatMessage(messages.instanceTooltipTitle) }}
|
||||
</h3>
|
||||
<p
|
||||
class="m-0 text-wrap text-sm w-full font-medium leading-tight text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.instanceTooltipDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template #cell-user="{ row: entry }">
|
||||
<AutoLink
|
||||
v-tooltip="actorName(entry)"
|
||||
@@ -171,7 +135,7 @@
|
||||
class="hidden min-h-14 bg-surface-3 @[800px]:grid @[800px]:h-14"
|
||||
:class="
|
||||
showWorldColumn
|
||||
? '@[800px]:grid-cols-[18%_52%_20%_10%]'
|
||||
? '@[800px]:grid-cols-[18%_46%_22%_14%]'
|
||||
: '@[800px]:grid-cols-[26%_58%_16%]'
|
||||
"
|
||||
>
|
||||
@@ -185,37 +149,7 @@
|
||||
v-if="showWorldColumn"
|
||||
class="hidden items-center px-2 font-semibold text-secondary @[800px]:flex"
|
||||
>
|
||||
<span class="inline-flex min-w-0 max-w-full items-center gap-1 font-semibold">
|
||||
<span class="min-w-0 truncate">{{ formatMessage(messages.worldColumn) }}</span>
|
||||
<Tooltip
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex shrink-0"
|
||||
:triggers="['hover', 'focus']"
|
||||
:popper-triggers="['hover', 'focus']"
|
||||
popper-class="v-popper--interactive"
|
||||
placement="top"
|
||||
:delay="{ show: 200, hide: 100 }"
|
||||
no-auto-focus
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.instanceTooltipTitle)"
|
||||
class="inline-flex cursor-help items-center justify-center border-0 bg-transparent p-0 text-secondary transition-colors hover:text-contrast"
|
||||
>
|
||||
<UnknownIcon class="size-4" aria-hidden="true" />
|
||||
</button>
|
||||
<template #popper>
|
||||
<div class="grid !w-64 gap-1">
|
||||
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
||||
{{ formatMessage(messages.instanceTooltipTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
{{ formatMessage(messages.instanceTooltipDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<span class="min-w-0 truncate">{{ formatMessage(messages.worldColumn) }}</span>
|
||||
</div>
|
||||
<div
|
||||
class="hidden items-center justify-end pl-2 pr-4 font-semibold text-secondary @[800px]:flex"
|
||||
@@ -250,8 +184,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { IntercomBubbleIcon, UnknownIcon } from '@modrinth/assets'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { IntercomBubbleIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useSlots, watch } from 'vue'
|
||||
|
||||
import { useFormatDateTime, useRelativeTime } from '../../../composables'
|
||||
@@ -324,15 +257,6 @@ const messages = defineMessages({
|
||||
id: 'servers.audit-log.column.world',
|
||||
defaultMessage: 'Instance',
|
||||
},
|
||||
instanceTooltipTitle: {
|
||||
id: 'servers.audit-log.column.world.tooltip-title',
|
||||
defaultMessage: 'Coming soon!',
|
||||
},
|
||||
instanceTooltipDescription: {
|
||||
id: 'servers.audit-log.column.world.tooltip-description',
|
||||
defaultMessage:
|
||||
'Server instances are contained environments with their own installed content and world files.',
|
||||
},
|
||||
eventColumn: {
|
||||
id: 'servers.audit-log.column.event',
|
||||
defaultMessage: 'Actions',
|
||||
@@ -417,7 +341,7 @@ const columns = computed<TableColumn<AuditLogTableColumn>[]>(() => {
|
||||
{
|
||||
key: 'event',
|
||||
label: formatMessage(messages.eventColumn),
|
||||
width: showWorldColumn.value ? '52%' : '58%',
|
||||
width: showWorldColumn.value ? '46%' : '58%',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -425,7 +349,7 @@ const columns = computed<TableColumn<AuditLogTableColumn>[]>(() => {
|
||||
tableColumns.push({
|
||||
key: 'world',
|
||||
label: formatMessage(messages.worldColumn),
|
||||
width: '20%',
|
||||
width: '22%',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -434,7 +358,7 @@ const columns = computed<TableColumn<AuditLogTableColumn>[]>(() => {
|
||||
label: formatMessage(messages.timeColumn),
|
||||
align: 'right',
|
||||
enableSorting: true,
|
||||
width: showWorldColumn.value ? '10%' : '16%',
|
||||
width: showWorldColumn.value ? '14%' : '16%',
|
||||
})
|
||||
|
||||
return tableColumns
|
||||
|
||||
@@ -118,7 +118,7 @@ const messages = defineMessages({
|
||||
creatingBackupDescription: {
|
||||
id: 'servers.backups.admonition.creating-backup.description',
|
||||
defaultMessage:
|
||||
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
|
||||
'Saving instance data and server configuration for {backupName}. This can take a few minutes.',
|
||||
},
|
||||
backupFailedTitle: {
|
||||
id: 'servers.backups.admonition.backup-failed.title',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
@@ -11,8 +11,8 @@ import InstallingBanner, {
|
||||
type SyncProgress,
|
||||
} from '#ui/components/servers/InstallingBanner.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
|
||||
import { useServerPermissions } from '#ui/composables/server-permissions'
|
||||
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
|
||||
import type { FileOperation } from '#ui/layouts/shared/files-tab/types'
|
||||
import { injectModrinthClient, injectModrinthServerContext } from '#ui/providers'
|
||||
|
||||
@@ -23,6 +23,7 @@ import UploadAdmonition from './UploadAdmonition.vue'
|
||||
const props = defineProps<{
|
||||
syncProgress?: SyncProgress | null
|
||||
contentError?: ContentError | null
|
||||
showInstanceInfo?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -45,6 +46,15 @@ const messages = defineMessages({
|
||||
id: 'servers.admonitions.background-task-running',
|
||||
defaultMessage: 'Background task running',
|
||||
},
|
||||
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.',
|
||||
},
|
||||
contentBusyBody: {
|
||||
id: 'content.page-layout.busy-description',
|
||||
defaultMessage: 'Please wait for the operation to complete before editing content.',
|
||||
@@ -55,8 +65,15 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const isOnContentTab = computed(() => route.path.includes('/content'))
|
||||
const isOnFilesTab = computed(() => route.path.includes('/files'))
|
||||
const isOnInstancesList = computed(
|
||||
() => route.path.includes('/instances') && !route.params.instance_id,
|
||||
)
|
||||
const isOnContentTab = computed(
|
||||
() =>
|
||||
route.path.includes('/content') ||
|
||||
(!!route.params.instance_id && !isOnFilesTab.value && !route.path.includes('/backups')),
|
||||
)
|
||||
|
||||
const bannerCoversInstalling = computed(
|
||||
() =>
|
||||
@@ -96,6 +113,29 @@ const dismissedIds = reactive(new Set<string>())
|
||||
const cancellingIds = reactive(new Set<string>())
|
||||
const uploadCancelling = ref(false)
|
||||
const dismissedContentErrorKey = ref<string | null>(null)
|
||||
const instanceInfoAdmonitionStorageLoaded = ref(false)
|
||||
const instanceInfoAdmonitionDismissed = ref(true)
|
||||
const INSTANCE_INFO_ADMONITION_KEY = 'server-instances-info-admonition-dismissed'
|
||||
|
||||
const instanceCount = computed(() => ctx.serverFull.value?.worlds.length ?? null)
|
||||
const showInstanceInfoAdmonition = computed(
|
||||
() =>
|
||||
props.showInstanceInfo &&
|
||||
isOnInstancesList.value &&
|
||||
instanceInfoAdmonitionStorageLoaded.value &&
|
||||
!instanceInfoAdmonitionDismissed.value &&
|
||||
instanceCount.value === 1,
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
try {
|
||||
instanceInfoAdmonitionDismissed.value =
|
||||
window.localStorage.getItem(INSTANCE_INFO_ADMONITION_KEY) === 'true'
|
||||
instanceInfoAdmonitionStorageLoaded.value = true
|
||||
} catch {
|
||||
instanceInfoAdmonitionStorageLoaded.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const contentErrorKey = computed(() =>
|
||||
props.contentError ? `${props.contentError.step}:${props.contentError.description}` : null,
|
||||
@@ -166,6 +206,7 @@ type ServerAdmonitionItem = StackedAdmonitionItem & {
|
||||
| { kind: 'upload' }
|
||||
| { kind: 'fs-op'; op: FileOperation }
|
||||
| { kind: 'backup'; entry: BackupAdmonitionEntry }
|
||||
| { kind: 'instance-info' }
|
||||
| { kind: 'busy-content' }
|
||||
| { kind: 'busy-files' }
|
||||
)
|
||||
@@ -255,6 +296,17 @@ const stackItems = computed<ServerAdmonitionItem[]>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (showInstanceInfoAdmonition.value) {
|
||||
out.push({
|
||||
id: 'instance-info',
|
||||
type: 'info',
|
||||
dismissible: true,
|
||||
kind: 'instance-info',
|
||||
priority: 6,
|
||||
sortIndex: sortIndex++,
|
||||
})
|
||||
}
|
||||
|
||||
if (contentBusyHeader.value) {
|
||||
const p = isOnContentTab.value ? 0 : 5
|
||||
out.push({
|
||||
@@ -374,6 +426,8 @@ async function onDismissAll() {
|
||||
}
|
||||
} else if (it.kind === 'backup') {
|
||||
tasks.push(onBackupDismiss(it.entry))
|
||||
} else if (it.kind === 'instance-info') {
|
||||
onInstanceInfoDismiss()
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks)
|
||||
@@ -390,12 +444,22 @@ function onContentErrorDismiss() {
|
||||
dismissedContentErrorKey.value = contentErrorKey.value
|
||||
}
|
||||
}
|
||||
|
||||
function onInstanceInfoDismiss() {
|
||||
instanceInfoAdmonitionDismissed.value = true
|
||||
try {
|
||||
window.localStorage.setItem(INSTANCE_INFO_ADMONITION_KEY, 'true')
|
||||
} catch {
|
||||
instanceInfoAdmonitionStorageLoaded.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StackedAdmonitions
|
||||
:items="stackItems"
|
||||
:dismiss-all-enabled="hasBulkDismissableItems"
|
||||
:animate-single-item="false"
|
||||
class="w-full"
|
||||
@dismiss-all="onDismissAll"
|
||||
>
|
||||
@@ -441,6 +505,15 @@ function onContentErrorDismiss() {
|
||||
>
|
||||
{{ formatMessage(messages.contentBusyBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-else-if="item.kind === 'instance-info'"
|
||||
type="info"
|
||||
:header="formatMessage(messages.instanceInfoHeader)"
|
||||
:dismissible="dismissible"
|
||||
@dismiss="onInstanceInfoDismiss"
|
||||
>
|
||||
{{ formatMessage(messages.instanceInfoBody) }}
|
||||
</Admonition>
|
||||
<Admonition
|
||||
v-else-if="item.kind === 'busy-files'"
|
||||
type="warning"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { createContext } from '#ui/providers'
|
||||
|
||||
export interface ServerPanelAdmonitionsContext {
|
||||
readonly showInstanceInfo: Ref<boolean>
|
||||
}
|
||||
|
||||
export const [injectServerPanelAdmonitionsContext, provideServerPanelAdmonitionsContext] =
|
||||
createContext<ServerPanelAdmonitionsContext>('[id].vue', 'serverPanelAdmonitionsContext')
|
||||
@@ -103,12 +103,12 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
||||
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (name: string) =>
|
||||
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>>()
|
||||
|
||||
@@ -85,12 +85,12 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
||||
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
|
||||
|
||||
const renameMutation = useMutation({
|
||||
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
||||
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>>()
|
||||
|
||||
@@ -74,7 +74,7 @@ const props = withDefaults(
|
||||
},
|
||||
)
|
||||
|
||||
const backupsQueryKey = ['backups', 'queue', ctx.serverId]
|
||||
const backupsQueryKey = computed(() => ['backups', 'queue', ctx.serverId, ctx.worldId.value])
|
||||
|
||||
function safetyBackupName(backupName: string) {
|
||||
const base = `Before restoring "${backupName}"`
|
||||
@@ -84,7 +84,7 @@ function safetyBackupName(backupName: string) {
|
||||
const restoreMutation = useMutation({
|
||||
mutationFn: ({ backupId, name }: { backupId: string; name: string }) =>
|
||||
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>>()
|
||||
|
||||
@@ -73,7 +73,7 @@ const props = withDefaults(
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const client = injectModrinthClient()
|
||||
const { serverId, server } = injectModrinthServerContext()
|
||||
const { serverId, server, worldId } = injectModrinthServerContext()
|
||||
const queryClient = useQueryClient()
|
||||
const isUploadingIcon = ref(false)
|
||||
const isSyncingIcon = ref(false)
|
||||
@@ -95,6 +95,7 @@ const {
|
||||
computed(() => server.value?.upstream ?? null),
|
||||
{
|
||||
includeProjectFallback: false,
|
||||
worldId,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -107,6 +108,19 @@ function isNotFound(error: unknown): boolean {
|
||||
return getStatusCode(error) === 404
|
||||
}
|
||||
|
||||
function getWorldId() {
|
||||
if (!worldId.value) {
|
||||
throw new Error('World ID is not available.')
|
||||
}
|
||||
return worldId.value
|
||||
}
|
||||
|
||||
async function uploadWorldFile(path: string, file: File | Blob) {
|
||||
const id = getWorldId()
|
||||
await client.kyros.files_v1.ensureFile(id, path)
|
||||
await client.kyros.files_v1.uploadFile(id, path, file).promise
|
||||
}
|
||||
|
||||
const uploadFile = async (e: Event) => {
|
||||
if (isIconActionDisabled.value) return
|
||||
|
||||
@@ -144,39 +158,11 @@ const uploadFile = async (e: Event) => {
|
||||
img.src = URL.createObjectURL(file)
|
||||
})
|
||||
|
||||
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
|
||||
} catch (scaledUploadError) {
|
||||
// Node FS may reject create when file already exists. Delete and retry once.
|
||||
try {
|
||||
await client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false)
|
||||
} catch (deleteError) {
|
||||
if (!isNotFound(deleteError)) {
|
||||
throw scaledUploadError
|
||||
}
|
||||
}
|
||||
|
||||
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon.png', scaledFile).promise
|
||||
}
|
||||
await uploadWorldFile('/server-icon.png', scaledFile)
|
||||
|
||||
// Keep original file in sync when possible, but don't block icon updates on failures here.
|
||||
try {
|
||||
await client.kyros.files_v0.deleteFileOrFolderWithAuth(
|
||||
fsAuth,
|
||||
'/server-icon-original.png',
|
||||
false,
|
||||
)
|
||||
} catch (deleteOriginalError) {
|
||||
if (!isNotFound(deleteOriginalError)) {
|
||||
// best effort
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.uploadFileWithAuth(fsAuth, '/server-icon-original.png', file)
|
||||
.promise
|
||||
await uploadWorldFile('/server-icon-original.png', file)
|
||||
} catch (originalUploadError) {
|
||||
if (!isNotFound(originalUploadError)) {
|
||||
// best effort
|
||||
@@ -222,10 +208,10 @@ const resetIcon = async () => {
|
||||
isSyncingIcon.value = true
|
||||
|
||||
try {
|
||||
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
|
||||
const id = getWorldId()
|
||||
const deleteResults = await Promise.allSettled([
|
||||
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon.png', false),
|
||||
client.kyros.files_v0.deleteFileOrFolderWithAuth(fsAuth, '/server-icon-original.png', false),
|
||||
client.kyros.files_v1.deleteFile(id, '/server-icon.png'),
|
||||
client.kyros.files_v1.deleteFile(id, '/server-icon-original.png'),
|
||||
])
|
||||
|
||||
for (const result of deleteResults) {
|
||||
|
||||
@@ -11,6 +11,7 @@ export { default as ModrinthServersIcon } from './ModrinthServersIcon.vue'
|
||||
export { default as SaveBanner } from './SaveBanner.vue'
|
||||
export * from './server-header'
|
||||
export { default as ServerListEmpty } from './server-list-empty/ServerListEmpty.vue'
|
||||
export { default as ServerInstanceSettingsModal } from './ServerInstanceSettingsModal.vue'
|
||||
export { type PendingChange, default as ServerListing } from './ServerListing.vue'
|
||||
export { default as ServerSettingsModal } from './ServerSettingsModal.vue'
|
||||
export { default as ServerSetupModal } from './ServerSetupModal.vue'
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
<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" :disabled="creating" @click="emit('create', world.id)">
|
||||
<LoaderCircleIcon v-if="creating" class="animate-spin" aria-hidden="true" />
|
||||
<PlusIcon v-else 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 { LoaderCircleIcon, 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
|
||||
creating?: boolean
|
||||
}>()
|
||||
|
||||
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>
|
||||
@@ -17,7 +17,7 @@
|
||||
</AutoLink>
|
||||
<div
|
||||
v-else
|
||||
v-tooltip="'Change server version'"
|
||||
v-tooltip="'Change instance version'"
|
||||
class="pointer-events-none flex min-w-0 flex-row items-center gap-1 truncate text-sm font-medium"
|
||||
>
|
||||
{{ game[0].toUpperCase() + game.slice(1) }}
|
||||
@@ -46,7 +46,7 @@ defineProps<{
|
||||
const settingsModal = injectServerSettingsModal(null)
|
||||
const settingsLinkTarget = computed(() => {
|
||||
if (settingsModal) {
|
||||
return () => settingsModal.openServerSettings({ tabId: 'installation' })
|
||||
return () => settingsModal.openServerInstanceSettings({ tabId: 'installation' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>
|
||||
<AutoLink
|
||||
v-if="isLink"
|
||||
v-tooltip="'Change server loader'"
|
||||
v-tooltip="'Change instance loader'"
|
||||
:to="settingsLinkTarget"
|
||||
class="flex min-w-0 items-center font-medium text-sm"
|
||||
:class="settingsLinkTarget ? 'hover:underline' : ''"
|
||||
@@ -54,7 +54,7 @@ defineProps<{
|
||||
const settingsModal = injectServerSettingsModal(null)
|
||||
const settingsLinkTarget = computed(() => {
|
||||
if (settingsModal) {
|
||||
return () => settingsModal.openServerSettings({ tabId: 'installation' })
|
||||
return () => settingsModal.openServerInstanceSettings({ tabId: 'installation' })
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user