Compare commits

...
Author SHA1 Message Date
Calum H. (IMB11) 246362787f fix: ws connection duplication + disconnecting during browse 2026-08-04 19:39:37 +01:00
Calum H. (IMB11) 986a40edfc fix: lint 2026-08-04 19:36:35 +01:00
Calum H. (IMB11) 50fa5af63b feat: improve handling 2026-08-04 19:36:01 +01:00
Calum H. (IMB11) 526232535e feat: sync individual content installation states on panel 2026-08-04 19:32:31 +01:00
Calum H. (IMB11) ed936728ad fix: merge issues 2026-08-04 19:30:36 +01:00
Calum H. (IMB11) 38bf2c54f3 chore: restore locale files from main 2026-08-03 18:50:27 +01:00
Calum H. (IMB11) 8169000127 fix: migrate server instance header to new PageHeader 2026-08-03 18:49:40 +01:00
Calum H. (IMB11) 91f8d25b0c fix: bad merge 2026-08-03 18:47:51 +01:00
Calum H. (IMB11) 4129be1831 fix: preserve hosting header updates after merge 2026-08-03 18:41:57 +01:00
Calum H. (IMB11) 32517df4a4 Merge remote-tracking branch 'origin/main' into cal/hosting-server-instances
# Conflicts:
#	apps/app-frontend/src/pages/Browse.vue
#	apps/app-frontend/src/pages/hosting/manage/Index.vue
#	apps/app-frontend/src/pages/instance/Index.vue
#	apps/app-frontend/src/pages/project/Index.vue
#	apps/app-frontend/src/routes.js
#	apps/frontend/src/locales/en-US/index.json
#	apps/frontend/src/middleware/project.global.ts
#	apps/frontend/src/pages/[type]/[project].vue
#	apps/frontend/src/pages/discover/[type]/index.vue
#	apps/frontend/src/pages/organization/[organization].vue
#	apps/frontend/src/pages/user/[user].vue
#	packages/ui/src/components/base/FloatingActionBar.vue
#	packages/ui/src/components/base/PageHeader.vue
#	packages/ui/src/components/base/StackedAdmonitions.vue
#	packages/ui/src/components/base/TeleportOverflowMenu.vue
#	packages/ui/src/components/base/index.ts
#	packages/ui/src/components/project/ProjectHeader.vue
#	packages/ui/src/components/servers/server-header/ServerManageHeader.vue
#	packages/ui/src/components/servers/server-header/index.ts
#	packages/ui/src/composables/index.ts
#	packages/ui/src/layouts/shared/browse-tab/composables/use-browse-search.ts
#	packages/ui/src/layouts/shared/browse-tab/header.vue
#	packages/ui/src/layouts/shared/browse-tab/layout.vue
#	packages/ui/src/layouts/shared/content-tab/components/ContentModpackCard.vue
#	packages/ui/src/layouts/shared/content-tab/layout.vue
#	packages/ui/src/layouts/shared/installation-settings/components/ContentDiffModal.vue
#	packages/ui/src/layouts/shared/installation-settings/providers/installation-settings.ts
#	packages/ui/src/layouts/wrapped/hosting/manage/[id]/index.vue
#	packages/ui/src/locales/cs-CZ/index.json
#	packages/ui/src/locales/de-CH/index.json
#	packages/ui/src/locales/de-DE/index.json
#	packages/ui/src/locales/es-419/index.json
#	packages/ui/src/locales/es-ES/index.json
#	packages/ui/src/locales/fr-FR/index.json
#	packages/ui/src/locales/hu-HU/index.json
#	packages/ui/src/locales/id-ID/index.json
#	packages/ui/src/locales/it-IT/index.json
#	packages/ui/src/locales/ja-JP/index.json
#	packages/ui/src/locales/ko-KR/index.json
#	packages/ui/src/locales/ms-MY/index.json
#	packages/ui/src/locales/nl-NL/index.json
#	packages/ui/src/locales/pl-PL/index.json
#	packages/ui/src/locales/pt-BR/index.json
#	packages/ui/src/locales/ru-RU/index.json
#	packages/ui/src/locales/sv-SE/index.json
#	packages/ui/src/locales/tr-TR/index.json
#	packages/ui/src/locales/uk-UA/index.json
#	packages/ui/src/locales/vi-VN/index.json
#	packages/ui/src/locales/zh-CN/index.json
#	packages/ui/src/locales/zh-TW/index.json
#	packages/ui/src/stories/base/PageHeader.stories.ts
#	packages/ui/src/utils/file-extensions.ts
2026-08-03 18:39:37 +01:00
Calum H. (IMB11) fe64b6c76d fix: change icons + copy 2026-06-26 18:08:49 +01:00
Calum H. (IMB11) 485ca623ab fix: lint 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 46763861d9 fix: lint 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 4986bc194d fix: qa 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 417c6964f6 fix: guard preload content 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) ef07172006 fix: ditch files v0 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 2e08fe7950 fix: use list descendants isntead of v0 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) af6aec8dec feat: migrate files to v1 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) e5f91eadb9 fix: info admon breaking 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 8196d622d6 fix: power 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) ea94bcc00f fix: missing serverpaneladmonitions + csp app 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) c77175176b fix: lint 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 32ca8d549b fix: better handling of datapacks in frontend & desync issues between browse page impl in app and website 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 7618f9bc09 fix: browse page not respecting instance source via wid param 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 77e74f840a feat: improve onboarding flow wait logic 2026-06-26 17:09:23 +01:00
Calum H. (IMB11) 8490718e79 fix: reset to onboarding flow not waiting 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 2021c221cc feat: impl delete world + move onboarding reset to server scope 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 2c1b8a2326 feat: impl world scoped power endpoint 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) ad7a4d7d76 fix: qa + lint + i18n 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 54e6fe09cd feat: implement world creation flow routes 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 0a451d9f9a feat: switch world route impl 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 8381482cbb fix: loss of isNuxt 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) db225b757d fix: lint 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 9ab9fe994f feat: server settings split up + copy changes across panel warning modals 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 27f629a7a3 fix: project middleware startLoading 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) c0d839c7f6 feat: qa 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 45d02bd5e6 feat: qa + app routing bugs 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) de007985c1 feat: migrate all headers to use PageHeader shared component. 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 8493e66137 fix: ssr for instances subpages with middleware 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 53ca40079f fix: ssr 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) d1a26ee97a fix: header power state 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) e3c50d19ed feat: header migration pt 2 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 69ba34a8ad feat: PageHeader migration start 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) b72c319569 fix: header issues 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 8fca23e66a fix: lint 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 946b9d3765 qa: pass 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) f097409b92 chore: rename worlds -> instances 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) f799e6f67e fix: modpack linked text alignment 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 70887e8617 fix: worlds layout 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 546a207ea1 feat: fix btn sizing 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 110a83ca1c fix: lint 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 003c8305a2 chore: clean up wrapped layout folder structure 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) f922145230 feat: reshuffle layout for worlds 2026-06-26 17:09:22 +01:00
Calum H. (IMB11) 5d49e9fa53 feat: world card alignment 2026-06-26 17:09:21 +01:00
Calum H. (IMB11) d73d7ca7d0 fix: lint + i18n 2026-06-26 17:09:21 +01:00
Calum H. (IMB11) a94e15e278 feat: world switching page implementation 2026-06-26 17:09:21 +01:00
162 changed files with 7446 additions and 4076 deletions
+76 -4
View File
@@ -170,6 +170,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()
let credentialsRefreshId = 0
const sidebarToggled = ref(true)
@@ -180,6 +181,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,
@@ -664,6 +681,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)
@@ -696,6 +718,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)
@@ -1746,11 +1812,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>
+49 -8
View File
@@ -117,7 +117,10 @@ const breadcrumbLabel = computed(() => {
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,
@@ -127,6 +130,10 @@ const {
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContextWorldName,
serverContextWorldGameVersion,
serverContextWorldLoader,
serverContextWorldLoaderVersion,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
@@ -432,10 +439,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))
@@ -476,6 +483,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,
@@ -543,6 +558,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:
@@ -576,6 +595,27 @@ const messages = defineMessages({
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
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 },
)
function resetInstanceContext() {
if (!instance.value) return
@@ -676,9 +716,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,
@@ -747,8 +788,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,
)
@@ -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.
}
@@ -30,7 +30,7 @@
"
>
<template #default="{ onReinstall, onReinstallFailed }">
<RouterView v-slot="{ Component }">
<RouterView v-slot="{ Component }" :route="managedRoute">
<template v-if="Component">
<Suspense>
<component
@@ -58,7 +58,7 @@ import {
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref, watch } from 'vue'
import { computed, ref, shallowRef, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { get_user } from '@/helpers/cache'
@@ -74,12 +74,24 @@ const queryClient = useQueryClient()
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const isContainedServerRoute = computed(() => route.name === 'ServerManageOverview')
const managedRoute = shallowRef(router.currentRoute.value)
const serverId = ref(getRouteParam(managedRoute.value.params.id) ?? '')
const isContainedServerRoute = computed(() => managedRoute.value.name === 'ServerManageOverview')
const serverId = computed(() => {
const rawId = route.params.id
return Array.isArray(rawId) ? (rawId[0] ?? '') : (rawId ?? '')
})
watch(
router.currentRoute,
(nextRoute) => {
if (!nextRoute.path.startsWith('/hosting/manage/')) return
managedRoute.value = nextRoute
serverId.value = getRouteParam(nextRoute.params.id) ?? ''
},
{ immediate: true },
)
function getRouteParam(param: string | string[] | undefined): string | null {
if (Array.isArray(param)) return param[0] ?? null
return param ?? null
}
function getCachedServerName(id: string): string | undefined {
return queryClient
@@ -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 }
+16 -6
View File
@@ -342,6 +342,10 @@ const messages = defineMessages({
id: 'app.project.install-context.back-to-instance',
defaultMessage: 'Back 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',
@@ -410,7 +414,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()
@@ -486,9 +493,12 @@ const projectInstallContext = computed(() => {
const serverData = serverInstallContent.serverContextServerData.value
if (serverData) {
return {
name: serverData.name,
loader: serverData.loader ?? '',
gameVersion: serverData.mc_version ?? '',
name:
serverInstallContent.serverContextWorldName.value ??
formatMessage(messages.worldFallbackName),
loader: serverInstallContent.serverContextWorldLoader.value ?? '',
loaderVersion: serverInstallContent.serverContextWorldLoaderVersion.value ?? '',
gameVersion: serverInstallContent.serverContextWorldGameVersion.value ?? '',
serverId: serverInstallContent.serverIdQuery.value,
upstream: serverData.upstream,
iconSrc: null,
@@ -846,8 +856,8 @@ async function install(version) {
overriddenProvidedFilterTypes: [],
targetPreferences: getTargetInstallPreferences(
{
gameVersion: serverInstallContent.serverContextServerData.value?.mc_version,
loader: serverInstallContent.serverContextServerData.value?.loader,
gameVersion: serverInstallContent.serverContextWorldGameVersion.value,
loader: serverInstallContent.serverContextWorldLoader.value,
},
contentType,
),
@@ -1,6 +1,5 @@
import type { AbstractModrinthClient, Archon, Labrinth } from '@modrinth/api-client'
import type { Archon, Labrinth } from '@modrinth/api-client'
import {
addPendingServerContentInstalls,
type BrowseInstallPlan,
type BrowseSelectedProject,
createContext,
@@ -10,16 +9,24 @@ import {
injectModrinthClient,
injectNotificationManager,
type ModpackSearchResult,
type PendingServerContentInstall,
type PendingServerContentInstallType,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
writePendingServerContentInstallBaseline,
useServerContextRuntime,
waitForServerContextRuntimeReady,
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'
@@ -28,7 +35,6 @@ type InstallableSearchResult = Labrinth.Search.v3.ResultSearchProject & {
installing?: boolean
installed?: boolean
}
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
export interface ServerModpackSelectionRequest {
projectId: string
@@ -53,6 +59,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>
@@ -90,116 +100,9 @@ function readQueryString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null
}
function getQueuedInstallOwnerFallback(project: InstallableSearchResult) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `https://modrinth.com/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `/user/${encodeURIComponent(ownerId)}`,
}
}
async function getQueuedInstallOwner(
client: AbstractModrinthClient,
project: InstallableSearchResult,
) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `https://modrinth.com/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `/user/${encodeURIComponent(owner.username)}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<InstallableSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
const project = plan.project as InstallableSearchResult & { slug?: string | null }
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: project.name ?? 'Project',
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: project.slug ?? plan.projectId,
iconUrl: project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
client: AbstractModrinthClient,
plans: Map<string, BrowseInstallPlan<InstallableSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(client, plan.project)),
),
)
}
export function createServerInstallContent(opts: {
serverSetupModalRef: Ref<ServerSetupModalHandle | null>
isRouteInContext?: (route: RouteLocationNormalizedLoaded) => boolean
}) {
const { serverSetupModalRef } = opts
const route = useRoute()
@@ -208,9 +111,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
@@ -220,14 +136,16 @@ export function createServerInstallContent(opts: {
const isFromWorlds = computed(() => browseFrom.value === 'worlds')
const isServerContext = computed(() => !!serverIdQuery.value)
const isSetupServerContext = computed(() => !!serverIdQuery.value && !!serverFlowFrom.value)
useServerContextRuntime(serverIdQuery)
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[]>(() =>
@@ -240,6 +158,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'
@@ -249,7 +194,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'
@@ -263,9 +208,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) {
@@ -282,11 +245,7 @@ export function createServerInstallContent(opts: {
.map((addon) => addon.project_id)
.filter((projectId): projectId is string => !!projectId),
)
const keys = new Set(
(content.addons ?? []).map((addon) => addon.project_id ?? addon.filename),
)
serverContentProjectIds.value = ids
serverContentInstallKeys.value = keys
} catch (err) {
handleError(err as Error)
}
@@ -301,6 +260,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) {
@@ -317,41 +281,77 @@ 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()
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()
}
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,
})
}
@@ -433,13 +433,20 @@ 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
}
const queuedPlans = getStoredServerAddonInstallQueue<InstallableSearchResult>(serverId, worldId)
if (queuedPlans.size === 0) return true
try {
await waitForServerContextRuntimeReady(client, serverId)
} catch (error) {
handleError(error as Error)
return false
}
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
@@ -457,15 +464,13 @@ 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),
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
}
handleError(result.error as Error)
return false
}
@@ -478,10 +483,6 @@ export function createServerInstallContent(opts: {
...serverContentProjectIds.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
serverContentInstallKeys.value = new Set([
...serverContentInstallKeys.value,
...result.flushedPlans.map((plan) => plan.projectId),
])
if (result.flushedPlans.length > 0) {
await queryClient.invalidateQueries({ queryKey: ['content', 'list', 'v1', serverId] })
}
@@ -506,20 +507,6 @@ export function createServerInstallContent(opts: {
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, serverContentInstallKeys.value)
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(client, plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
await router.push(backUrl)
void flushQueuedServerInstalls(sid, wid)
@@ -565,7 +552,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
}
@@ -580,6 +567,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,
@@ -590,6 +582,10 @@ export function createServerInstallContent(opts: {
isSetupServerContext,
effectiveServerWorldId,
serverContextServerData,
serverContextWorldName,
serverContextWorldGameVersion,
serverContextWorldLoader,
serverContextWorldLoaderVersion,
serverContentProjectIds,
queuedServerInstallProjectIds,
queuedServerInstallCount,
+27
View File
@@ -37,6 +37,33 @@ export default new createRouter({
name: 'ServerManageContent',
component: Hosting.Content,
},
{
path: 'instances',
name: 'ServerManageInstances',
component: Hosting.Instances,
},
{
path: 'instances/:instance_id',
name: 'ServerManageInstance',
component: Hosting.Instance,
children: [
{
path: '',
name: 'ServerManageInstanceContent',
component: Hosting.Content,
},
{
path: 'files',
name: 'ServerManageInstanceFiles',
component: Hosting.Files,
},
{
path: 'backups',
name: 'ServerManageInstanceBackups',
component: Hosting.Backups,
},
],
},
{
path: 'files',
name: 'ServerManageFiles',
+2 -2
View File
@@ -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/*"
}
]
},
+1 -1
View File
@@ -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"
@@ -54,7 +54,6 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
showViewProdRouteBanner: false,
showModeratorProjectMemberUi: false,
archonApiStaging: false,
showHostingAccessInstanceAuditLog: false,
versionDevInfoCollapsed: true,
alwaysShowVersionDevInfo: false,
advancedFiltersCollapsed: true,
@@ -6,11 +6,8 @@ import type {
CreationFlowContextValue,
EnvironmentSearchOverride,
FilterValue,
PendingServerContentInstall,
PendingServerContentInstallType,
} from '@modrinth/ui'
import {
addPendingServerContentInstalls,
commonMessages,
defineMessages,
flushStoredServerAddonInstallQueue,
@@ -18,14 +15,13 @@ import {
getTargetInstallPreferences,
injectModrinthClient,
injectNotificationManager,
readPendingServerContentInstalls,
readStoredServerInstallQueue,
removePendingServerContentInstall,
requestInstall,
stripServerRuntimeInstallFilters,
stripServerRuntimeInstallOverrides,
useServerContextRuntime,
useVIntl,
writePendingServerContentInstallBaseline,
waitForServerContextRuntimeReady,
writeStoredServerInstallQueue,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
@@ -35,7 +31,6 @@ import { computed, nextTick, ref, watch } from 'vue'
import { navigateTo, useRoute } from '#app'
import { queryAsString } from '~/utils/router'
type PendingServerContentInstallInput = Omit<PendingServerContentInstall, 'createdAt'>
type ServerInstallBrowseSearchState = Pick<
BrowseSearchState,
'currentFilters' | 'overriddenProvidedFilterTypes'
@@ -68,11 +63,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',
@@ -86,36 +81,20 @@ 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) {
if (project.organization) {
const ownerId = project.organization_id ?? project.organization
return {
id: ownerId,
name: project.organization,
type: 'organization' as const,
link: `/organization/${ownerId}`,
}
}
if (!project.author) return null
const ownerId = project.author_id ?? project.author
return {
id: ownerId,
name: project.author,
type: 'user' as const,
link: `/user/${ownerId}`,
}
}
function getQueuedAddonInstallPlans(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Array.from(plans.values()).filter((plan) => plan.contentType !== 'modpack')
}
export function useServerInstallContent({
projectType,
onboardingModalRef,
@@ -136,6 +115,7 @@ export function useServerInstallContent({
const currentServerId = computed(() => queryAsString(route.query.sid) || null)
const fromContext = computed(() => queryAsString(route.query.from) || null)
const currentWorldId = computed(() => queryAsString(route.query.wid) || null)
useServerContextRuntime(currentServerId)
const {
data: serverData,
@@ -153,6 +133,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),
@@ -196,14 +181,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
}
@@ -219,81 +245,6 @@ export function useServerInstallContent({
writeStoredServerInstallQueue(serverId, worldId, plans)
}
async function getQueuedInstallOwner(project: ServerInstallSearchResult) {
const fallback = getQueuedInstallOwnerFallback(project)
try {
if (project.organization) {
const organization = await client.labrinth.projects_v3.getOrganization(project.project_id)
if (organization) {
return {
id: organization.id,
name: organization.name,
type: 'organization' as const,
avatar_url: organization.icon_url ?? undefined,
link: `/organization/${organization.slug}`,
}
}
}
const members = await client.labrinth.projects_v3.getMembers(project.project_id)
const owner =
members.find((member) => member.user.id === project.author_id)?.user ??
members.find((member) => member.is_owner || member.role === 'Owner')?.user ??
members[0]?.user
if (owner) {
return {
id: owner.id,
name: owner.username,
type: 'user' as const,
avatar_url: owner.avatar_url,
link: `/user/${owner.username}`,
}
}
} catch {
return fallback
}
return fallback
}
function getQueuedInstallPlaceholder(
plan: BrowseInstallPlan<ServerInstallSearchResult>,
owner: PendingServerContentInstallInput['owner'],
): PendingServerContentInstallInput {
return {
projectId: plan.projectId,
versionId: plan.versionId,
contentType: plan.contentType as PendingServerContentInstallType,
title: getInstallProjectName(plan.project),
versionName: plan.versionName ?? null,
versionNumber: plan.versionNumber ?? null,
fileName: plan.fileName ?? null,
owner,
slug: plan.project.slug ?? plan.projectId,
iconUrl: plan.project.icon_url ?? null,
}
}
function getQueuedInstallPlaceholderFallbacks(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return getQueuedAddonInstallPlans(plans).map((plan) =>
getQueuedInstallPlaceholder(plan, getQueuedInstallOwnerFallback(plan.project)),
)
}
async function getQueuedInstallPlaceholders(
plans: Map<string, BrowseInstallPlan<ServerInstallSearchResult>>,
) {
return Promise.all(
getQueuedAddonInstallPlans(plans).map(async (plan) =>
getQueuedInstallPlaceholder(plan, await getQueuedInstallOwner(plan.project)),
),
)
}
function setProjectInstalling(projectId: string, installing: boolean) {
const next = new Set(installingProjectIds.value)
if (installing) {
@@ -319,10 +270,6 @@ export function useServerInstallContent({
)
}
function getServerInstalledContentKeys(data = serverContentData.value) {
return new Set((data?.addons ?? []).map((addon) => addon.project_id ?? addon.filename))
}
function syncHiddenInstalledProjectIds() {
hiddenInstalledProjectIds.value = new Set([
...getServerInstalledProjectIds(),
@@ -340,12 +287,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)) {
@@ -421,8 +368,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,
)
@@ -439,7 +386,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({
@@ -458,6 +409,7 @@ export function useServerInstallContent({
resolvedAddons.push({
project_id: item.project_id,
version_id: item.version_id,
kind: plan.contentType as Archon.Content.v1.AddonKind,
})
}
}
@@ -498,6 +450,13 @@ export function useServerInstallContent({
)
if (queuedPlans.size === 0) return true
try {
await waitForServerContextRuntimeReady(client, serverId)
} catch (error) {
handleError(error as Error)
return false
}
isInstallingQueuedServerInstalls.value = true
queuedInstallProgress.value = {
completed: 0,
@@ -518,9 +477,6 @@ export function useServerInstallContent({
})
if (!result.ok) {
for (const plan of result.attemptedPlans) {
removePendingServerContentInstall(serverId, worldId, plan.projectId)
}
handleError(result.error as Error)
return false
}
@@ -559,23 +515,6 @@ export function useServerInstallContent({
if (sid && wid) {
writeStoredServerInstallQueue(sid, wid, plans)
writePendingServerContentInstallBaseline(sid, wid, [
...getServerInstalledContentKeys(),
...optimisticallyInstalledProjectIds.value,
])
addPendingServerContentInstalls(sid, wid, getQueuedInstallPlaceholderFallbacks(plans))
void getQueuedInstallPlaceholders(plans)
.then((items) => {
const pendingProjectIds = new Set(
readPendingServerContentInstalls(sid, wid).map((item) => item.projectId),
)
addPendingServerContentInstalls(
sid,
wid,
items.filter((item) => pendingProjectIds.has(item.projectId)),
)
})
.catch((err) => handleError(err as Error))
}
await navigateTo(backUrl)
void flushQueuedServerInstalls(sid, wid)
@@ -584,11 +523,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
@@ -596,6 +530,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)) {
@@ -673,9 +618,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: {
@@ -687,13 +664,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
@@ -703,14 +674,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)
})
@@ -718,15 +697,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,
@@ -740,7 +724,7 @@ export function useServerInstallContent({
installProgress: queuedInstallProgress.value,
clearQueued: clearQueuedServerInstalls,
clearSelected: clearQueuedServerInstalls,
onBack: flushQueuedServerInstalls,
onBack: fromContext.value === 'create-instance' ? undefined : flushQueuedServerInstalls,
discardSelectedAndBack: discardQueuedServerInstallsAndBack,
installSelected: installQueuedServerInstallsAndBack,
}
@@ -796,6 +780,7 @@ export function useServerInstallContent({
currentWorldId,
serverData,
serverContentData,
serverContentProjectType,
serverFilters,
serverHideInstalled,
serverContentServerOnly,
@@ -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 })
})
@@ -114,6 +114,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 +165,7 @@ const {
queuedServerInstallProjectIds,
queuedServerInstallCount,
isInstallingQueuedServerInstalls,
serverContentProjectType,
installContext,
setBrowseSearchState,
syncHiddenInstalledProjectIds,
@@ -177,6 +179,27 @@ 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 },
)
function getServerModpackContent(project: Labrinth.Search.v3.ResultSearchProject) {
const content = project.minecraft_java_server?.content
if (content?.kind === 'modpack') {
@@ -401,8 +424,6 @@ const advancedFiltersCollapsed = computed({
},
})
const projectTypeId = computed(() => projectType.value?.id ?? 'mod')
debug('projectTypeId:', projectTypeId.value)
watch(projectTypeId, (val) => debug('projectTypeId changed:', val))
@@ -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>
@@ -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,
})
@@ -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.
}
@@ -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>
+4 -2
View File
@@ -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)
},
+2 -1
View File
@@ -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))
@@ -9,6 +9,7 @@ export type WebSocketEventHandler<
export interface WebSocketConnection {
serverId: string
socket: WebSocket
authenticated: boolean
reconnectAttempts: number
reconnectTimer?: ReturnType<typeof setTimeout>
isReconnecting: boolean
@@ -31,6 +32,7 @@ export abstract class AbstractWebSocketClient {
protected readonly MAX_RECONNECT_ATTEMPTS = 10
protected readonly RECONNECT_BASE_DELAY = 1000
protected readonly RECONNECT_MAX_DELAY = 30000
protected readonly AUTHENTICATION_TIMEOUT = 30000
constructor(
protected client: {
@@ -58,6 +60,7 @@ export abstract class AbstractWebSocketClient {
}
if (status && !status.connected && !options?.force) {
await this.waitForAuthentication(serverId)
return
}
@@ -69,6 +72,28 @@ export abstract class AbstractWebSocketClient {
await this.connect(serverId, auth)
}
protected async waitForAuthentication(serverId: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
let unsubscribe = () => {}
const timeout = setTimeout(() => {
unsubscribe()
reject(new Error(`WebSocket authentication timed out for server ${serverId}`))
}, this.AUTHENTICATION_TIMEOUT)
unsubscribe = this.on(serverId, 'auth-ok', () => {
clearTimeout(timeout)
unsubscribe()
resolve()
})
if (this.getStatus(serverId)?.connected) {
clearTimeout(timeout)
unsubscribe()
resolve()
}
})
}
on<E extends Archon.Websocket.v0.WSEventType>(
serverId: string,
eventType: E,
@@ -88,7 +113,7 @@ export abstract class AbstractWebSocketClient {
if (!connection) return null
return {
connected: connection.socket.readyState === WebSocket.OPEN,
connected: connection.socket.readyState === WebSocket.OPEN && connection.authenticated,
reconnecting: connection.isReconnecting,
reconnectAttempts: connection.reconnectAttempts,
}
@@ -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
}
@@ -1111,6 +1150,53 @@ export namespace Archon {
version_id: string
}
export type InstallProgressFileKey = {
type: 'file'
install_type: 'install' | 'update'
project_id: string
version_id: string
parent_directory: string
source_filename: string | null
target_filename?: string | null
}
export type InstallProgressModrinthModpackKey = {
type: 'modrinth_modpack'
project_id: string
version_id: string
}
export type InstallProgressLocalModpackKey = {
type: 'local_modpack'
filename: string
}
export type InstallProgressPlatformKey = {
type: 'platform'
platform: 'forge' | 'neoforge' | 'fabric' | 'quilt' | 'paper' | 'purpur' | 'vanilla'
platform_version: string
game_version: string
}
export type InstallProgressKey =
| InstallProgressFileKey
| InstallProgressModrinthModpackKey
| InstallProgressLocalModpackKey
| InstallProgressPlatformKey
export type InstallProgressItem = {
world_id: string
key: InstallProgressKey
id: string
progress: number | null
error: string | null
}
export type WSInstallProgressEvent = {
event: 'install-progress'
items: InstallProgressItem[]
}
export type FilesystemOpKind = 'unarchive'
export type FilesystemOpState =
@@ -1208,6 +1294,7 @@ export namespace Archon {
| WSInstallationResultEvent
| WSUptimeEvent
| WSNewModEvent
| WSInstallProgressEvent
| WSFilesystemOpsEvent
export type WSEventType = WSEvent['event']
+2 -2
View File
@@ -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'
@@ -94,7 +94,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,
})
}
}
+74 -26
View File
@@ -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
@@ -19,12 +19,14 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
return new Promise((resolve, reject) => {
let settled = false
try {
const ws = new WebSocket(getNodeWebSocketUrl(auth.url))
const connection: WebSocketConnection = {
serverId,
socket: ws,
authenticated: false,
reconnectAttempts: 0,
reconnectTimer: undefined,
isReconnecting: false,
@@ -37,18 +39,26 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
connection.reconnectAttempts = 0
connection.isReconnecting = false
resolve()
}
ws.onmessage = (messageEvent) => {
try {
const data = JSON.parse(messageEvent.data) as Archon.Websocket.v0.WSEvent
if (data.event === 'auth-ok') {
connection.authenticated = true
} else if (data.event === 'auth-incorrect') {
connection.authenticated = false
}
const eventKey = `${serverId}:${data.event}` as keyof WSEventMap
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.emitter.emit(eventKey, data as any)
if (data.event === 'auth-ok' && !settled) {
settled = true
resolve()
}
if (data.event === 'auth-expiring' || data.event === 'auth-incorrect') {
this.handleAuthExpiring(serverId).catch(console.error)
}
@@ -58,11 +68,20 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
}
ws.onclose = (event) => {
connection.authenticated = false
console.debug(`[WebSocket] Closed for server ${serverId}:`, {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
})
if (!settled) {
settled = true
reject(
new Error(
`WebSocket closed before authentication for server ${serverId} (code: ${event.code})`,
),
)
}
if (event.code !== NORMAL_CLOSURE) {
this.scheduleReconnect(serverId, auth)
}
@@ -77,13 +96,17 @@ export class GenericWebSocketClient extends AbstractWebSocketClient {
readyStateLabel: ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'][readyState],
type: (event as Event).type,
})
reject(
new Error(
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
),
)
if (!settled) {
settled = true
reject(
new Error(
`WebSocket connection failed for server ${serverId} (readyState: ${readyState})`,
),
)
}
}
} catch (error) {
settled = true
reject(error)
}
})
+4
View File
@@ -110,6 +110,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
@@ -652,6 +655,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
+1
View File
@@ -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

+1
View File
@@ -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

@@ -18,6 +18,7 @@ const props = defineProps<{
belowModal?: boolean
hideWhenModalOpen?: boolean
inline?: boolean
toolbarMaxWidth?: string
}>()
const INTERCOM_BUBBLE_GAP = 8
@@ -48,6 +49,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
@@ -219,7 +225,7 @@ defineOptions({
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 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 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,
@@ -227,6 +233,7 @@ defineOptions({
},
inline ? 'w-full' : 'mx-auto md:max-w-[60vw]',
]"
:style="toolbarStyle"
@animationend="attentionRequested = false"
>
<slot />
@@ -248,6 +255,12 @@ defineOptions({
right: auto;
}
@media (min-width: 768px) {
.floating-action-toolbar {
max-width: var(--floating-action-bar-toolbar-max-width, 60vw);
}
}
.floating-action-bar-attention {
animation: floating-action-bar-attention 300ms ease-in-out;
}
+154 -31
View File
@@ -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 {}
@@ -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,6 +237,12 @@ 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 && !singleItemAnimationDisabled.value) {
return {
...position,
opacity: 0,
}
}
if (!item || !enteringItemIds.value.has(item.id)) return position
return {
@@ -234,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)
@@ -355,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
}
@@ -380,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)
}
})
@@ -459,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"
@@ -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)
@@ -39,15 +39,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',
@@ -55,7 +55,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',
@@ -63,7 +63,7 @@ export const creationFlowMessages = defineMessages({
},
setupServerButton: {
id: 'creation-flow.button.setup-server',
defaultMessage: 'Setup server',
defaultMessage: 'Create instance',
},
finishButton: {
id: 'creation-flow.button.finish',
@@ -89,7 +89,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,
}
@@ -220,9 +220,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
@@ -430,8 +427,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,7 +1,8 @@
<template>
<Admonition
:type="contentError ? 'critical' : 'info'"
:dismissible="dismissible"
v-if="installation"
:type="installation.status === 'failed' ? 'critical' : 'info'"
:dismissible="installation.status === 'failed'"
:progress="progressValue"
progress-color="blue"
:waiting="isWaiting"
@@ -10,23 +11,8 @@
<template #header>
{{ headerLabel }}
</template>
<template v-if="contentError">
{{ errorLabel }}
</template>
<template v-else-if="effectivePhase">{{ phaseLabel }}</template>
<div v-else class="ticker-container">
<div class="ticker-content">
<div
v-for="(message, index) in tickerMessages"
:key="message"
class="ticker-item"
:class="{ active: index === currentIndex % tickerMessages.length }"
>
{{ message }}
</div>
</div>
</div>
<template v-if="contentError" #top-right-actions>
{{ installation.status === 'failed' ? errorLabel : descriptionLabel }}
<template v-if="installation.status === 'failed'" #top-right-actions>
<ButtonStyled color="red" type="outlined">
<button
v-tooltip="retryDisabled ? retryDisabledTooltip : undefined"
@@ -45,29 +31,17 @@
<script setup lang="ts">
import { RotateCounterClockwiseIcon } from '@modrinth/assets'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectModrinthServerContext } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import { formatLoaderLabel } from '#ui/utils/loaders'
import Admonition from '../base/Admonition.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
export interface SyncProgress {
phase: 'Analyzing' | 'InstallingPack' | 'InstallingLoader' | 'Addons'
percent: number
}
export interface ContentError {
step: string
description: string
}
const props = defineProps<{
progress?: SyncProgress | null
fallbackPhase?: SyncProgress['phase'] | null
contentError?: ContentError | null
dismissible?: boolean
defineProps<{
retryDisabled?: boolean
retryDisabledTooltip?: string
}>()
@@ -78,191 +52,136 @@ const emit = defineEmits<{
}>()
const { formatMessage } = useVIntl()
const { installation } = injectModrinthServerContext()
const messages = defineMessages({
errorHeader: {
id: 'servers.installing-banner.error.header',
defaultMessage: 'Installation failed',
},
preparingHeader: {
id: 'servers.installing-banner.preparing.header',
defaultMessage: "We're preparing your server",
},
invalidLoaderVersionError: {
id: 'servers.installing-banner.error.invalid-loader-version',
defaultMessage:
'The specified loader or Minecraft version could not be installed. It may be invalid or unsupported.',
},
unsupportedLoaderVersionError: {
id: 'servers.installing-banner.error.unsupported-loader-version',
defaultMessage: 'This version of Minecraft or loader is not yet supported by Modrinth Hosting.',
},
internalPlatformError: {
id: 'servers.installing-banner.error.internal-platform',
defaultMessage: 'An internal error occurred while installing the platform. Please try again.',
},
noPrimaryFileError: {
id: 'servers.installing-banner.error.no-primary-file',
defaultMessage:
'This modpack version does not include a downloadable file. It may have been packaged incorrectly.',
},
modpackInstallFailedError: {
id: 'servers.installing-banner.error.modpack-install-failed',
defaultMessage: 'The modpack could not be installed. It may be corrupted or incompatible.',
},
unknownError: {
id: 'servers.installing-banner.error.unknown',
defaultMessage: 'An unexpected error occurred during installation.',
},
preparingHeader: {
id: 'servers.installing-banner.preparing.header',
defaultMessage: 'Preparing your server',
},
installingPlatform: {
id: 'servers.installing-banner.phase.installing-platform',
defaultMessage: 'Installing platform...',
id: 'servers.installing-banner.installing-platform',
defaultMessage: 'Installing {loader} for Minecraft {version}',
},
installingMinecraft: {
id: 'servers.installing-banner.installing-minecraft',
defaultMessage: 'Installing Minecraft {version}',
},
installingModpack: {
id: 'servers.installing-banner.phase.installing-modpack',
defaultMessage: 'Installing modpack...',
id: 'servers.installing-banner.installing-modpack',
defaultMessage: 'Installing modpack',
},
installingAddons: {
id: 'servers.installing-banner.phase.installing-addons',
defaultMessage: 'Installing addons...',
installingLocalModpack: {
id: 'servers.installing-banner.installing-local-modpack',
defaultMessage: 'Installing {filename}',
},
tickerOrganizingFiles: {
id: 'servers.installing-banner.ticker.organizing-files',
defaultMessage: 'Organizing files...',
preparingDescription: {
id: 'servers.installing-banner.description.preparing',
defaultMessage: 'Preparing your server...',
},
tickerDownloadingMods: {
id: 'servers.installing-banner.ticker.downloading-mods',
defaultMessage: 'Downloading mods...',
applyingDescription: {
id: 'servers.installing-banner.description.applying',
defaultMessage: 'Applying your installation changes...',
},
tickerConfiguringServer: {
id: 'servers.installing-banner.ticker.configuring-server',
defaultMessage: 'Configuring server...',
durationDescription: {
id: 'servers.installing-banner.description.duration',
defaultMessage: 'This installation may take several minutes...',
},
tickerSettingUpEnvironment: {
id: 'servers.installing-banner.ticker.setting-up-environment',
defaultMessage: 'Setting up environment...',
controlsDescription: {
id: 'servers.installing-banner.description.controls',
defaultMessage: 'Server controls will unlock when installation finishes.',
},
tickerAddingJava: {
id: 'servers.installing-banner.ticker.adding-java',
defaultMessage: 'Adding Java...',
stillWorkingDescription: {
id: 'servers.installing-banner.description.still-working',
defaultMessage: 'Still working—your installation is in progress...',
},
})
const errorLabel = computed(() => {
const desc = props.contentError?.description?.toLowerCase()
const step = props.contentError?.step
if (step === 'modloader') {
if (desc === 'the specified version may be incorrect') {
return formatMessage(messages.invalidLoaderVersionError)
}
if (desc === 'this version is not yet supported') {
return formatMessage(messages.unsupportedLoaderVersionError)
}
if (desc === 'internal error') {
return formatMessage(messages.internalPlatformError)
}
}
if (step === 'modpack') {
if (desc?.includes('no primary file')) {
return formatMessage(messages.noPrimaryFileError)
}
if (desc?.includes('failed to install')) {
return formatMessage(messages.modpackInstallFailedError)
}
}
return props.contentError?.description ?? formatMessage(messages.unknownError)
})
const effectivePhase = computed(() => props.progress?.phase ?? props.fallbackPhase ?? null)
const headerLabel = computed(() => {
if (props.contentError) return formatMessage(messages.errorHeader)
if (effectivePhase.value === 'Addons') return formatMessage(commonMessages.installingContentLabel)
const current = installation.value
if (!current) return ''
if (current.status === 'failed') return formatMessage(messages.errorHeader)
switch (current.key.type) {
case 'platform': {
if (current.key.platform === 'vanilla') {
return formatMessage(messages.installingMinecraft, {
version: current.key.game_version,
})
}
return formatMessage(messages.installingPlatform, {
loader: formatLoaderLabel(current.key.platform),
version: current.key.game_version,
})
}
case 'modrinth_modpack':
return formatMessage(messages.installingModpack)
case 'local_modpack':
return formatMessage(messages.installingLocalModpack, {
filename: current.key.filename,
})
case 'unknown':
return formatMessage(messages.preparingHeader)
}
return formatMessage(messages.preparingHeader)
})
const phaseLabel = computed(() => {
switch (effectivePhase.value) {
case 'InstallingLoader':
return formatMessage(messages.installingPlatform)
case 'InstallingPack':
return formatMessage(messages.installingModpack)
case 'Addons':
return formatMessage(messages.installingAddons)
const descriptionIndex = ref(0)
const installationId = computed(() => installation.value?.id ?? null)
watch(installationId, () => {
descriptionIndex.value = 0
})
const descriptionLabel = computed(() => {
switch (descriptionIndex.value) {
case 0:
return formatMessage(messages.preparingDescription)
case 1:
return formatMessage(messages.applyingDescription)
case 2:
return formatMessage(messages.durationDescription)
default:
return formatMessage(commonMessages.installingLabel)
return descriptionIndex.value % 2 === 1
? formatMessage(messages.controlsDescription)
: formatMessage(messages.stillWorkingDescription)
}
})
const errorLabel = computed(() => installation.value?.error ?? formatMessage(messages.unknownError))
const progressValue = computed(() => {
if (props.contentError) return undefined
return props.progress ? props.progress.percent / 100 : 0
const current = installation.value
if (!current || current.status === 'failed') return undefined
return current.progress == null ? 0 : current.progress / 100
})
const isWaiting = computed(() => {
if (props.contentError) return false
return !props.progress || props.progress.percent <= 0
const current = installation.value
if (!current || current.status === 'failed') return false
return current.progress == null || current.progress <= 0
})
const tickerMessages = computed(() => [
formatMessage(messages.tickerOrganizingFiles),
formatMessage(messages.tickerDownloadingMods),
formatMessage(messages.tickerConfiguringServer),
formatMessage(messages.tickerSettingUpEnvironment),
formatMessage(messages.tickerAddingJava),
])
const currentIndex = ref(0)
let intervalId: ReturnType<typeof setInterval> | null = null
onMounted(() => {
intervalId = setInterval(() => {
currentIndex.value = (currentIndex.value + 1) % tickerMessages.value.length
}, 3000)
if (installation.value?.status === 'pending' || installation.value?.status === 'installing') {
descriptionIndex.value += 1
}
}, 15_000)
})
onUnmounted(() => {
if (intervalId) {
clearInterval(intervalId)
}
if (intervalId) clearInterval(intervalId)
})
</script>
<style scoped>
.ticker-container {
height: 20px;
width: 100%;
position: relative;
}
.ticker-content {
position: relative;
width: 100%;
}
.ticker-item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 20px;
display: flex;
align-items: center;
white-space: nowrap;
color: var(--color-secondary-text);
opacity: 0;
transform: scale(0.9);
filter: blur(4px);
transition: all 0.3s ease-in-out;
}
.ticker-item.active {
opacity: 1;
transform: scale(1);
filter: blur(0);
}
</style>
@@ -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',
@@ -131,6 +131,11 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
await handleMrpackUpload(ctx.modpackFile.value, ctx.buildProperties())
} else if (ctx.setupType.value === 'modpack' && ctx.modpackSelection.value) {
debug('onFlowComplete: modpack selection path, calling installContent')
serverContext.beginInstallation({
type: 'modrinth_modpack',
project_id: ctx.modpackSelection.value.projectId,
version_id: ctx.modpackSelection.value.versionId,
})
await client.archon.content_v1.installContent(
serverContext.serverId,
serverContext.worldId.value!,
@@ -159,6 +164,15 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
apiLoader: toApiLoader(loader ?? 'vanilla'),
})
serverContext.beginInstallation({
type: 'platform',
platform: (loader ?? 'vanilla') as Extract<
Archon.Websocket.v0.InstallProgressKey,
{ type: 'platform' }
>['platform'],
platform_version: loaderVersion,
game_version: ctx.selectedGameVersion.value ?? '',
})
await client.archon.content_v1.installContent(
serverContext.serverId,
serverContext.worldId.value!,
@@ -183,6 +197,7 @@ async function onFlowComplete(ctx: CreationFlowContextValue) {
creationFlowRef.value?.hide()
} catch (error) {
debug('onFlowComplete: ERROR', error)
serverContext.cancelOptimisticInstallation()
if ((error as ModrinthApiError).statusCode === 429) {
addNotification({
title: formatMessage(messages.rateLimitTitle),
@@ -210,6 +225,10 @@ async function handleMrpackUpload(file: File, properties: Archon.Content.v1.Prop
{ softOverride: false },
)
await uploadProgressModal.value!.track(handle)
serverContext.beginInstallation({
type: 'local_modpack',
filename: file.name,
})
emitReinstall()
}
@@ -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,18 +1,15 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import Admonition from '#ui/components/base/Admonition.vue'
import StackedAdmonitions, {
type StackedAdmonitionItem,
} from '#ui/components/base/StackedAdmonitions.vue'
import InstallingBanner, {
type ContentError,
type SyncProgress,
} from '#ui/components/servers/InstallingBanner.vue'
import InstallingBanner 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'
@@ -21,12 +18,11 @@ import FileOperationAdmonition from './FileOperationAdmonition.vue'
import UploadAdmonition from './UploadAdmonition.vue'
const props = defineProps<{
syncProgress?: SyncProgress | null
contentError?: ContentError | null
showInstanceInfo?: boolean
}>()
const emit = defineEmits<{
'content-retry': []
'installation-retry': []
}>()
const { formatMessage } = useVIntl()
@@ -45,6 +41,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,17 +60,14 @@ const messages = defineMessages({
},
})
const isOnContentTab = computed(() => route.path.includes('/content'))
const isOnFilesTab = computed(() => route.path.includes('/files'))
const bannerCoversInstalling = computed(
const isOnInstancesList = computed(
() => route.path.includes('/instances') && !route.params.instance_id,
)
const isOnContentTab = computed(
() =>
ctx.server.value?.status === 'installing' ||
ctx.isSyncingContent.value ||
ctx.busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
),
route.path.includes('/content') ||
(!!route.params.instance_id && !isOnFilesTab.value && !route.path.includes('/backups')),
)
function isBackupReason(id: string) {
@@ -73,13 +75,13 @@ function isBackupReason(id: string) {
}
function isInstallingReason(id: string) {
return id === 'servers.busy.installing' || id === 'servers.busy.syncing-content'
return id === 'servers.busy.installing'
}
const filteredBusyReasons = computed(() =>
ctx.busyReasons.value.filter((r) => {
if (isBackupReason(r.reason.id)) return false
if (bannerCoversInstalling.value && isInstallingReason(r.reason.id)) return false
if (isInstallingReason(r.reason.id)) return false
return true
}),
)
@@ -95,15 +97,27 @@ const filesBusyHeader = computed(() =>
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 contentErrorKey = computed(() =>
props.contentError ? `${props.contentError.step}:${props.contentError.description}` : null,
const instanceCount = computed(() => ctx.serverFull.value?.worlds.length ?? null)
const showInstanceInfoAdmonition = computed(
() =>
props.showInstanceInfo &&
isOnInstancesList.value &&
instanceInfoAdmonitionStorageLoaded.value &&
!instanceInfoAdmonitionDismissed.value &&
instanceCount.value === 1,
)
watch(contentErrorKey, (key) => {
if (!key) {
dismissedContentErrorKey.value = null
onMounted(() => {
try {
instanceInfoAdmonitionDismissed.value =
window.localStorage.getItem(INSTANCE_INFO_ADMONITION_KEY) === 'true'
instanceInfoAdmonitionStorageLoaded.value = true
} catch {
instanceInfoAdmonitionStorageLoaded.value = false
}
})
@@ -166,17 +180,13 @@ type ServerAdmonitionItem = StackedAdmonitionItem & {
| { kind: 'upload' }
| { kind: 'fs-op'; op: FileOperation }
| { kind: 'backup'; entry: BackupAdmonitionEntry }
| { kind: 'instance-info' }
| { kind: 'busy-content' }
| { kind: 'busy-files' }
)
const showInstallingBanner = computed(() => {
if (!ctx.server.value) return false
const installing = bannerCoversInstalling.value || !!props.contentError
if (!installing) return false
if (contentErrorKey.value && dismissedContentErrorKey.value === contentErrorKey.value)
return false
return props.syncProgress?.phase !== 'Analyzing'
return !!ctx.installation.value && ctx.installation.value.status !== 'complete'
})
function fsOpType(op: FileOperation): StackedAdmonitionItem['type'] {
@@ -210,10 +220,11 @@ const stackItems = computed<ServerAdmonitionItem[]>(() => {
let sortIndex = 0
if (showInstallingBanner.value) {
const failed = ctx.installation.value?.status === 'failed'
out.push({
id: 'installing',
type: props.contentError ? 'critical' : 'info',
dismissible: !!props.contentError,
type: failed ? 'critical' : 'info',
dismissible: failed,
kind: 'installing',
priority: 0,
sortIndex: sortIndex++,
@@ -255,6 +266,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({
@@ -365,8 +387,8 @@ async function onDismissAll() {
const tasks: Promise<unknown>[] = []
for (const it of stackItems.value) {
if (!it.dismissible) continue
if (it.kind === 'installing' && props.contentError) {
onContentErrorDismiss()
if (it.kind === 'installing') {
onInstallationDismiss()
} else if (it.kind === 'fs-op' && it.op.id) {
const { op } = it
if (op.state === 'done' || op.state?.startsWith('fail')) {
@@ -374,6 +396,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)
@@ -385,9 +409,18 @@ function onFileOpDismiss(item: ServerAdmonitionItem) {
}
}
function onContentErrorDismiss() {
if (contentErrorKey.value) {
dismissedContentErrorKey.value = contentErrorKey.value
function onInstallationDismiss() {
if (ctx.installation.value) {
ctx.dismissInstallation(ctx.installation.value.id)
}
}
function onInstanceInfoDismiss() {
instanceInfoAdmonitionDismissed.value = true
try {
window.localStorage.setItem(INSTANCE_INFO_ADMONITION_KEY, 'true')
} catch {
instanceInfoAdmonitionStorageLoaded.value = false
}
}
</script>
@@ -396,20 +429,17 @@ function onContentErrorDismiss() {
<StackedAdmonitions
:items="stackItems"
:dismiss-all-enabled="hasBulkDismissableItems"
:animate-single-item="false"
class="w-full"
@dismiss-all="onDismissAll"
>
<template #item="{ item, dismissible }">
<InstallingBanner
v-if="item.kind === 'installing'"
:progress="syncProgress"
:fallback-phase="isOnContentTab && !syncProgress ? 'Addons' : null"
:content-error="contentError"
:dismissible="dismissible && !!contentError"
:retry-disabled="!canSetup"
:retry-disabled-tooltip="permissionDeniedMessage"
@dismiss="onContentErrorDismiss"
@retry="emit('content-retry')"
@dismiss="onInstallationDismiss"
@retry="emit('installation-retry')"
/>
<UploadAdmonition
v-else-if="item.kind === 'upload'"
@@ -441,6 +471,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 ''
})
@@ -1,14 +1,24 @@
<template>
<div class="contents">
<div class="flex flex-row items-center gap-2 rounded-lg">
<ButtonStyled v-if="isInstalling" type="standard" color="brand" size="large">
<ButtonStyled v-if="isInstalling" type="standard" color="brand" :size="size">
<button disabled class="flex-shrink-0">
<LoaderCircleIcon class="size-5 animate-spin" /> Installing...
</button>
</ButtonStyled>
<template v-else-if="showRestartButton">
<ButtonStyled type="standard" color="orange" size="large">
<JoinedButtons
v-if="powerActionWorlds.length"
color="orange"
:size="size"
:actions="restartSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canTakeAction"
:primary-tooltip="busyTooltip"
:dropdown-tooltip="busyTooltip"
/>
<ButtonStyled v-else type="standard" color="orange" :size="size">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<UpdatedIcon />
<span>{{ primaryActionText }}</span>
@@ -17,7 +27,7 @@
<JoinedButtons
color="red"
size="large"
:size="size"
:actions="stopSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canKill"
@@ -34,7 +44,7 @@
<template v-else-if="isStopping">
<JoinedButtons
color="red"
size="large"
:size="size"
:actions="stopSplitActions"
:primary-disabled="true"
:dropdown-disabled="!canKill"
@@ -49,10 +59,20 @@
</template>
<template v-else>
<ButtonStyled type="standard" color="brand" size="large">
<JoinedButtons
v-if="powerActionWorlds.length"
color="brand"
:size="size"
:actions="startSplitActions"
:primary-disabled="!canTakeAction"
:dropdown-disabled="!canTakeAction"
:primary-tooltip="busyTooltip"
:dropdown-tooltip="busyTooltip"
/>
<ButtonStyled v-else type="standard" color="brand" :size="size">
<button v-tooltip="busyTooltip" :disabled="!canTakeAction" @click="handlePrimaryAction">
<PlayIcon />
<span>{{ primaryActionText }}</span>
<span>{{ startActionText }}</span>
</button>
</ButtonStyled>
</template>
@@ -77,9 +97,15 @@ import { useServerPowerAction } from './use-server-power-action'
const props = withDefaults(
defineProps<{
disabled?: boolean
size?: 'standard' | 'large' | 'small'
startLabel?: string
worlds?: { id: string; name: string }[]
}>(),
{
disabled: false,
size: 'large',
startLabel: 'Start',
worlds: () => [],
},
)
@@ -97,6 +123,42 @@ const {
disabled: computed(() => props.disabled),
})
const size = computed(() => props.size)
const startActionText = computed(() =>
primaryActionText.value === 'Start' ? props.startLabel : primaryActionText.value,
)
const powerActionWorlds = computed(() => (props.worlds.length > 1 ? props.worlds : []))
const startSplitActions = computed<JoinedButtonAction[]>(() => [
{
id: 'start',
label: startActionText.value,
icon: PlayIcon,
action: handlePrimaryAction,
},
...powerActionWorlds.value.map((world) => ({
id: `start-${world.id}`,
label: `Start with ${world.name}`,
icon: PlayIcon,
action: () => initiateAction('Start', world.id),
})),
])
const restartSplitActions = computed<JoinedButtonAction[]>(() => [
{
id: 'restart',
label: primaryActionText.value,
icon: UpdatedIcon,
action: handlePrimaryAction,
},
...powerActionWorlds.value.map((world) => ({
id: `restart-${world.id}`,
label: `Restart with ${world.name}`,
icon: UpdatedIcon,
action: () => initiateAction('Restart', world.id),
})),
])
const stopSplitActions = computed<JoinedButtonAction[]>(() => [
{
id: 'stop',
@@ -0,0 +1,183 @@
<template>
<div class="w-full flex flex-col gap-4" :class="{ 'mt-4': isNuxt }">
<PageHeader :title="props.name || props.fallbackName" :header-class="props.headerClass">
<template #leading>
<ButtonStyled circular size="large">
<button
v-tooltip="props.backLabel"
type="button"
:aria-label="props.backLabel"
@click="router.push(props.backHref)"
>
<LeftArrowIcon aria-hidden="true" />
</button>
</ButtonStyled>
</template>
<template v-if="headerMetadata.length" #metadata>
<PageHeaderMetadata>
<PageHeaderMetadataItem
v-for="item in headerMetadata"
:key="item.id"
:icon="item.icon"
:icon-props="item.iconProps"
>
{{ item.label }}
</PageHeaderMetadataItem>
</PageHeaderMetadata>
</template>
<template v-if="props.actions.length" #actions>
<PageHeaderActions>
<template v-for="action in props.actions" :key="action.id">
<JoinedButtons
v-if="action.joinedActions?.length"
:actions="action.joinedActions"
:color="action.color ?? 'standard'"
: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"
>
<button
v-tooltip="action.tooltip"
type="button"
:disabled="action.disabled"
:aria-label="action.ariaLabel ?? action.tooltip ?? action.label"
@click="action.onClick"
>
<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>
</PageHeaderActions>
</template>
</PageHeader>
</div>
</template>
<script setup lang="ts">
import { NuxtModrinthClient } from '@modrinth/api-client'
import { LeftArrowIcon, TagCategoryGamepad2Icon as Gamepad2Icon, TimerIcon } from '@modrinth/assets'
import { type Component, computed } from 'vue'
import { useRouter } from 'vue-router'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import JoinedButtons, { type JoinedButtonAction } from '#ui/components/base/JoinedButtons.vue'
import PageHeader from '#ui/components/base/page-header/index.vue'
import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.vue'
import PageHeaderMetadataItem from '#ui/components/base/page-header/metadata/page-header-metadata-item.vue'
import PageHeaderActions from '#ui/components/base/page-header/page-header-actions.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import { injectModrinthClient } from '#ui/providers'
import { formatLoaderLabel } from '#ui/utils/loaders'
type MetadataItem = {
id: string
label: string
icon: Component
iconProps?: Record<string, unknown>
}
type HeaderAction = {
id: string
label: string
icon?: Component
iconProps?: Record<string, unknown>
iconClass?: string
tooltip?: string
ariaLabel?: string
onClick?: () => void | Promise<void>
disabled?: boolean
labelHidden?: boolean
circular?: boolean
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
size?: 'standard' | 'large' | 'small'
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
joinedActions?: JoinedButtonAction[]
primaryDisabled?: boolean
dropdownDisabled?: boolean
primaryMuted?: boolean
}
const props = withDefaults(
defineProps<{
name?: string | null
metadataItems?: MetadataItem[]
gameVersion?: string | null
loader?: string | null
loaderVersion?: string | null
lastActive?: string | null
backHref: string
backLabel: string
fallbackName?: string
headerClass?: string
actions?: HeaderAction[]
}>(),
{
name: null,
metadataItems: () => [],
gameVersion: null,
loader: null,
loaderVersion: null,
lastActive: null,
fallbackName: 'Instance',
headerClass: '',
actions: () => [],
},
)
const client = injectModrinthClient()
const router = useRouter()
const isNuxt = computed(() => client instanceof NuxtModrinthClient)
const loaderLabel = computed(() => {
if (!props.loader) return null
const label = formatLoaderLabel(props.loader.toLowerCase())
return [label, props.loaderVersion].filter(Boolean).join(' ')
})
const headerMetadata = computed<MetadataItem[]>(() => {
if (props.metadataItems.length) return props.metadataItems
const items: MetadataItem[] = []
if (props.gameVersion) {
items.push({
id: 'game-version',
label: props.gameVersion,
icon: Gamepad2Icon,
})
}
if (props.loader && loaderLabel.value) {
items.push({
id: 'loader',
label: loaderLabel.value,
icon: LoaderIcon,
iconProps: {
loader: formatLoaderLabel(props.loader.toLowerCase()),
},
})
}
if (props.lastActive) {
items.push({
id: 'last-active',
label: props.lastActive,
icon: TimerIcon,
})
}
return items
})
</script>
@@ -1 +1,2 @@
export { default as PanelServerActionButton } from './PanelServerActionButton.vue'
export { default as ServerInstanceManageHeader } from './ServerInstanceManageHeader.vue'
@@ -1,3 +1,4 @@
import type { Archon } from '@modrinth/api-client'
import { computed, type Ref } from 'vue'
import { useVIntl } from '#ui/composables/i18n'
@@ -10,23 +11,22 @@ import {
export type PowerAction = 'Start' | 'Stop' | 'Restart' | 'Kill'
const powerActionMap = {
Start: 'start',
Stop: 'stop',
Restart: 'restart',
Kill: 'kill',
} as const satisfies Record<PowerAction, Archon.Servers.v1.WorldPowerAction>
export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { serverId, server, powerState, isSyncingContent, busyReasons } =
injectModrinthServerContext()
const { serverId, worldId, powerState, busyReasons } = injectModrinthServerContext()
const { addNotification } = injectNotificationManager()
const { canUsePowerActions, permissionDeniedMessage } = useServerPermissions()
const isInstalling = computed(
() =>
server.value.status === 'installing' ||
isSyncingContent.value ||
busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' ||
r.reason.id === 'servers.busy.syncing-content',
),
const isInstalling = computed(() =>
busyReasons.value.some((reason) => reason.reason.id === 'servers.busy.installing'),
)
const isRunning = computed(() => powerState.value === 'running')
const isStopping = computed(() => powerState.value === 'stopping')
@@ -45,16 +45,18 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
const busyTooltip = computed(() => {
if (!canUsePowerActions.value) return permissionDeniedMessage.value
if (!worldId.value) return 'Your server instance is loading'
if (isStarting.value) return 'Your server is starting'
return busyReasons.value.length > 0 ? formatMessage(busyReasons.value[0].reason) : undefined
})
const canTakeAction = computed(
() => !isTransitioning.value && !isBlockedByPropsBusyOrPermission.value,
() => !!worldId.value && !isTransitioning.value && !isBlockedByPropsBusyOrPermission.value,
)
const canKill = computed(
() =>
!!worldId.value &&
!isBlockedByPropsBusyOrPermission.value &&
(isStopping.value || isRunning.value || isStarting.value),
)
@@ -71,9 +73,13 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
}
})
async function sendPowerAction(action: PowerAction) {
async function sendPowerAction(action: PowerAction, targetWorldId = worldId.value) {
if (!targetWorldId) return
try {
await client.archon.servers_v0.power(serverId, action)
await client.archon.servers_v1.powerWorld(serverId, targetWorldId, {
action: powerActionMap[action],
})
} catch (error) {
console.error(`Error performing ${action} on server:`, error)
addNotification({
@@ -84,13 +90,13 @@ export function useServerPowerAction(options?: { disabled?: Ref<boolean> }) {
}
}
function initiateAction(action: PowerAction) {
function initiateAction(action: PowerAction, targetWorldId = worldId.value) {
if (action === 'Kill') {
if (!canKill.value) return
} else {
if (!canTakeAction.value) return
}
void sendPowerAction(action)
void sendPowerAction(action, targetWorldId)
}
function handlePrimaryAction() {
+6 -5
View File
@@ -11,17 +11,18 @@ export * from './i18n'
export * from './i18n-debug'
export * from './page-leave-safety'
export * from './scroll-indicator'
export * from './server-backup'
export * from './server-backups-queue'
export * from './server-console'
export * from './server-manage-core-runtime'
export * from './server-context-runtime'
export * from './server-permissions'
export * from './servers/server-backup'
export * from './servers/server-backups-queue'
export * from './servers/server-console'
export * from './servers/server-manage-core-runtime'
export * from './servers/use-server-image'
export { applyEarsMod, removeEarsMod } from './skin-rendering/use-ears-mod-features'
export * from './sticky-observer'
export * from './terminal'
export * from './use-loading-bar-token'
export * from './use-loading-state-core'
export * from './use-ready-state'
export * from './use-server-image'
export * from './use-server-project'
export * from './virtual-scroll'
@@ -0,0 +1,350 @@
import type { AbstractModrinthClient, Archon } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { onUnmounted, ref, watch } from 'vue'
import { injectModrinthClient } from '../providers'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type RuntimeUnsubscriber = () => void
type RuntimeReadyWaiter = {
resolve: () => void
reject: (error: Error) => void
timeout: ReturnType<typeof setTimeout>
}
type ServerContextRuntime = {
client: AbstractModrinthClient
serverId: string
leases: number
socketLeases: number
syncLeases: number
releaseTimer: ReturnType<typeof setTimeout> | null
socketReleaseTimer: ReturnType<typeof setTimeout> | null
syncReleaseTimer: ReturnType<typeof setTimeout> | null
connectPromise: Promise<void> | null
socketUnsubscribers: RuntimeUnsubscriber[]
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
isSocketAuthenticated: Ref<boolean>
isSocketAuthIncorrect: Ref<boolean>
hasAuthoritativeInstallProgress: Ref<boolean>
readyWaiters: Set<RuntimeReadyWaiter>
destroyed: boolean
}
export type ServerContextRuntimeLease = {
serverId: string
installProgressItems: Ref<Archon.Websocket.v0.InstallProgressItem[]>
isSocketAuthenticated: Ref<boolean>
isSocketAuthIncorrect: Ref<boolean>
hasAuthoritativeInstallProgress: Ref<boolean>
waitUntilReady: () => Promise<void>
release: () => void
}
type RetainServerContextRuntimeOptions = {
connect?: boolean
socket?: boolean
sync?: boolean
}
const runtimeReleaseDelay = 1000
const authoritativeReadinessTimeout = 30000
const runtimesByClient = new WeakMap<AbstractModrinthClient, Map<string, ServerContextRuntime>>()
function getClientRuntimes(client: AbstractModrinthClient) {
let runtimes = runtimesByClient.get(client)
if (!runtimes) {
runtimes = new Map()
runtimesByClient.set(client, runtimes)
}
return runtimes
}
function isRuntimeReady(runtime: ServerContextRuntime) {
return runtime.isSocketAuthenticated.value && runtime.hasAuthoritativeInstallProgress.value
}
function resolveReadyWaiters(runtime: ServerContextRuntime) {
if (!isRuntimeReady(runtime)) return
for (const waiter of runtime.readyWaiters) {
clearTimeout(waiter.timeout)
waiter.resolve()
}
runtime.readyWaiters.clear()
}
function createServerContextRuntime(
client: AbstractModrinthClient,
serverId: string,
): ServerContextRuntime {
const runtime: ServerContextRuntime = {
client,
serverId,
leases: 0,
socketLeases: 0,
syncLeases: 0,
releaseTimer: null,
socketReleaseTimer: null,
syncReleaseTimer: null,
connectPromise: null,
socketUnsubscribers: [],
installProgressItems: ref([]),
isSocketAuthenticated: ref(false),
isSocketAuthIncorrect: ref(false),
hasAuthoritativeInstallProgress: ref(false),
readyWaiters: new Set(),
destroyed: false,
}
return runtime
}
function attachRuntimeSocketListeners(runtime: ServerContextRuntime) {
if (runtime.socketUnsubscribers.length > 0) return
runtime.socketUnsubscribers = [
runtime.client.archon.sockets.on(runtime.serverId, 'auth-ok', () => {
runtime.isSocketAuthenticated.value = true
runtime.isSocketAuthIncorrect.value = false
runtime.hasAuthoritativeInstallProgress.value = false
}),
runtime.client.archon.sockets.on(runtime.serverId, 'auth-incorrect', () => {
runtime.isSocketAuthenticated.value = false
runtime.isSocketAuthIncorrect.value = true
runtime.hasAuthoritativeInstallProgress.value = false
}),
runtime.client.archon.sockets.on(runtime.serverId, 'install-progress', (event) => {
runtime.installProgressItems.value = event.items
runtime.hasAuthoritativeInstallProgress.value = true
resolveReadyWaiters(runtime)
}),
]
}
function disconnectRuntimeSocket(runtime: ServerContextRuntime) {
for (const unsubscribe of runtime.socketUnsubscribers) unsubscribe()
runtime.socketUnsubscribers = []
runtime.client.archon.sockets.disconnect(runtime.serverId)
runtime.connectPromise = null
runtime.isSocketAuthenticated.value = false
runtime.isSocketAuthIncorrect.value = false
runtime.hasAuthoritativeInstallProgress.value = false
for (const waiter of runtime.readyWaiters) {
clearTimeout(waiter.timeout)
waiter.reject(new Error(`Node socket for server ${runtime.serverId} was released`))
}
runtime.readyWaiters.clear()
}
function disconnectRuntimeSync(runtime: ServerContextRuntime) {
runtime.client.archon.sync.disconnect(runtime.serverId)
}
async function ensureRuntimeConnections(
runtime: ServerContextRuntime,
options: RetainServerContextRuntimeOptions = {},
) {
if (runtime.destroyed) {
throw new Error(`Server context runtime for ${runtime.serverId} has been released`)
}
const shouldConnectSocket = options.socket !== false
const shouldConnectSync = options.sync !== false
const socketStatus = runtime.client.archon.sockets.getStatus(runtime.serverId)
if (shouldConnectSocket && !socketStatus?.connected) {
attachRuntimeSocketListeners(runtime)
runtime.isSocketAuthenticated.value = false
runtime.hasAuthoritativeInstallProgress.value = false
}
if (shouldConnectSync) {
void runtime.client.archon.sync
.safeConnectServer(runtime.serverId, { intent: 'all' })
.catch((error) => {
console.warn(
`[server-context-runtime] Failed to connect sync stream for ${runtime.serverId}:`,
error,
)
})
}
if (shouldConnectSocket && !runtime.connectPromise) {
const connectPromise = runtime.client.archon.sockets
.safeConnect(runtime.serverId)
.then(() => {
runtime.isSocketAuthenticated.value = true
})
.finally(() => {
if (runtime.connectPromise === connectPromise) {
runtime.connectPromise = null
}
})
runtime.connectPromise = connectPromise
}
if (runtime.connectPromise) await runtime.connectPromise
}
async function waitUntilRuntimeReady(runtime: ServerContextRuntime) {
await ensureRuntimeConnections(runtime)
if (isRuntimeReady(runtime)) return
await new Promise<void>((resolve, reject) => {
const waiter: RuntimeReadyWaiter = {
resolve,
reject,
timeout: setTimeout(() => {
runtime.readyWaiters.delete(waiter)
reject(
new Error(
`Timed out waiting for authoritative install progress for server ${runtime.serverId}`,
),
)
}, authoritativeReadinessTimeout),
}
runtime.readyWaiters.add(waiter)
resolveReadyWaiters(runtime)
})
}
function destroyRuntime(runtime: ServerContextRuntime) {
if (runtime.destroyed || runtime.leases > 0) return
runtime.destroyed = true
if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer)
if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer)
disconnectRuntimeSocket(runtime)
disconnectRuntimeSync(runtime)
getClientRuntimes(runtime.client).delete(runtime.serverId)
}
export function retainServerContextRuntime(
client: AbstractModrinthClient,
serverId: string,
options: RetainServerContextRuntimeOptions = {},
): ServerContextRuntimeLease {
const runtimes = getClientRuntimes(client)
let runtime = runtimes.get(serverId)
if (!runtime) {
runtime = createServerContextRuntime(client, serverId)
runtimes.set(serverId, runtime)
}
if (runtime.releaseTimer) {
clearTimeout(runtime.releaseTimer)
runtime.releaseTimer = null
}
const retainSocket = options.socket !== false
const retainSync = options.sync !== false
if (retainSocket) {
if (runtime.socketReleaseTimer) {
clearTimeout(runtime.socketReleaseTimer)
runtime.socketReleaseTimer = null
}
attachRuntimeSocketListeners(runtime)
runtime.socketLeases += 1
}
if (retainSync) {
if (runtime.syncReleaseTimer) {
clearTimeout(runtime.syncReleaseTimer)
runtime.syncReleaseTimer = null
}
runtime.syncLeases += 1
}
runtime.leases += 1
if (options.connect !== false) {
void ensureRuntimeConnections(runtime, options).catch((error) => {
if (runtime && runtime.leases > 0) {
console.warn(
`[server-context-runtime] Failed to connect node socket for ${serverId}:`,
error,
)
}
})
}
let released = false
return {
serverId,
installProgressItems: runtime.installProgressItems,
isSocketAuthenticated: runtime.isSocketAuthenticated,
isSocketAuthIncorrect: runtime.isSocketAuthIncorrect,
hasAuthoritativeInstallProgress: runtime.hasAuthoritativeInstallProgress,
waitUntilReady: () => waitUntilRuntimeReady(runtime),
release: () => {
if (released) return
released = true
runtime.leases = Math.max(0, runtime.leases - 1)
if (retainSocket) {
runtime.socketLeases = Math.max(0, runtime.socketLeases - 1)
}
if (retainSync) {
runtime.syncLeases = Math.max(0, runtime.syncLeases - 1)
}
if (runtime.leases === 0) {
if (runtime.socketReleaseTimer) clearTimeout(runtime.socketReleaseTimer)
if (runtime.syncReleaseTimer) clearTimeout(runtime.syncReleaseTimer)
runtime.socketReleaseTimer = null
runtime.syncReleaseTimer = null
runtime.releaseTimer = setTimeout(() => {
runtime.releaseTimer = null
destroyRuntime(runtime)
}, runtimeReleaseDelay)
return
}
if (retainSocket && runtime.socketLeases === 0) {
runtime.socketReleaseTimer = setTimeout(() => {
runtime.socketReleaseTimer = null
if (runtime.socketLeases === 0) disconnectRuntimeSocket(runtime)
}, runtimeReleaseDelay)
}
if (retainSync && runtime.syncLeases === 0) {
runtime.syncReleaseTimer = setTimeout(() => {
runtime.syncReleaseTimer = null
if (runtime.syncLeases === 0) disconnectRuntimeSync(runtime)
}, runtimeReleaseDelay)
}
},
}
}
export function useServerContextRuntime(serverId: ReadableRef<string | null>) {
const client = injectModrinthClient()
let lease: ServerContextRuntimeLease | null = null
const stop = watch(
() => serverId.value,
(nextServerId) => {
lease?.release()
lease = null
if (typeof window !== 'undefined' && nextServerId) {
lease = retainServerContextRuntime(client, nextServerId)
}
},
{ immediate: true },
)
onUnmounted(() => {
stop()
lease?.release()
lease = null
})
}
export async function waitForServerContextRuntimeReady(
client: AbstractModrinthClient,
serverId: string,
) {
const lease = retainServerContextRuntime(client, serverId)
try {
await lease.waitUntilReady()
} finally {
lease.release()
}
}
@@ -0,0 +1,204 @@
import type { Archon } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
export type ServerInstallationKey =
| Exclude<Archon.Websocket.v0.InstallProgressKey, { type: 'file' }>
| { type: 'unknown' }
export type ServerInstallationState = {
id: string
key: ServerInstallationKey
status: 'pending' | 'installing' | 'complete' | 'failed'
progress: number | null
error: string | null
source: 'optimistic' | 'websocket' | 'server'
}
type OptimisticInstallation = {
id: string
key: ServerInstallationKey
startRevision: number
}
type UseServerInstallationTrackerOptions = {
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
}
function installationKeyId(key: ServerInstallationKey) {
switch (key.type) {
case 'platform':
return `platform:${key.platform}:${key.platform_version}:${key.game_version}`
case 'modrinth_modpack':
return `modrinth-modpack:${key.project_id}:${key.version_id}`
case 'local_modpack':
return `local-modpack:${key.filename}`
case 'unknown':
return 'unknown'
}
}
function itemStatus(
item: Archon.Websocket.v0.InstallProgressItem,
): ServerInstallationState['status'] {
if (item.error != null) return 'failed'
if (item.progress === 100) return 'complete'
return 'installing'
}
export function useServerInstallationTracker(options: UseServerInstallationTrackerOptions) {
const installProgressItems = ref<Archon.Websocket.v0.InstallProgressItem[]>([])
const optimisticInstallation = ref<OptimisticInstallation | null>(null)
const receivedProgressSnapshot = ref(false)
const snapshotRevision = ref(0)
const seenActiveIds = ref(new Set<string>())
const dismissedIds = ref(new Set<string>())
let unknownInstallationId = 0
const currentWorldItems = computed(() =>
installProgressItems.value.filter((item) => item.world_id === options.worldId.value),
)
const websocketInstallation = computed<ServerInstallationState | null>(() => {
const optimistic = optimisticInstallation.value
const candidates = currentWorldItems.value.filter(
(item) => item.key.type !== 'file' && !dismissedIds.value.has(installationKeyId(item.key)),
)
for (const item of candidates) {
if (item.key.type === 'file') continue
const id = installationKeyId(item.key)
const status = itemStatus(item)
if (status === 'complete') {
if (
optimistic &&
snapshotRevision.value <= optimistic.startRevision &&
!seenActiveIds.value.has(id)
) {
continue
}
if (
!optimistic &&
!seenActiveIds.value.has(id) &&
options.server.value?.status !== 'installing'
) {
continue
}
}
return {
id,
key: item.key,
status,
progress: item.progress,
error: item.error,
source: 'websocket',
}
}
return null
})
const installation = computed<ServerInstallationState | null>(() => {
if (websocketInstallation.value) return websocketInstallation.value
const optimistic = optimisticInstallation.value
if (optimistic && !dismissedIds.value.has(optimistic.id)) {
return {
id: optimistic.id,
key: optimistic.key,
status: 'pending',
progress: null,
error: null,
source: 'optimistic',
}
}
if (options.server.value?.status !== 'installing' || receivedProgressSnapshot.value) return null
return {
id: `unknown:${unknownInstallationId}`,
key: { type: 'unknown' },
status: 'installing',
progress: null,
error: null,
source: 'server',
}
})
const isBlocking = computed(
() => installation.value?.status === 'pending' || installation.value?.status === 'installing',
)
function handleProgress(items: Archon.Websocket.v0.InstallProgressItem[]) {
snapshotRevision.value += 1
receivedProgressSnapshot.value = true
installProgressItems.value = items
const nextSeenActiveIds = new Set(seenActiveIds.value)
const nextDismissedIds = new Set(dismissedIds.value)
let hasAuthoritativeInstallation = false
for (const item of items) {
if (item.world_id !== options.worldId.value || item.key.type === 'file') continue
hasAuthoritativeInstallation = true
const id = installationKeyId(item.key)
if (item.error == null && item.progress != null && item.progress < 100) {
nextSeenActiveIds.add(id)
nextDismissedIds.delete(id)
}
}
seenActiveIds.value = nextSeenActiveIds
dismissedIds.value = nextDismissedIds
if (hasAuthoritativeInstallation) {
optimisticInstallation.value = null
}
}
function begin(key: ServerInstallationKey) {
const id =
key.type === 'unknown' ? `unknown:${++unknownInstallationId}` : installationKeyId(key)
const nextDismissedIds = new Set(dismissedIds.value)
nextDismissedIds.delete(id)
dismissedIds.value = nextDismissedIds
optimisticInstallation.value = {
id,
key,
startRevision: snapshotRevision.value,
}
}
function cancelOptimistic() {
optimisticInstallation.value = null
}
function dismiss(id: string) {
dismissedIds.value = new Set([...dismissedIds.value, id])
if (optimisticInstallation.value?.id === id) {
optimisticInstallation.value = null
}
}
function reset() {
installProgressItems.value = []
optimisticInstallation.value = null
receivedProgressSnapshot.value = false
snapshotRevision.value = 0
seenActiveIds.value = new Set()
dismissedIds.value = new Set()
unknownInstallationId = 0
}
return {
begin,
cancelOptimistic,
dismiss,
handleProgress,
installation,
installProgressItems,
isBlocking,
reset,
}
}
@@ -5,6 +5,11 @@ import { onMounted, onUnmounted, watch } from 'vue'
import { injectModrinthClient } from '#ui/providers'
import {
retainServerContextRuntime,
type ServerContextRuntimeLease,
} from './server-context-runtime'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SyncUnsubscriber = () => void
@@ -20,6 +25,7 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
const queryClient = useQueryClient()
let activeServerId: string | null = null
let runtimeLease: ServerContextRuntimeLease | null = null
let unsubscribers: SyncUnsubscriber[] = []
let mounted = false
let actionLogInvalidateTimer: ReturnType<typeof setTimeout> | null = null
@@ -43,12 +49,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
unsubscribers = [
client.archon.sync.onAny(targetServerId, (event) => handleSyncEvent(targetServerId, event)),
]
void client.archon.sync.safeConnectServer(targetServerId, { intent: 'all' }).catch((error) => {
console.warn(
`[server-panel-sync] Failed to connect sync stream for ${targetServerId}:`,
error,
)
runtimeLease = retainServerContextRuntime(client, targetServerId, {
socket: false,
sync: true,
})
}
@@ -61,10 +64,9 @@ export function useServerPanelSync(options: UseServerPanelSyncOptions) {
for (const unsubscribe of unsubscribers) unsubscribe()
unsubscribers = []
if (activeServerId) {
client.archon.sync.disconnect(activeServerId)
activeServerId = null
}
runtimeLease?.release()
runtimeLease = null
activeServerId = null
}
function handleSyncEvent(serverId: string, event: Archon.Sync.v1.SyncEvent) {
@@ -1,7 +1,7 @@
import type { Archon } from '@modrinth/api-client'
import { injectModrinthClient } from '../providers/api-client'
import { injectNotificationManager } from '../providers/web-notifications'
import { injectModrinthClient } from '../../providers/api-client'
import { injectNotificationManager } from '../../providers/web-notifications'
export function useServerBackupDownload() {
const client = injectModrinthClient()
@@ -4,7 +4,7 @@ import { computed, reactive, type Ref } from 'vue'
import { type BusyReason, injectModrinthClient } from '#ui/providers'
import { defineMessage } from './i18n'
import { defineMessage } from '../i18n'
type ProgressKey = `${string}:${'create' | 'restore'}`
@@ -12,7 +12,7 @@ export function useServerBackupsQueue(serverId: Ref<string>, worldId: Ref<string
const client = injectModrinthClient()
const queryClient = useQueryClient()
const queryKey = computed(() => ['backups', 'queue', serverId.value] as const)
const queryKey = computed(() => ['backups', 'queue', serverId.value, worldId.value] as const)
const progressOverlay = reactive(new Map<ProgressKey, number>())
const lastSeenState = new Map<ProgressKey, Archon.Websocket.v0.BackupState>()
@@ -1,8 +1,8 @@
import { createGlobalState } from '@vueuse/core'
import { type Ref, ref, shallowRef, triggerRef } from 'vue'
import { detectLogLevel } from '../layouts/shared/console/composables/log-level'
import type { Log4jEvent, LogLevel, LogLine } from '../layouts/shared/console/types'
import { detectLogLevel } from '../../layouts/shared/console/composables/log-level'
import type { Log4jEvent, LogLevel, LogLine } from '../../layouts/shared/console/types'
// Flip to true during development to enable console perf logging.
// Uses a plain constant to avoid turbo env-var declarations.
@@ -5,19 +5,23 @@ import {
type UploadState,
} from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type { FileOperation } from '../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../providers'
import type { BusyReason, CancelUploadHandler, ServerStats } from '../providers/server-context'
import { defineMessage } from './i18n'
import type { FileOperation } from '../../layouts/shared/files-tab/types'
import { injectModrinthClient, provideModrinthServerContext } from '../../providers'
import type { BusyReason, CancelUploadHandler, ServerStats } from '../../providers/server-context'
import { defineMessage } from '../i18n'
import {
retainServerContextRuntime,
type ServerContextRuntimeLease,
} from '../server-context-runtime'
import { useServerInstallationTracker } from '../server-installation-tracker'
import { useModrinthServersConsole } from './server-console'
type ReadableRef<T> = Ref<T> | ComputedRef<T>
type SocketUnsubscriber = () => void
type ConnectSocketOptions = {
force?: boolean
extraSubscriptions?: (targetServerId: string) => SocketUnsubscriber[]
}
@@ -26,7 +30,6 @@ type UseServerManageCoreRuntimeOptions = {
worldId: ReadableRef<string | null>
server: ReadableRef<Archon.Servers.v0.Server | null | undefined>
serverFull?: ReadableRef<Archon.Servers.v1.ServerFull | null | undefined>
isSyncingContent: ReadableRef<boolean>
extraBusyReasons?: ComputedRef<BusyReason[]>
setDisconnectedOnAuthIncorrect?: boolean
syncUptimeFromState?: boolean
@@ -96,10 +99,24 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
const {
begin: beginInstallation,
cancelOptimistic: cancelOptimisticInstallation,
dismiss: dismissInstallation,
handleProgress: handleInstallProgress,
installation,
installProgressItems,
isBlocking: isInstallationBlocking,
reset: resetInstallation,
} = useServerInstallationTracker({
worldId: options.worldId,
server: options.server,
})
const connectedSocketServerId = ref<string | null>(null)
const socketUnsubscribers = ref<SocketUnsubscriber[]>([])
const cpuData = ref<number[]>([])
const ramData = ref<number[]>([])
let serverContextRuntimeLease: ServerContextRuntimeLease | null = null
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
let staleStatsTimeoutId: ReturnType<typeof setTimeout> | null = null
@@ -107,7 +124,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
const busyReasons = computed<BusyReason[]>(() => {
const reasons: BusyReason[] = []
if (options.server.value?.status === 'installing') {
if (isInstallationBlocking.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.installing',
@@ -115,14 +132,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
}),
})
}
if (options.isSyncingContent.value) {
reasons.push({
reason: defineMessage({
id: 'servers.busy.syncing-content',
defaultMessage: 'Content sync in progress',
}),
})
}
if (options.extraBusyReasons) reasons.push(...options.extraBusyReasons.value)
return reasons
})
@@ -265,20 +274,6 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
startUptimeTicker()
}
const handleAuthIncorrect = () => {
if (!shouldProcessEvent()) return
isWsAuthIncorrect.value = true
if (options.setDisconnectedOnAuthIncorrect) {
isConnected.value = false
}
}
const handleAuthOk = () => {
if (!shouldProcessEvent()) return
isWsAuthIncorrect.value = false
isConnected.value = true
}
const clearSocketListeners = () => {
for (const unsub of socketUnsubscribers.value) unsub()
socketUnsubscribers.value = []
@@ -288,10 +283,8 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
if (!targetServerId && !connectedSocketServerId.value) return
clearSocketListeners()
if (targetServerId) {
client.archon.sockets.disconnect(targetServerId)
}
serverContextRuntimeLease?.release()
serverContextRuntimeLease = null
stopUptimeTicker()
clearStaleStatsTimers()
@@ -301,6 +294,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
serverPowerState.value = 'stopped'
powerStateDetails.value = undefined
uptimeSeconds.value = 0
resetInstallation()
}
const connectSocket = async (
@@ -317,14 +311,11 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
disconnectSocket(connectedSocketServerId.value ?? undefined)
try {
const safeConnectOptions = connectOptions.force ? { force: true } : undefined
await client.archon.sockets.safeConnect(targetServerId, safeConnectOptions)
const runtimeLease = retainServerContextRuntime(client, targetServerId, {
connect: false,
})
serverContextRuntimeLease = runtimeLease
connectedSocketServerId.value = targetServerId
isConnected.value = true
isWsAuthIncorrect.value = false
modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
const baseSubscriptions: SocketUnsubscriber[] = [
client.archon.sockets.on(targetServerId, 'log', handleLog),
@@ -333,15 +324,45 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
client.archon.sockets.on(targetServerId, 'state', handleState),
client.archon.sockets.on(targetServerId, 'power-state', handlePowerState),
client.archon.sockets.on(targetServerId, 'uptime', handleUptime),
client.archon.sockets.on(targetServerId, 'auth-incorrect', handleAuthIncorrect),
client.archon.sockets.on(targetServerId, 'auth-ok', handleAuthOk),
watch(
runtimeLease.installProgressItems,
(items) => {
if (shouldProcessEvent()) handleInstallProgress(items)
},
{ immediate: true },
),
watch(
runtimeLease.isSocketAuthenticated,
(authenticated) => {
if (!shouldProcessEvent()) return
if (authenticated || options.setDisconnectedOnAuthIncorrect) {
isConnected.value = authenticated
}
},
{ immediate: true },
),
watch(
runtimeLease.isSocketAuthIncorrect,
(authIncorrect) => {
if (shouldProcessEvent()) isWsAuthIncorrect.value = authIncorrect
},
{ immediate: true },
),
]
const extraSubscriptions = connectOptions.extraSubscriptions?.(targetServerId) ?? []
socketUnsubscribers.value = [...baseSubscriptions, ...extraSubscriptions]
modrinthServersConsole.clear()
modrinthServersConsole.beginInitialLogHydration()
await runtimeLease.waitUntilReady()
isConnected.value = true
isWsAuthIncorrect.value = false
return true
} catch (error) {
console.error('[hosting/manage] Failed to connect server socket:', error)
isConnected.value = false
disconnectSocket(targetServerId)
return false
}
}
@@ -370,7 +391,7 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
}
try {
await client.kyros.files_v0.modifyOperation(opId, action)
await client.kyros.files_v1.modifyOperation(opId, action)
} catch (error) {
if (action === 'dismiss') return
console.error(`Failed to ${action} operation:`, error)
@@ -402,7 +423,11 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
isServerRunning,
stats,
uptimeSeconds,
isSyncingContent: options.isSyncingContent as Ref<boolean>,
installProgressItems,
installation,
beginInstallation,
cancelOptimisticInstallation,
dismissInstallation,
busyReasons,
fsAuth,
fsOps,
@@ -423,20 +448,25 @@ export function useServerManageCoreRuntime(options: UseServerManageCoreRuntimeOp
return {
activeOperations,
beginInstallation,
busyReasons,
cancelUpload,
cancelOptimisticInstallation,
cleanupCoreRuntime,
connectSocket,
connectedSocketServerId,
cpuData,
disconnectSocket,
dismissInstallation,
dismissOperation,
fsAuth,
fsOps,
fsQueuedOps,
installation,
isConnected,
isServerRunning,
isWsAuthIncorrect,
installProgressItems,
powerStateDetails,
ramData,
refreshFsAuth,
@@ -5,11 +5,14 @@ import { computed, type ComputedRef, ref } from 'vue'
import { injectModrinthClient } from '#ui/providers'
type UpstreamRef = ComputedRef<Archon.Servers.v0.Server['upstream'] | null | undefined>
type ServerIdSource = string | { readonly value: string }
type WorldIdSource = string | null | undefined | { readonly value: string | null | undefined }
type UseServerImageOptions = {
enabled?: ComputedRef<boolean> | boolean
size?: number
includeProjectFallback?: boolean
worldId?: WorldIdSource
}
export async function processImageBlob(blob: Blob, size: number): Promise<string> {
@@ -39,7 +42,7 @@ function isNotFound(error: unknown): boolean {
}
export function useServerImage(
serverId: string,
serverId: ServerIdSource,
upstream: UpstreamRef,
options: UseServerImageOptions = {},
) {
@@ -47,35 +50,50 @@ export function useServerImage(
const localImage = ref<string | null | undefined>(undefined)
const iconSize = options.size ?? 512
const includeProjectFallback = options.includeProjectFallback ?? false
const resolvedServerId = computed(() => resolveServerId(serverId))
const resolvedWorldId = computed(() => resolveWorldId(options.worldId))
const queryKey = computed(
() => ['servers', 'detail', serverId, 'icon', upstream.value?.project_id ?? null] as const,
() =>
[
'servers',
'detail',
resolvedServerId.value,
'icon',
resolvedWorldId.value ?? 'active',
upstream.value?.project_id ?? null,
] as const,
)
const isEnabled = computed(() => {
const explicitEnabled =
typeof options.enabled === 'boolean' ? options.enabled : options.enabled?.value
return !!serverId && (explicitEnabled ?? true)
return !!resolvedServerId.value && (explicitEnabled ?? true)
})
const { data: remoteImage, refetch } = useQuery({
queryKey,
queryFn: async (): Promise<string | null> => {
if (!serverId) return null
const id = resolvedServerId.value
if (!id) return null
try {
const fsAuth = await client.archon.servers_v0.getFilesystemAuth(serverId)
const targetWorldId = resolvedWorldId.value ?? (await getActiveWorldId(id))
if (!targetWorldId) return null
try {
const blob = await client.kyros.files_v0.downloadFileWithAuth(fsAuth, '/server-icon.png')
const blob = await client.kyros.files_v1.downloadRawFileContents(
targetWorldId,
'/server-icon.png',
)
return await processImageBlob(blob, iconSize)
} catch (error) {
if (!isNotFound(error)) throw error
}
try {
const blob = await client.kyros.files_v0.downloadFileWithAuth(
fsAuth,
const blob = await client.kyros.files_v1.downloadRawFileContents(
targetWorldId,
'/server-icon-original.png',
)
return await processImageBlob(blob, iconSize)
@@ -84,7 +102,6 @@ export function useServerImage(
}
} catch (error) {
console.debug('Server image fetch failed:', error)
return null
}
if (!includeProjectFallback || !upstream.value?.project_id) return null
@@ -123,6 +140,12 @@ export function useServerImage(
localImage.value = undefined
}
async function getActiveWorldId(id: string): Promise<string | null> {
const server = await client.archon.servers_v1.get(id)
const activeWorld = server.worlds.find((world) => world.is_active)
return activeWorld?.id ?? server.worlds[0]?.id ?? null
}
return {
image,
queryKey,
@@ -132,3 +155,13 @@ export function useServerImage(
resetLocalOverride,
}
}
function resolveServerId(serverId: ServerIdSource): string {
return typeof serverId === 'string' ? serverId : serverId.value
}
function resolveWorldId(worldId: WorldIdSource): string | null {
if (worldId == null) return null
if (typeof worldId === 'string') return worldId
return worldId.value ?? null
}
@@ -2,6 +2,7 @@
<FloatingActionBar
:shown="shown"
:aria-label="formatMessage(messages.ariaLabel)"
toolbar-max-width="min(1152px, calc(100vw - 3rem))"
hide-when-modal-open
>
<div class="flex min-w-0 items-center gap-0.5">
@@ -53,9 +53,6 @@ export interface BrowseInstallPlan<TProject extends BrowseInstallProject = Brows
project: TProject
projectId: string
versionId: string
versionName?: string
versionNumber?: string
fileName?: string
contentType: BrowseInstallContentType
preferences: BrowseInstallPreferences
source: BrowseInstallPlanSource
@@ -567,15 +564,10 @@ export async function resolveInstallPlan<TProject extends BrowseInstallProject>(
const version = getLatestMatchingInstallVersion(versions, candidate.preferences)
if (version) {
const fileName =
version.files.find((file) => file.primary)?.filename ?? version.files[0]?.filename
return {
project: options.project,
projectId,
versionId: version.id,
versionName: version.name,
versionNumber: version.version_number,
fileName,
contentType: options.contentType,
preferences: candidate.preferences,
source: candidate.source,
@@ -11,7 +11,7 @@ import PageHeader from '#ui/components/base/page-header/index.vue'
import PageHeaderMetadata from '#ui/components/base/page-header/metadata/index.vue'
import PageHeaderMetadataItem from '#ui/components/base/page-header/metadata/page-header-metadata-item.vue'
import LoaderIcon from '#ui/components/servers/icons/LoaderIcon.vue'
import { useServerImage } from '#ui/composables/use-server-image'
import { useServerImage } from '#ui/composables/servers/use-server-image'
import { formatLoaderLabel } from '#ui/utils/loaders'
import SelectedProjectsLeaveModal from './components/SelectedProjectsLeaveModal.vue'
@@ -21,6 +21,7 @@ export interface BrowseSelectedProject {
export interface BrowseInstallContext {
name: string
loader: string
loaderVersion?: string | null
gameVersion: string
serverId?: string | null
upstream?: { project_id?: string | null } | null
@@ -18,6 +18,7 @@ import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
import ProgressSpinner from '#ui/components/base/ProgressSpinner.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import Toggle from '#ui/components/base/Toggle.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
@@ -50,6 +51,7 @@ interface Props {
source?: ContentSource
enabled?: boolean
installing?: boolean
installProgress?: number | null
hasUpdate?: boolean
isClientOnly?: boolean
clientWarning?: ClientWarningType | null
@@ -73,6 +75,7 @@ const props = withDefaults(defineProps<Props>(), {
source: undefined,
enabled: undefined,
installing: false,
installProgress: undefined,
hasUpdate: false,
isClientOnly: false,
clientWarning: null,
@@ -124,6 +127,11 @@ const clientWarningMessage = computed(() => {
const { shift: shiftHeld } = useMagicKeys()
const deleteHovered = ref(false)
const installTooltip = computed(() => {
if (!props.installing) return undefined
if (props.installProgress == null) return formatMessage(commonMessages.installingLabel)
return `${formatMessage(commonMessages.installingLabel)} (${Math.round(props.installProgress)}%)`
})
</script>
<template>
@@ -147,6 +155,7 @@ const deleteHovered = ref(false)
v-if="showCheckbox"
:model-value="selected ?? false"
:aria-label="formatMessage(messages.selectProject, { project: project.title })"
:disabled="isDisabled"
class="shrink-0"
@update:model-value="(value, event) => emit('select', value, event)"
/>
@@ -155,10 +164,7 @@ const deleteHovered = ref(false)
class="flex min-w-0 items-center gap-3 transition-[filter,opacity] duration-200"
:class="enabled === false && !disabled ? 'grayscale opacity-50' : ''"
>
<div
v-tooltip="installing ? formatMessage(commonMessages.installingLabel) : undefined"
class="relative flex shrink-0 items-center"
>
<div v-tooltip="installTooltip" class="relative flex shrink-0 items-center">
<Avatar
:src="project.icon_url"
:alt="project.title"
@@ -170,7 +176,13 @@ const deleteHovered = ref(false)
v-if="installing"
class="absolute inset-0 flex items-center justify-center rounded-2xl bg-black/20"
>
<SpinnerIcon class="size-5 animate-spin text-white" />
<ProgressSpinner
v-if="installProgress != null && installProgress > 0"
:progress="installProgress"
:max="100"
class="size-5 text-white"
/>
<SpinnerIcon v-else class="size-5 animate-spin text-white" />
</div>
</div>
<div class="flex min-w-0 flex-col gap-0.5">
@@ -104,20 +104,24 @@ defineExpose({
})
// Selection logic
const selectableItems = computed(() => props.items.filter((item) => !item.disabled))
const allSelected = computed(() => {
if (props.items.length === 0) return false
return props.items.every((item) => selectedIds.value.includes(item.id))
if (selectableItems.value.length === 0) return false
return selectableItems.value.every((item) => selectedIds.value.includes(item.id))
})
const someSelected = computed(() => {
return props.items.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
return (
selectableItems.value.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
)
})
function toggleSelectAll() {
if (allSelected.value || someSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = props.items.map((item) => item.id)
selectedIds.value = selectableItems.value.map((item) => item.id)
}
}
@@ -132,7 +136,10 @@ function toggleItemSelection(
if (selected && event?.shiftKey && lastSelectedIndex.value !== null && index !== undefined) {
const start = Math.min(lastSelectedIndex.value, index)
const end = Math.max(lastSelectedIndex.value, index)
const rangeIds = props.items.slice(start, end + 1).map((item) => item.id)
const rangeIds = props.items
.slice(start, end + 1)
.filter((item) => !item.disabled)
.map((item) => item.id)
const merged = new Set([...selectedIds.value, ...rangeIds])
selectedIds.value = [...merged]
} else if (selected) {
@@ -192,6 +199,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:model-value="allSelected"
:indeterminate="someSelected"
:aria-label="formatMessage(commonMessages.selectAllLabel)"
:disabled="selectableItems.length === 0"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
@@ -267,6 +275,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:source="item.source"
:enabled="item.enabled"
:installing="item.installing"
:install-progress="item.installProgress"
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
:client-warning="item.clientWarning"
@@ -331,6 +340,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:source="item.source"
:enabled="item.enabled"
:installing="item.installing"
:install-progress="item.installProgress"
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
:client-warning="item.clientWarning"
@@ -5,9 +5,11 @@ import {
DownloadIcon,
HeartIcon,
MoreVerticalIcon,
Settings2Icon,
SpinnerIcon,
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import { Tooltip } from 'floating-vue'
import { computed, getCurrentInstance, onMounted, onUnmounted, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
@@ -34,9 +36,17 @@ import type {
const { formatMessage } = useVIntl()
const messages = defineMessages({
installationSettingsTooltip: {
id: 'content.modpack-card.installation-settings',
defaultMessage: 'Installation settings',
contentHintTitle: {
id: 'content.modpack-card.content-hint-title',
defaultMessage: 'Modpack content moved',
},
contentHintDescription: {
id: 'content.modpack-card.content-hint-description',
defaultMessage: "Your modpack's content can now be found here!",
},
dismissHint: {
id: 'content.modpack-card.dismiss-hint',
defaultMessage: "Don't show again",
},
})
@@ -51,6 +61,7 @@ interface Props {
overflowOptions?: OverflowMenuOption[]
hasUpdate?: boolean
disabledText?: string
showContentHint?: boolean
}
withDefaults(defineProps<Props>(), {
@@ -63,12 +74,14 @@ withDefaults(defineProps<Props>(), {
overflowOptions: undefined,
hasUpdate: false,
disabledText: undefined,
showContentHint: false,
})
const emit = defineEmits<{
update: []
content: []
settings: []
'dismiss-content-hint': []
}>()
const instance = getCurrentInstance()
@@ -124,7 +137,7 @@ onUnmounted(() => {
<template>
<div
ref="containerRef"
class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md border border-solid border-surface-4"
class="@container flex flex-col gap-4 rounded-[20px] border border-solid border-surface-4 bg-bg-raised p-6 shadow-md"
:class="{ 'opacity-50': disabled }"
>
<div class="flex flex-wrap items-start justify-between gap-4">
@@ -141,19 +154,10 @@ onUnmounted(() => {
>
{{ project.title }}
</AutoLink>
<span
v-if="project.filename && (owner || version)"
class="truncate text-secondary mb-2"
>
<span v-if="project.filename" class="truncate text-secondary mb-2">
{{ project.filename }}
</span>
</div>
<div
v-if="project.filename && !(owner || version)"
class="flex min-h-8 min-w-0 items-center text-secondary"
>
<span class="truncate">{{ project.filename }}</span>
</div>
<div
v-if="owner || version"
class="flex flex-nowrap items-center gap-2 overflow-hidden text-secondary"
@@ -217,19 +221,61 @@ onUnmounted(() => {
</button>
</ButtonStyled>
<ButtonStyled v-if="hasContentListener">
<button class="!shadow-none" @click="emit('content')">
<BoxesIcon />
{{ formatMessage(commonMessages.contentLabel) }}
</button>
</ButtonStyled>
<Tooltip
v-if="hasContentListener"
theme="dismissable-prompt"
class="inline-flex"
:triggers="[]"
:shown="showContentHint && isExpanded"
:auto-hide="false"
placement="bottom-end"
>
<ButtonStyled>
<button
class="!shadow-none"
@click="
() => {
emit('content')
emit('dismiss-content-hint')
}
"
>
<BoxesIcon />
{{ formatMessage(commonMessages.contentLabel) }}
</button>
</ButtonStyled>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(messages.contentHintTitle) }}
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(messages.dismissHint)"
@click="emit('dismiss-content-hint')"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.contentHintDescription) }}
</p>
</div>
</template>
</Tooltip>
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
<button
v-tooltip="formatMessage(messages.installationSettingsTooltip)"
@click="emit('settings')"
@click="
() => {
emit('settings')
emit('dismiss-content-hint')
}
"
>
<Settings2Icon />
<WrenchIcon />
</button>
</ButtonStyled>
</div>
@@ -245,19 +291,53 @@ onUnmounted(() => {
</button>
</ButtonStyled>
</div>
<ButtonStyled v-if="collapsedOptions.length" circular type="outlined">
<TeleportOverflowMenu :options="collapsedOptions" class="flex @[700px]:hidden">
<MoreVerticalIcon class="size-5" />
<template #content>
<BoxesIcon class="size-5" />
{{ formatMessage(commonMessages.contentLabel) }}
</template>
<template #settings>
<Settings2Icon class="size-5" />
{{ formatMessage(messages.installationSettingsTooltip) }}
</template>
</TeleportOverflowMenu>
</ButtonStyled>
<Tooltip
v-if="collapsedOptions.length"
theme="dismissable-prompt"
class="inline-flex"
:triggers="[]"
:shown="showContentHint && !isExpanded"
:auto-hide="false"
placement="bottom-end"
>
<ButtonStyled circular type="outlined"
><TeleportOverflowMenu
:options="collapsedOptions"
class="flex @[700px]:hidden"
@open="emit('dismiss-content-hint')"
>
<MoreVerticalIcon class="size-5" />
<template #content>
<BoxesIcon class="size-5" />
{{ formatMessage(commonMessages.contentLabel) }}
</template>
<template #settings>
<WrenchIcon class="size-5" />
{{ formatMessage(commonMessages.settingsLabel) }}
</template>
</TeleportOverflowMenu></ButtonStyled
>
<template #popper>
<div class="grid grid-cols-[min-content] gap-1">
<div class="flex min-w-48 items-center justify-between gap-8">
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
{{ formatMessage(messages.contentHintTitle) }}
</h3>
<ButtonStyled size="small" circular>
<button
v-tooltip="formatMessage(messages.dismissHint)"
@click="emit('dismiss-content-hint')"
>
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
{{ formatMessage(messages.contentHintDescription) }}
</p>
</div>
</template>
</Tooltip>
<ButtonStyled
v-if="overflowOptions?.length"
@@ -15,6 +15,7 @@
:backup-name="
visibleBackupTip ? `Before bulk update (${visibleBackupTip})` : 'Before bulk update'
"
:target-type="props.targetType"
:shift-click-hint-override="formatMessage(messages.shiftClickHint)"
@update:buttons-disabled="buttonsDisabled = $event"
/>
@@ -88,6 +89,7 @@ const props = defineProps<{
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
targetType?: 'server' | 'instance'
}>()
const emit = defineEmits<{
@@ -17,6 +17,7 @@
<InlineBackupCreator
ref="backupCreator"
:backup-name="props.backupTip ? `Before deletion (${props.backupTip})` : 'Before deletion'"
:target-type="props.targetType"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
@@ -71,7 +72,7 @@ const messages = defineMessages({
admonitionBody: {
id: 'content.confirm-deletion.admonition-body',
defaultMessage:
'Deleting a mod can permanently affect your world and may cause missing content or unexpected issues when it loads again.',
'Deleting a mod can permanently affect your instance and may cause missing content or unexpected issues when it starts again.',
},
deleteButton: {
id: 'content.confirm-deletion.delete-button',
@@ -88,6 +89,7 @@ const props = withDefaults(
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
targetType?: 'server' | 'instance'
}>(),
{
warning: null,
@@ -95,6 +97,7 @@ const props = withDefaults(
backupTip: undefined,
actionDisabled: false,
actionDisabledTooltip: undefined,
targetType: undefined,
},
)
@@ -25,6 +25,7 @@
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupName"
:target-type="props.targetType"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
@@ -72,6 +73,7 @@ const props = defineProps<{
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
targetType?: 'server' | 'instance'
}>()
const { formatMessage } = useVIntl()
@@ -13,6 +13,7 @@
<InlineBackupCreator
ref="backupCreator"
:backup-name="backupTip ? `Before reinstall (${backupTip})` : 'Before reinstall'"
:target-type="targetType"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
@@ -75,6 +76,7 @@ const messages = defineMessages({
defineProps<{
server?: boolean
backupTip?: string
targetType?: 'server' | 'instance'
}>()
const emit = defineEmits<{
@@ -13,6 +13,7 @@
<InlineBackupCreator
ref="backupCreator"
:backup-name="props.backupTip ? `Before unlink (${props.backupTip})` : 'Before unlink'"
:target-type="props.targetType"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
@@ -60,6 +61,7 @@ const props = defineProps<{
backupTip?: string
actionDisabled?: boolean
actionDisabledTooltip?: string
targetType?: 'server' | 'instance'
}>()
const { formatMessage } = useVIntl()
@@ -1,11 +1,7 @@
<template>
<div class="flex flex-col gap-3">
<span class="text-primary">
{{
formatMessage(messages.warningBody, {
type: formatMessage(backup.isServer ? messages.worldLabel : messages.instanceLabel),
})
}}
{{ formatMessage(messages.warningBody, { type: backupTargetType }) }}
</span>
<div v-if="backup.available" class="flex items-center gap-2">
@@ -47,7 +43,7 @@
<TriangleAlertIcon
v-if="backup.isServer"
v-tooltip="formatMessage(messages.backupTakesAWhile)"
v-tooltip="formatMessage(messages.backupTakesAWhile, { type: backupTargetType })"
class="size-5 shrink-0 text-brand-orange hover:brightness-110"
/>
</div>
@@ -71,6 +67,7 @@ import { useInlineBackup } from '../../composables/use-inline-backup'
const props = defineProps<{
backupName: string
targetType?: 'server' | 'instance'
hideShiftClickHint?: boolean
shiftClickHintOverride?: string
}>()
@@ -87,6 +84,7 @@ const canManageBackups = computed(
const permissionDeniedMessage = computed(() => formatMessage(commonMessages.noPermissionAction))
const backup = useInlineBackup(() => props.backupName)
const backupTargetType = computed(() => props.targetType ?? 'instance')
function startBackup() {
if (
@@ -115,15 +113,7 @@ const messages = defineMessages({
warningBody: {
id: 'content.inline-backup.warning-body',
defaultMessage:
'We recommend creating a backup before proceeding so you can restore your {type} if anything breaks.',
},
worldLabel: {
id: 'content.inline-backup.world-label',
defaultMessage: 'world',
},
instanceLabel: {
id: 'content.inline-backup.instance-label',
defaultMessage: 'instance',
'We recommend creating a backup before proceeding so you can restore your {type, select, server {server} other {instance}} if anything breaks.',
},
createBackup: {
id: 'content.inline-backup.create-backup',
@@ -144,7 +134,7 @@ const messages = defineMessages({
backupTakesAWhile: {
id: 'content.inline-backup.backup-takes-a-while',
defaultMessage:
'Creating a backup may take several minutes depending on the size of your server.',
'Creating a backup may take several minutes depending on the size of your {type, select, server {server} other {instance}}.',
},
backupInProgress: {
id: 'content.inline-backup.backup-in-progress',
@@ -205,11 +205,10 @@
<span>{{
warning ??
formatMessage(
incompatibilityWarningMode
? messages.incompatibilityWarning
: isApp
? messages.updateWarningApp
: messages.updateWarningWeb,
incompatibilityWarningMode ? messages.incompatibilityWarning : messages.updateWarning,
{
type: updateWarningTargetType,
},
)
}}</span>
</div>
@@ -352,14 +351,10 @@ const messages = defineMessages({
id: 'instances.updater-modal.select-version',
defaultMessage: 'Select a version to view its changelog',
},
updateWarningApp: {
id: 'instances.updater-modal.warning-app',
updateWarning: {
id: 'instances.updater-modal.warning',
defaultMessage:
'Updating can break your instance. Review version changelogs and back up first.',
},
updateWarningWeb: {
id: 'instances.updater-modal.warning-web',
defaultMessage: 'Updating can break your world. Review version changelogs and back up first.',
'Updating can break your {type, select, server {server} other {instance}}. Review version changelogs and back up first.',
},
incompatibilityWarning: {
id: 'instances.updater-modal.incompatibility-warning',
@@ -424,6 +419,7 @@ const props = withDefaults(
currentLoader: string
currentVersionId: string
isApp: boolean
targetType?: 'server' | 'instance'
/** The project type (e.g. mod, shader, resourcepack, datapack, modpack). */
projectType?: string
projectIconUrl?: string
@@ -440,6 +436,7 @@ const props = withDefaults(
actionDisabledTooltip?: string
}>(),
{
targetType: undefined,
projectType: undefined,
projectIconUrl: undefined,
projectName: undefined,
@@ -469,6 +466,7 @@ const defaultHeader = computed(() => {
: messages.updateVersionHeader,
)
})
const updateWarningTargetType = computed(() => props.targetType ?? 'instance')
const emit = defineEmits<{
update: [version: Labrinth.Versions.v2.Version, event: MouseEvent]
@@ -1,7 +1,7 @@
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import { useServerBackupsQueue } from '#ui/composables/server-backups-queue'
import { useServerBackupsQueue } from '#ui/composables/servers/server-backups-queue.ts'
import {
injectAppBackup,
injectModrinthClient,
@@ -286,6 +286,7 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
toggleDisabled: ctx.isBusy.value,
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
installing: item.installing === true,
installProgress: item.installProgress,
hasUpdate: base.hasUpdate ?? item.has_update,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
@@ -1124,6 +1125,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
:target-type="ctx.deletionContext ?? 'instance'"
@delete="confirmDelete"
/>
<ConfirmDisableModal
@@ -1153,6 +1155,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:server="ctx.deletionContext === 'server'"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
:target-type="ctx.deletionContext ?? 'instance'"
@update="confirmBulkUpdate"
/>
<ConfirmUnlinkModal
@@ -1162,6 +1165,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:backup-tip="ctx.modpack.value?.project.title"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
:target-type="ctx.deletionContext ?? 'instance'"
@unlink="ctx.unlinkModpack!()"
/>
@@ -56,6 +56,7 @@ export interface ContentCardTableItem {
toggleDisabled?: boolean
toggleDisabledTooltip?: string | null
installing?: boolean
installProgress?: number | null
hasUpdate?: boolean
isClientOnly?: boolean
clientWarning?: ClientWarningType | null
@@ -91,6 +92,7 @@ export interface ContentItem extends Omit<
pack_client_retained?: boolean
pack_client_depends?: boolean
installing?: boolean
installProgress?: number | null
source_kind?: ContentSourceKind | null
external?: boolean
external_url?: string

Some files were not shown because too many files have changed in this diff Show More