mirror of
https://github.com/modrinth/code.git
synced 2026-08-24 16:44:51 +00:00
refactor: use DI for instance page + subpages (#6987)
* refactor: use DI for instance page + subpages * fix: qa * refactor: layout.vue bring up a layer * refactor: rename folders * import order * refactor: move settings into instance page * fix: lint --------- Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
@@ -120,6 +120,7 @@ import {
|
||||
} from '@/helpers/utils.js'
|
||||
import { start_join_server, start_join_singleplayer_world } from '@/helpers/worlds.ts'
|
||||
import i18n from '@/i18n.config'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
import {
|
||||
appUpdateState,
|
||||
downloadAvailableAppUpdate,
|
||||
@@ -242,7 +243,7 @@ const { data: authenticatedModrinthUser } = useQuery({
|
||||
retry: false,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: computed(() => ['shared-instance-eligibility', credentials.value?.user?.id]),
|
||||
queryKey: computed(() => instanceKeys.sharedEligibility(credentials.value?.user?.id)),
|
||||
queryFn: can_current_user_use_shared_instances,
|
||||
enabled: () => !!credentials.value?.session && !!credentials.value?.user?.id,
|
||||
retry: false,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
|
||||
const ITEM_SIZE = 52
|
||||
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
|
||||
@@ -123,7 +124,7 @@ const getInstances = async () => {
|
||||
const instances = await list().catch(handleError)
|
||||
|
||||
for (const instance of instances) {
|
||||
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
|
||||
queryClient.setQueryData(instanceKeys.detail(instance.id), instance)
|
||||
}
|
||||
|
||||
allInstances.value = instances.sort((a, b) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -67,7 +68,7 @@ async function addServer(instance) {
|
||||
try {
|
||||
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
|
||||
instance.added = true
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
|
||||
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.id) })
|
||||
|
||||
trackEvent('AddServerToInstance', {
|
||||
server_name: serverName.value,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { kill, list as listInstances } from '@/helpers/instance'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { add_server_to_instance, getServerAddress } from '@/helpers/worlds'
|
||||
import { instanceKeys } from '@/pages/instance/query-options'
|
||||
|
||||
interface BrowseServerInstance {
|
||||
id: string
|
||||
@@ -38,7 +39,7 @@ interface ContextMenuOptionClick {
|
||||
}
|
||||
|
||||
export interface UseAppServerBrowseOptions {
|
||||
instance: Ref<BrowseServerInstance | null>
|
||||
instance: Readonly<Ref<BrowseServerInstance | null>>
|
||||
isFromWorlds: ComputedRef<boolean>
|
||||
allInstalledIds: ComputedRef<Set<string>>
|
||||
newlyInstalled: Ref<string[]>
|
||||
@@ -131,7 +132,7 @@ export function useAppServerBrowse(options: UseAppServerBrowseOptions) {
|
||||
project.minecraft_java_server?.content?.kind,
|
||||
)
|
||||
options.newlyInstalled.value.push(project.project_id)
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instanceId] })
|
||||
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instanceId) })
|
||||
} catch (error) {
|
||||
options.handleError(error)
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ export async function loadInstanceContentData(
|
||||
}
|
||||
|
||||
function handleLoadError(error: unknown, onError?: (error: Error) => unknown) {
|
||||
onError?.(error as Error)
|
||||
if (!onError) throw error
|
||||
onError(error as Error)
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
@@ -41,15 +41,9 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
|
||||
import {
|
||||
get_project,
|
||||
get_project_v3,
|
||||
get_search_results_v3,
|
||||
get_version_many,
|
||||
} from '@/helpers/cache.js'
|
||||
import { get_project, get_search_results_v3, get_version_many } from '@/helpers/cache.js'
|
||||
import { instance_listener } from '@/helpers/events.js'
|
||||
import {
|
||||
get as getInstance,
|
||||
get_installed_project_ids as getInstalledProjectIds,
|
||||
list as listInstances,
|
||||
} from '@/helpers/instance'
|
||||
@@ -57,6 +51,11 @@ import { get_loader_versions as getLoaderManifest } from '@/helpers/metadata'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
|
||||
import { get_categories, get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { get_instance_worlds } from '@/helpers/worlds'
|
||||
import {
|
||||
instanceDetailQueryOptions,
|
||||
instanceKeys,
|
||||
instanceLinkedProjectQueryOptions,
|
||||
} from '@/pages/instance/query-options'
|
||||
import {
|
||||
type BreadcrumbDefinition,
|
||||
useBreadcrumb,
|
||||
@@ -154,30 +153,29 @@ const {
|
||||
markServerProjectInstalled,
|
||||
} = serverInstallContent
|
||||
|
||||
type Instance = {
|
||||
game_version: string
|
||||
loader: string
|
||||
path: string
|
||||
install_stage: string
|
||||
icon_path?: string
|
||||
name: string
|
||||
link?: {
|
||||
type: string
|
||||
project_id: string
|
||||
version_id: string
|
||||
}
|
||||
}
|
||||
|
||||
const initialInstanceId = String(route.query.i ?? '')
|
||||
const instance: Ref<Instance | null> = ref(
|
||||
queryClient.getQueryData<Instance>(['instances', 'summary', initialInstanceId]) ?? null,
|
||||
const initialInstanceId = computed(() => String(route.query.i ?? ''))
|
||||
const instanceQuery = useQuery(
|
||||
computed(() => ({
|
||||
...instanceDetailQueryOptions(initialInstanceId.value),
|
||||
enabled: !!initialInstanceId.value,
|
||||
})),
|
||||
)
|
||||
const instance = computed(() => instanceQuery.data.value ?? null)
|
||||
const linkedInstanceProjectId = computed(() => instance.value?.link?.project_id ?? '')
|
||||
const linkedInstanceProjectQuery = useQuery(
|
||||
computed(() => ({
|
||||
...instanceLinkedProjectQueryOptions(linkedInstanceProjectId.value),
|
||||
enabled: !!linkedInstanceProjectId.value,
|
||||
})),
|
||||
)
|
||||
const installedProjectIds: Ref<string[] | null> = ref(null)
|
||||
const instanceHideInstalled = ref(route.query.ai === 'true')
|
||||
const newlyInstalled = ref<string[]>([])
|
||||
const hiddenInstanceProjectIds = ref<Set<string>>(new Set())
|
||||
const hiddenInstanceProjectIdsInitialized = ref(false)
|
||||
const isServerInstance = ref(false)
|
||||
const isServerInstance = computed(
|
||||
() => linkedInstanceProjectQuery.data.value?.minecraft_server != null,
|
||||
)
|
||||
|
||||
const instanceBreadcrumb = route.query.i
|
||||
? useBreadcrumb({
|
||||
@@ -291,7 +289,13 @@ await initInstanceContext()
|
||||
|
||||
async function refreshInstalledProjectIds() {
|
||||
if (!route.query.i) {
|
||||
const instances = await listInstances().catch(handleError)
|
||||
const instances = await queryClient
|
||||
.fetchQuery({
|
||||
queryKey: [...instanceKeys.all, 'installed-project-ids'],
|
||||
queryFn: listInstances,
|
||||
staleTime: 0,
|
||||
})
|
||||
.catch(handleError)
|
||||
if (!instances) return
|
||||
|
||||
const ids = instances
|
||||
@@ -303,7 +307,14 @@ async function refreshInstalledProjectIds() {
|
||||
}
|
||||
|
||||
if (route.query.from === 'worlds') {
|
||||
const worlds = await get_instance_worlds(route.query.i as string).catch(handleError)
|
||||
const targetInstanceId = route.query.i as string
|
||||
const worlds = await queryClient
|
||||
.fetchQuery({
|
||||
queryKey: instanceKeys.installedProjectIds(targetInstanceId, 'worlds'),
|
||||
queryFn: () => get_instance_worlds(targetInstanceId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.catch(handleError)
|
||||
if (!worlds) return
|
||||
|
||||
const serverProjectIds = worlds
|
||||
@@ -314,7 +325,14 @@ async function refreshInstalledProjectIds() {
|
||||
return
|
||||
}
|
||||
|
||||
const ids = await getInstalledProjectIds(route.query.i as string).catch(handleError)
|
||||
const targetInstanceId = route.query.i as string
|
||||
const ids = await queryClient
|
||||
.fetchQuery({
|
||||
queryKey: instanceKeys.installedProjectIds(targetInstanceId, 'content'),
|
||||
queryFn: () => getInstalledProjectIds(targetInstanceId),
|
||||
staleTime: 0,
|
||||
})
|
||||
.catch(handleError)
|
||||
if (!ids) return
|
||||
|
||||
debugLog('installedProjectIds loaded', { count: ids.length })
|
||||
@@ -329,11 +347,13 @@ async function initInstanceContext() {
|
||||
queryWid: route.query.wid,
|
||||
queryFrom: route.query.from,
|
||||
})
|
||||
await initServerContext()
|
||||
await refreshInstalledProjectIds()
|
||||
await Promise.all([
|
||||
initServerContext(),
|
||||
refreshInstalledProjectIds(),
|
||||
route.query.i ? instanceQuery.suspense().catch(handleError) : Promise.resolve(),
|
||||
])
|
||||
|
||||
if (route.query.i) {
|
||||
instance.value = (await getInstance(route.query.i as string).catch(handleError)) ?? null
|
||||
debugLog('instance loaded', {
|
||||
name: instance.value?.name,
|
||||
loader: instance.value?.loader,
|
||||
@@ -341,15 +361,7 @@ async function initInstanceContext() {
|
||||
})
|
||||
|
||||
if (instance.value?.link?.project_id) {
|
||||
debugLog('checking linked project for server status', instance.value.link.project_id)
|
||||
const projectV3 = await get_project_v3(
|
||||
instance.value.link.project_id,
|
||||
'must_revalidate',
|
||||
).catch(handleError)
|
||||
if (projectV3?.minecraft_server != null) {
|
||||
debugLog('instance is a server instance')
|
||||
isServerInstance.value = true
|
||||
}
|
||||
await linkedInstanceProjectQuery.suspense().catch(handleError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -577,16 +589,12 @@ const messages = defineMessages({
|
||||
const projectType = ref<ProjectType>(route.params.projectType as ProjectType)
|
||||
|
||||
function resetInstanceContext() {
|
||||
if (!instance.value) return
|
||||
|
||||
debugLog('instance context removed, resetting')
|
||||
instance.value = null
|
||||
installedProjectIds.value = null
|
||||
instanceHideInstalled.value = false
|
||||
newlyInstalled.value = []
|
||||
hiddenInstanceProjectIds.value = new Set()
|
||||
hiddenInstanceProjectIdsInitialized.value = false
|
||||
isServerInstance.value = false
|
||||
browseBreadcrumb.reset()
|
||||
void refreshInstalledProjectIds()
|
||||
}
|
||||
@@ -611,9 +619,18 @@ watch(
|
||||
|
||||
watch(
|
||||
() => route.query.i,
|
||||
(instanceId) => {
|
||||
if (!instanceId && route.path.startsWith('/browse')) {
|
||||
async (nextInstanceId, previousInstanceId) => {
|
||||
if (!route.path.startsWith('/browse') || nextInstanceId === previousInstanceId) return
|
||||
if (!nextInstanceId) {
|
||||
resetInstanceContext()
|
||||
return
|
||||
}
|
||||
|
||||
installedProjectIds.value = null
|
||||
hiddenInstanceProjectIdsInitialized.value = false
|
||||
await Promise.all([instanceQuery.suspense().catch(handleError), refreshInstalledProjectIds()])
|
||||
if (instance.value?.link?.project_id) {
|
||||
await linkedInstanceProjectQuery.suspense().catch(handleError)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<template>{{ instance.name }} overview</template>
|
||||
<script setup lang="ts">
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu>
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
}>()
|
||||
</script>
|
||||
+5
-5
@@ -36,11 +36,11 @@ import { computed, ref, watch } from 'vue'
|
||||
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import InstanceAdmonitionsSharedInstanceStale from './instance-admonitions-shared-instance-stale.vue'
|
||||
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
|
||||
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './instance-admonitions-shared-instance-update-available.vue'
|
||||
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
|
||||
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
|
||||
import InstanceAdmonitionsSharedInstanceStale from './shared-instance-stale.vue'
|
||||
import InstanceAdmonitionsSharedInstanceUnavailable from './shared-instance-unavailable.vue'
|
||||
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './shared-instance-update-available.vue'
|
||||
import InstanceAdmonitionsSharedInstanceWrongAccount from './shared-instance-wrong-account.vue'
|
||||
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types.ts'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
+1
-1
@@ -42,7 +42,7 @@ import { computed, ref } from 'vue'
|
||||
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
|
||||
import { instanceAdmonitionsMessages as messages } from './messages'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
+1
-1
@@ -20,7 +20,7 @@
|
||||
import { DownloadIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
|
||||
import { instanceAdmonitionsMessages as messages } from './messages'
|
||||
|
||||
defineProps<{
|
||||
instanceName: string
|
||||
+1
-1
@@ -29,7 +29,7 @@ import { computed } from 'vue'
|
||||
|
||||
import { get_user } from '@/helpers/cache'
|
||||
|
||||
import { instanceAdmonitionsMessages as messages } from './instance-admonitions-messages'
|
||||
import { instanceAdmonitionsMessages as messages } from './messages'
|
||||
import type { SharedInstanceRole } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
+2
-2
@@ -21,9 +21,9 @@ import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInsta
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, list, remove } from '@/helpers/instance'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
import type { GameInstance } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
+2
-2
@@ -10,9 +10,9 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings, Hooks } from '../../../helpers/types'
|
||||
import type { AppSettings, Hooks } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
+8
-8
@@ -22,19 +22,19 @@ import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
|
||||
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
|
||||
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
|
||||
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
|
||||
import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue'
|
||||
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
|
||||
import { get_project_v3 } from '@/helpers/cache'
|
||||
import { get_linked_modpack_info } from '@/helpers/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { provideInstanceSettings } from '@/providers/instance-settings'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import type { GameInstance } from '../../../helpers/types'
|
||||
import GeneralSettings from './general-settings.vue'
|
||||
import HooksSettings from './hooks-settings.vue'
|
||||
import InstallationSettings from './installation-settings.vue'
|
||||
import { provideInstanceSettings } from './instance-settings-context.ts'
|
||||
import JavaSettings from './java-settings.vue'
|
||||
import SharingSettings from './sharing-settings.vue'
|
||||
import WindowSettings from './window-settings.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
+7
-4
@@ -15,7 +15,6 @@ import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
|
||||
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
@@ -34,10 +33,12 @@ import {
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { provideInstanceBackup } from '@/providers/instance-backup'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
import type { Manifest } from '../../../helpers/types'
|
||||
import type { Manifest } from '../../../../helpers/types'
|
||||
import { instanceKeys } from '../../query-options.ts'
|
||||
import { injectInstanceSettings } from './instance-settings-context.ts'
|
||||
import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const filePicker = injectFilePicker()
|
||||
@@ -148,7 +149,9 @@ async function unlinkSharedInstance() {
|
||||
unlinkingSharedInstance.value = true
|
||||
try {
|
||||
await unlink_shared_instance(instance.value.id)
|
||||
await queryClient.invalidateQueries({ queryKey: ['sharedInstanceUsers', instance.value.id] })
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: instanceKeys.sharedMembers(instance.value.id),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
|
||||
onUnlinked()
|
||||
} catch (error) {
|
||||
+2
-2
@@ -25,9 +25,9 @@ import useJavaTest from '@/composables/useJavaTest'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
import { edit, get_optimal_jre_key } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings } from '../../../helpers/types'
|
||||
import type { AppSettings } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
+6
-4
@@ -94,8 +94,6 @@ import {
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
|
||||
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
|
||||
import { config } from '@/config'
|
||||
import {
|
||||
get_shared_instance_invites,
|
||||
@@ -104,7 +102,11 @@ import {
|
||||
unpublish_shared_instance,
|
||||
} from '@/helpers/instance'
|
||||
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import { instanceKeys } from '../../query-options.ts'
|
||||
import ConfirmRevokeSharedInstanceInviteModal from './confirm-revoke-shared-instance-invite-modal.vue'
|
||||
import { injectInstanceSettings } from './instance-settings-context.ts'
|
||||
import SharedInstanceInstallationSettingsControls from './shared-instance-installation-settings-controls.vue'
|
||||
|
||||
const { instance, offline, onUnlinked } = injectInstanceSettings()
|
||||
const { notifySharedInstanceError } = useSharedInstanceErrors()
|
||||
@@ -193,7 +195,7 @@ async function unpublishSharedInstance() {
|
||||
unpublishing.value = true
|
||||
try {
|
||||
await unpublish_shared_instance(instance.value.id)
|
||||
queryClient.setQueryData(['sharedInstanceUsers', instance.value.id], [])
|
||||
queryClient.setQueryData(instanceKeys.sharedMembers(instance.value.id), [])
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
|
||||
onUnlinked()
|
||||
} catch (error) {
|
||||
+2
-2
@@ -11,9 +11,9 @@ import { computed, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import { injectInstanceSettings } from '@/providers/instance-settings'
|
||||
|
||||
import type { AppSettings } from '../../../helpers/types'
|
||||
import type { AppSettings } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
+120
-111
@@ -19,13 +19,11 @@
|
||||
ref="modpackContentModal"
|
||||
:modpack-name="displayedModpackProject?.title"
|
||||
:modpack-icon-url="displayedModpackProject?.icon_url ?? undefined"
|
||||
:enable-toggle="!props.isServerInstance && !isSharedMember && !isQuarantined"
|
||||
:enable-toggle="!isServerInstance && !isSharedMember && !isQuarantined"
|
||||
:busy="isBulkOperating"
|
||||
:get-overflow-options="getOverflowOptions"
|
||||
:switch-version="
|
||||
props.isServerInstance || isSharedMember || isQuarantined
|
||||
? undefined
|
||||
: handleSwitchVersion
|
||||
isServerInstance || isSharedMember || isQuarantined ? undefined : handleSwitchVersion
|
||||
"
|
||||
@update:enabled="handleModpackContentToggle"
|
||||
@bulk:enable="(items) => handleModpackContentBulkToggle(items, true)"
|
||||
@@ -146,13 +144,15 @@ import {
|
||||
} from '@/helpers/instance'
|
||||
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
||||
import type { CacheBehaviour, GameInstance } from '@/helpers/types'
|
||||
import type { CacheBehaviour } from '@/helpers/types'
|
||||
import { highlightModInInstance } from '@/helpers/utils.js'
|
||||
import { injectContentInstall } from '@/providers/content-install'
|
||||
import { provideInstanceBackup } from '@/providers/instance-backup'
|
||||
import { useTheming } from '@/store/state'
|
||||
import type { FeatureFlag } from '@/store/theme'
|
||||
|
||||
import { injectInstancePage } from '../instance-context'
|
||||
import { instanceContentQueryOptions, instanceKeys } from '../query-options'
|
||||
|
||||
const messages = defineMessages({
|
||||
shareTitle: {
|
||||
id: 'app.instance.mods.share-title',
|
||||
@@ -218,13 +218,11 @@ const skipNonEssentialWarnings = computed(() =>
|
||||
themeStore.getFeatureFlag('skip_non_essential_warnings'),
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
isServerInstance?: boolean
|
||||
openSettings?: () => void
|
||||
preloadedContent?: InstanceContentData | null
|
||||
}>()
|
||||
const managedContentPolicy = useManagedContentPolicy(computed(() => props.instance))
|
||||
const instancePage = injectInstancePage()
|
||||
const instance = instancePage.instance
|
||||
const isServerInstance = instancePage.isServerInstance
|
||||
const openSettings = () => instancePage.openSettings(1)
|
||||
const managedContentPolicy = useManagedContentPolicy(computed(() => instance.value))
|
||||
const {
|
||||
isManagedModpack: isSharedMember,
|
||||
isQuarantined,
|
||||
@@ -232,18 +230,20 @@ const {
|
||||
canUpdateContent: canUpdateProject,
|
||||
} = managedContentPolicy
|
||||
|
||||
function hasPreloadedContent(contentData: InstanceContentData | null | undefined) {
|
||||
return contentData?.path === props.instance.id
|
||||
}
|
||||
|
||||
const loading = ref(!hasPreloadedContent(props.preloadedContent))
|
||||
const contentQuery = useQuery(
|
||||
computed(() => ({
|
||||
...instanceContentQueryOptions(instancePage.instanceId.value),
|
||||
enabled: !!instancePage.instanceId.value,
|
||||
})),
|
||||
)
|
||||
const loading = ref(contentQuery.data.value === undefined)
|
||||
const projects = ref<ContentItem[]>([])
|
||||
|
||||
const installingBuffer = ref<ContentItem[]>([])
|
||||
const handledInstallRevision = ref(0)
|
||||
|
||||
watch(
|
||||
() => installingItems.value.get(props.instance.id),
|
||||
() => installingItems.value.get(instance.value.id),
|
||||
(items) => {
|
||||
if (items && items.length > 0) {
|
||||
installingBuffer.value = [...items]
|
||||
@@ -261,7 +261,7 @@ watch(projects, (newProjects) => {
|
||||
})
|
||||
|
||||
const mergedProjects = computed<ContentItem[]>(() => {
|
||||
const active = installingItems.value.get(props.instance.id)
|
||||
const active = installingItems.value.get(instance.value.id)
|
||||
const pending = active ?? installingBuffer.value
|
||||
if (pending.length === 0) return projects.value
|
||||
const pendingProjectIds = new Set(pending.map((p) => p.project?.id).filter(Boolean))
|
||||
@@ -276,7 +276,7 @@ const mergedProjects = computed<ContentItem[]>(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => installFailureRevisionByInstance.value.get(props.instance.id) ?? 0,
|
||||
() => installFailureRevisionByInstance.value.get(instance.value.id) ?? 0,
|
||||
(revision, previousRevision) => {
|
||||
if (revision === previousRevision) return
|
||||
installingBuffer.value = []
|
||||
@@ -292,14 +292,14 @@ const linkedModpackUpdateVersionId = ref<string | null>(null)
|
||||
const localImportedModpackUnlinked = ref(false)
|
||||
|
||||
const localImportedModpackProject = computed<ContentModpackCardProject | null>(() => {
|
||||
const link = props.instance.link
|
||||
const link = instance.value.link
|
||||
if (localImportedModpackUnlinked.value || link?.type !== 'imported_modpack') return null
|
||||
|
||||
return {
|
||||
id: link.filename ?? props.instance.id,
|
||||
slug: link.filename ?? props.instance.id,
|
||||
title: link.name ?? props.instance.name,
|
||||
icon_url: props.instance.icon_path ? convertFileSrc(props.instance.icon_path) : undefined,
|
||||
id: link.filename ?? instance.value.id,
|
||||
slug: link.filename ?? instance.value.id,
|
||||
title: link.name ?? instance.value.name,
|
||||
icon_url: instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
|
||||
description: '',
|
||||
filename: link.filename ?? undefined,
|
||||
}
|
||||
@@ -310,7 +310,7 @@ const displayedModpackProject = computed(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.instance.link,
|
||||
() => instance.value.link,
|
||||
() => {
|
||||
localImportedModpackUnlinked.value = false
|
||||
},
|
||||
@@ -318,12 +318,12 @@ watch(
|
||||
|
||||
const isModpackUpdating = ref(false)
|
||||
const isBulkOperating = ref(false)
|
||||
const isInstanceBusy = computed(() => props.instance?.install_stage !== 'installed')
|
||||
const isInstanceBusy = computed(() => instance.value?.install_stage !== 'installed')
|
||||
const isPackLocked = computed(
|
||||
() =>
|
||||
props.instance.quarantined ||
|
||||
props.instance?.link?.type === 'modrinth_modpack' ||
|
||||
props.instance?.link?.type === 'server_project_modpack',
|
||||
instance.value.quarantined ||
|
||||
instance.value?.link?.type === 'modrinth_modpack' ||
|
||||
instance.value?.link?.type === 'server_project_modpack',
|
||||
)
|
||||
|
||||
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
|
||||
@@ -337,15 +337,15 @@ const unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal>
|
||||
const unknownFileName = ref('')
|
||||
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
|
||||
|
||||
const modpackContentQueryKey = computed(() => ['linkedModpackContent', props.instance.id])
|
||||
const modpackContentQueryKey = computed(() => instanceKeys.linkedContent(instance.value.id))
|
||||
const modpackContentQuery = useQuery({
|
||||
queryKey: modpackContentQueryKey,
|
||||
queryFn: () => get_linked_modpack_content(props.instance.id),
|
||||
queryFn: () => get_linked_modpack_content(instance.value.id),
|
||||
enabled: computed(
|
||||
() =>
|
||||
!!props.instance?.id &&
|
||||
!!props.instance?.link &&
|
||||
props.instance.install_stage === 'installed',
|
||||
!!instance.value?.id &&
|
||||
!!instance.value?.link &&
|
||||
instance.value.install_stage === 'installed',
|
||||
),
|
||||
})
|
||||
|
||||
@@ -523,15 +523,12 @@ async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: st
|
||||
}
|
||||
|
||||
async function handleBrowseContent() {
|
||||
if (!props.instance || props.instance.quarantined) return
|
||||
await router.push({
|
||||
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
|
||||
query: { i: props.instance.id },
|
||||
})
|
||||
if (!instance.value || instance.value.quarantined) return
|
||||
await instancePage.browseContent(instance.value.loader === 'vanilla' ? 'resourcepack' : 'mod')
|
||||
}
|
||||
|
||||
async function handleUploadFiles() {
|
||||
if (!props.instance || props.instance.quarantined) return
|
||||
if (!instance.value || instance.value.quarantined) return
|
||||
const files = await open({ multiple: true })
|
||||
if (!files) return
|
||||
const selectedFiles: Array<{ path: string; filename: string }> = []
|
||||
@@ -566,7 +563,7 @@ async function handleUploadFiles() {
|
||||
await Promise.all(
|
||||
confirmedFiles.map(async ({ path, filename }) => {
|
||||
try {
|
||||
const installedPath = await add_project_from_path(props.instance.id, path)
|
||||
const installedPath = await add_project_from_path(instance.value.id, path)
|
||||
return { filename, installedPath }
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
@@ -637,7 +634,7 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) {
|
||||
const originalFilePath = mod.file_path
|
||||
|
||||
try {
|
||||
const newPath = await toggle_disable_project(props.instance.id, mod.file_path, desiredEnabled)
|
||||
const newPath = await toggle_disable_project(instance.value.id, mod.file_path, desiredEnabled)
|
||||
const newFileName = fileNameFromPath(newPath)
|
||||
const enabled = !newPath.endsWith('.disabled')
|
||||
mod.file_path = newPath
|
||||
@@ -655,8 +652,8 @@ async function toggleDisableMod(mod: ContentItem, desiredEnabled?: boolean) {
|
||||
})
|
||||
|
||||
trackEvent('InstanceProjectDisable', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
id: mod.project?.id,
|
||||
name: mod.project?.title ?? mod.file_name,
|
||||
project_type: mod.project_type,
|
||||
@@ -678,12 +675,12 @@ async function removeMod(mod: ContentItem) {
|
||||
|
||||
try {
|
||||
const removedPath = mod.file_path
|
||||
await remove_project(props.instance.id, removedPath)
|
||||
await remove_project(instance.value.id, removedPath)
|
||||
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
|
||||
|
||||
trackEvent('InstanceProjectRemove', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
id: mod.project?.id,
|
||||
name: mod.project?.title ?? mod.file_name,
|
||||
project_type: mod.project_type,
|
||||
@@ -709,7 +706,7 @@ function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item
|
||||
}
|
||||
|
||||
async function getDeleteDependencyWarning(items: ContentItem[]) {
|
||||
if (props.isServerInstance) return null
|
||||
if (isServerInstance.value) return null
|
||||
|
||||
const deletingIds = new Set(items.map(getContentItemId))
|
||||
const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item)))
|
||||
@@ -787,12 +784,12 @@ async function bulkUpdateAllProjects(onProgress?: (status: BulkOperationStatus)
|
||||
waiting: true,
|
||||
})
|
||||
unlisten = await instance_bulk_update_progress_listener((progress) => {
|
||||
if (progress.instanceId !== props.instance.id) return
|
||||
if (progress.instanceId !== instance.value.id) return
|
||||
onProgress(formatBulkUpdateProgress(progress))
|
||||
})
|
||||
}
|
||||
|
||||
await update_all(props.instance.id)
|
||||
await update_all(instance.value.id)
|
||||
await refreshContentState('must_revalidate')
|
||||
} catch (err) {
|
||||
handleError(err as Error)
|
||||
@@ -810,14 +807,14 @@ async function updateProject(mod: ContentItem) {
|
||||
try {
|
||||
const updateVersionId = mod.update_version_id!
|
||||
await switch_project_version_with_dependencies(
|
||||
props.instance.id,
|
||||
instance.value.id,
|
||||
mod.file_path,
|
||||
updateVersionId,
|
||||
)
|
||||
|
||||
trackEvent('InstanceProjectUpdate', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
id: mod.project?.id,
|
||||
name: mod.project?.title ?? mod.file_name,
|
||||
project_type: mod.project_type,
|
||||
@@ -840,11 +837,11 @@ async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions
|
||||
const oldPath = mod.file_path
|
||||
|
||||
try {
|
||||
await switch_project_version_with_dependencies(props.instance.id, oldPath, version.id)
|
||||
await switch_project_version_with_dependencies(instance.value.id, oldPath, version.id)
|
||||
|
||||
trackEvent('InstanceProjectUpdate', {
|
||||
loader: props.instance.loader,
|
||||
game_version: props.instance.game_version,
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
id: mod.project?.id,
|
||||
name: mod.project?.title ?? mod.file_name,
|
||||
project_type: mod.project_type,
|
||||
@@ -872,8 +869,8 @@ async function handleUpdate(id: string) {
|
||||
currentVersionId: item.version.id,
|
||||
currentVersionNumber: item.version.version_number,
|
||||
updateVersionId: item.update_version_id,
|
||||
instanceGameVersion: props.instance.game_version,
|
||||
instanceLoader: props.instance.loader,
|
||||
instanceGameVersion: instance.value.game_version,
|
||||
instanceLoader: instance.value.loader,
|
||||
})
|
||||
|
||||
updatingModpack.value = false
|
||||
@@ -899,11 +896,11 @@ async function handleUpdate(id: string) {
|
||||
updateVersionId: item.update_version_id,
|
||||
},
|
||||
instance: {
|
||||
path: props.instance.id,
|
||||
name: props.instance.name,
|
||||
gameVersion: props.instance.game_version,
|
||||
loader: props.instance.loader,
|
||||
link: props.instance.link,
|
||||
path: instance.value.id,
|
||||
name: instance.value.name,
|
||||
gameVersion: instance.value.game_version,
|
||||
loader: instance.value.loader,
|
||||
link: instance.value.link,
|
||||
},
|
||||
modalStateBeforeFetch: {
|
||||
updatingModpack: updatingModpack.value,
|
||||
@@ -1027,7 +1024,7 @@ async function setModpackContentEnabled(items: ContentItem[], enabled: boolean)
|
||||
}
|
||||
|
||||
async function handleModpackContent() {
|
||||
if (!props.instance?.id) return
|
||||
if (!instance.value?.id) return
|
||||
|
||||
if (modpackContentQuery.data.value?.length) {
|
||||
modpackContentModal.value?.show(modpackContentQuery.data.value)
|
||||
@@ -1047,12 +1044,12 @@ async function handleModpackContent() {
|
||||
}
|
||||
|
||||
async function refreshModpackContentItems(cacheBehaviour?: CacheBehaviour) {
|
||||
if (!props.instance?.id) return
|
||||
if (!instance.value?.id) return
|
||||
|
||||
const contentItems = await queryClient
|
||||
.fetchQuery({
|
||||
queryKey: modpackContentQueryKey.value,
|
||||
queryFn: () => get_linked_modpack_content(props.instance.id, cacheBehaviour),
|
||||
queryFn: () => get_linked_modpack_content(instance.value.id, cacheBehaviour),
|
||||
})
|
||||
.catch(handleError)
|
||||
|
||||
@@ -1067,7 +1064,7 @@ async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => installRevisionByInstance.value.get(props.instance.id) ?? 0,
|
||||
() => installRevisionByInstance.value.get(instance.value.id) ?? 0,
|
||||
async (revision) => {
|
||||
if (revision <= handledInstallRevision.value) return
|
||||
handledInstallRevision.value = revision
|
||||
@@ -1076,7 +1073,7 @@ watch(
|
||||
)
|
||||
|
||||
async function handleModpackUpdate() {
|
||||
if (!props.instance?.link?.project_id) return
|
||||
if (!instance.value?.link?.project_id) return
|
||||
|
||||
const requestId = beginUpdateRequest()
|
||||
|
||||
@@ -1089,7 +1086,7 @@ async function handleModpackUpdate() {
|
||||
await nextTick()
|
||||
|
||||
const initialVersionId =
|
||||
linkedModpackUpdateVersionId.value ?? props.instance?.link?.version_id ?? undefined
|
||||
linkedModpackUpdateVersionId.value ?? instance.value?.link?.version_id ?? undefined
|
||||
debug('handleModpackUpdate: opening modpack updater modal', {
|
||||
type: 'modpack',
|
||||
initialVersionId,
|
||||
@@ -1098,11 +1095,11 @@ async function handleModpackUpdate() {
|
||||
linkedModpackVersion: linkedModpackVersion.value,
|
||||
linkedModpackHasUpdate: linkedModpackHasUpdate.value,
|
||||
instance: {
|
||||
path: props.instance.id,
|
||||
name: props.instance.name,
|
||||
gameVersion: props.instance.game_version,
|
||||
loader: props.instance.loader,
|
||||
link: props.instance.link,
|
||||
path: instance.value.id,
|
||||
name: instance.value.name,
|
||||
gameVersion: instance.value.game_version,
|
||||
loader: instance.value.loader,
|
||||
link: instance.value.link,
|
||||
},
|
||||
modalStateBeforeFetch: {
|
||||
updatingModpack: updatingModpack.value,
|
||||
@@ -1118,7 +1115,7 @@ async function handleModpackUpdate() {
|
||||
})
|
||||
contentUpdaterModal.value?.show(initialVersionId)
|
||||
|
||||
const versions = await getUpdaterProjectVersions(props.instance.link.project_id, initialVersionId)
|
||||
const versions = await getUpdaterProjectVersions(instance.value.link.project_id, initialVersionId)
|
||||
|
||||
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
|
||||
|
||||
@@ -1143,7 +1140,7 @@ async function handleModpackUpdate() {
|
||||
: null,
|
||||
versionCount: versions.length,
|
||||
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
|
||||
currentLinkedVersionId: props.instance.link.version_id,
|
||||
currentLinkedVersionId: instance.value.link.version_id,
|
||||
})
|
||||
|
||||
updatingProjectVersions.value = versions
|
||||
@@ -1195,14 +1192,14 @@ function resetUpdateState() {
|
||||
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
|
||||
pendingModpackUpdateVersion.value = selectedVersion
|
||||
|
||||
const currentVersionId = props.instance?.link?.version_id
|
||||
const currentVersionId = instance.value?.link?.version_id
|
||||
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
|
||||
isModpackUpdateDowngrade.value = currentVersion
|
||||
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
|
||||
: false
|
||||
const shouldShowWarning =
|
||||
isModpackUpdateDowngrade.value ||
|
||||
versionChangesGameVersion(selectedVersion, props.instance.game_version)
|
||||
versionChangesGameVersion(selectedVersion, instance.value.game_version)
|
||||
|
||||
if (skipNonEssentialWarnings.value || !shouldShowWarning) {
|
||||
await handleModpackUpdateConfirm()
|
||||
@@ -1213,7 +1210,7 @@ async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.
|
||||
}
|
||||
|
||||
async function handleModpackUpdateConfirm() {
|
||||
if (!pendingModpackUpdateVersion.value || !props.instance?.id) return
|
||||
if (!pendingModpackUpdateVersion.value || !instance.value?.id) return
|
||||
|
||||
const version = pendingModpackUpdateVersion.value
|
||||
pendingModpackUpdateVersion.value = null
|
||||
@@ -1221,7 +1218,7 @@ async function handleModpackUpdateConfirm() {
|
||||
contentUpdaterModal.value?.hide()
|
||||
isModpackUpdating.value = true
|
||||
try {
|
||||
await update_managed_modrinth_version(props.instance.id, version.id)
|
||||
await update_managed_modrinth_version(instance.value.id, version.id)
|
||||
await initProjects()
|
||||
} finally {
|
||||
isModpackUpdating.value = false
|
||||
@@ -1260,7 +1257,7 @@ async function handleModalUpdate(
|
||||
}
|
||||
|
||||
async function unpairInstance() {
|
||||
await edit(props.instance.id, {
|
||||
await edit(instance.value.id, {
|
||||
link: null as unknown as undefined,
|
||||
})
|
||||
linkedModpackProject.value = null
|
||||
@@ -1312,7 +1309,7 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
|
||||
options.push({
|
||||
id: formatMessage(commonMessages.showFileButton),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => highlightModInInstance(props.instance.id, item.file_path),
|
||||
action: () => highlightModInInstance(instance.value.id, item.file_path),
|
||||
})
|
||||
|
||||
if (item.project?.slug) {
|
||||
@@ -1330,15 +1327,19 @@ function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
|
||||
return options
|
||||
}
|
||||
|
||||
async function initProjects(cacheBehaviour?: CacheBehaviour) {
|
||||
if (!props.instance) return
|
||||
async function initProjects(cacheBehaviour?: CacheBehaviour, staleTime = 0) {
|
||||
if (!instance.value) return
|
||||
|
||||
const contentData = await loadInstanceContentData(props.instance.id, cacheBehaviour, handleError)
|
||||
const contentData = await queryClient.fetchQuery({
|
||||
...instanceContentQueryOptions(instance.value.id),
|
||||
queryFn: () => loadInstanceContentData(instance.value.id, cacheBehaviour, handleError),
|
||||
staleTime,
|
||||
})
|
||||
applyContentData(contentData)
|
||||
}
|
||||
|
||||
function applyContentData(contentData: InstanceContentData) {
|
||||
if (contentData.path !== props.instance.id) {
|
||||
if (contentData.path !== instance.value.id) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1372,8 +1373,6 @@ function applyContentData(contentData: InstanceContentData) {
|
||||
return true
|
||||
}
|
||||
|
||||
provideInstanceBackup(() => props.instance)
|
||||
|
||||
provideContentManager({
|
||||
items: mergedProjects,
|
||||
loading,
|
||||
@@ -1384,14 +1383,14 @@ provideContentManager({
|
||||
project: linkedModpackProject.value,
|
||||
projectLink: {
|
||||
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}`,
|
||||
query: { i: props.instance.id },
|
||||
query: { i: instance.value.id },
|
||||
},
|
||||
version: linkedModpackVersion.value ?? undefined,
|
||||
versionLink:
|
||||
linkedModpackProject.value && linkedModpackVersion.value
|
||||
? {
|
||||
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}/version/${linkedModpackVersion.value.id}`,
|
||||
query: { i: props.instance.id },
|
||||
query: { i: instance.value.id },
|
||||
}
|
||||
: undefined,
|
||||
owner: linkedModpackOwner.value
|
||||
@@ -1459,12 +1458,12 @@ provideContentManager({
|
||||
bulkUpdateAll: bulkUpdateAllProjects,
|
||||
bulkUpdateItem: updateProject,
|
||||
updateModpack:
|
||||
props.isServerInstance || isSharedMember.value || isQuarantined.value
|
||||
isServerInstance.value || isSharedMember.value || isQuarantined.value
|
||||
? undefined
|
||||
: handleModpackUpdate,
|
||||
viewModpackContent: handleModpackContent,
|
||||
unlinkModpack: unpairInstance,
|
||||
openSettings: props.openSettings,
|
||||
openSettings: openSettings,
|
||||
switchVersion: handleSwitchVersion,
|
||||
getOverflowOptions,
|
||||
shareItems: handleShareItems,
|
||||
@@ -1478,7 +1477,7 @@ provideContentManager({
|
||||
icon_url: null,
|
||||
},
|
||||
projectLink: item.project?.id
|
||||
? { path: `/project/${item.project.id}`, query: { i: props.instance.id } }
|
||||
? { path: `/project/${item.project.id}`, query: { i: instance.value.id } }
|
||||
: undefined,
|
||||
version: item.version ?? {
|
||||
id: item.file_name,
|
||||
@@ -1489,7 +1488,7 @@ provideContentManager({
|
||||
item.project?.id && item.version?.id
|
||||
? {
|
||||
path: `/project/${item.project.id}/version/${item.version.id}`,
|
||||
query: { i: props.instance.id },
|
||||
query: { i: instance.value.id },
|
||||
}
|
||||
: undefined,
|
||||
owner: item.owner
|
||||
@@ -1504,7 +1503,7 @@ provideContentManager({
|
||||
hideSwitchVersion: !canMutateContent(item) || !item.project?.id || !item.version?.id,
|
||||
hasUpdate: canUpdateProject(item),
|
||||
}),
|
||||
filterPersistKey: props.instance.id,
|
||||
filterPersistKey: instance.value.id,
|
||||
})
|
||||
|
||||
type UnlistenFn = () => void
|
||||
@@ -1513,7 +1512,7 @@ const initialContentReady = loadInitialContent()
|
||||
void initialContentReady.then(restoreModpackContentModalState).catch(handleError)
|
||||
|
||||
function getInstallRevision() {
|
||||
return installRevisionByInstance.value.get(props.instance.id) ?? 0
|
||||
return installRevisionByInstance.value.get(instance.value.id) ?? 0
|
||||
}
|
||||
|
||||
function loadInitialContent() {
|
||||
@@ -1523,13 +1522,23 @@ function loadInitialContent() {
|
||||
return initProjects('must_revalidate')
|
||||
}
|
||||
|
||||
if (props.preloadedContent && applyContentData(props.preloadedContent)) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return initProjects()
|
||||
return initProjects(undefined, 30_000)
|
||||
}
|
||||
|
||||
watch(
|
||||
contentQuery.data,
|
||||
(data) => {
|
||||
if (data) applyContentData(data)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(contentQuery.error, (error) => {
|
||||
if (error) {
|
||||
loading.value = false
|
||||
handleError(error)
|
||||
}
|
||||
})
|
||||
|
||||
async function restoreModpackContentModalState() {
|
||||
if (!savedModalState) return
|
||||
|
||||
@@ -1552,11 +1561,11 @@ let unlistenInstances: UnlistenFn | null = null
|
||||
onMounted(() => {
|
||||
void getCurrentWebview()
|
||||
.onDragDropEvent(async (event) => {
|
||||
if (event.payload.type !== 'drop' || !props.instance) return
|
||||
if (event.payload.type !== 'drop' || !instance.value) return
|
||||
|
||||
for (const file of event.payload.paths) {
|
||||
if (file.endsWith('.mrpack')) continue
|
||||
await add_project_from_path(props.instance.id, file).catch(handleError)
|
||||
await add_project_from_path(instance.value.id, file).catch(handleError)
|
||||
}
|
||||
await initProjects()
|
||||
})
|
||||
@@ -1572,10 +1581,10 @@ onMounted(() => {
|
||||
|
||||
void instance_listener(async (event: { event: string; instance_id: string }) => {
|
||||
if (
|
||||
props.instance &&
|
||||
event.instance_id === props.instance.id &&
|
||||
instance.value &&
|
||||
event.instance_id === instance.value.id &&
|
||||
event.event === 'synced' &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
instance.value.install_stage === 'installed' &&
|
||||
!isBulkOperating.value
|
||||
) {
|
||||
await initProjects()
|
||||
@@ -1593,7 +1602,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance?.install_stage,
|
||||
() => instance.value?.install_stage,
|
||||
async (newStage, oldStage) => {
|
||||
if (oldStage !== 'installed' && newStage === 'installed') {
|
||||
await refreshContentState('must_revalidate')
|
||||
@@ -1604,7 +1613,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.instance?.link,
|
||||
() => instance.value?.link,
|
||||
async (newInstanceLink, oldInstanceLink) => {
|
||||
if (oldInstanceLink && !newInstanceLink) {
|
||||
await initProjects('must_revalidate')
|
||||
@@ -1613,7 +1622,7 @@ watch(
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.instance?.update_channel,
|
||||
() => instance.value?.update_channel,
|
||||
async (newValue, oldValue) => {
|
||||
if (newValue !== oldValue) {
|
||||
await initProjects('must_revalidate')
|
||||
+58
-43
@@ -10,6 +10,7 @@ import {
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import {
|
||||
mkdir,
|
||||
@@ -22,21 +23,17 @@ import {
|
||||
writeFile as writeFileBytes,
|
||||
writeTextFile,
|
||||
} from '@tauri-apps/plugin-fs'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { get_full_path } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { highlightInFolder } from '@/helpers/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: unknown
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
isServerInstance: boolean
|
||||
}>()
|
||||
import { injectInstancePage } from '../instance-context'
|
||||
import { instanceKeys } from '../query-options'
|
||||
|
||||
const instancePage = injectInstancePage()
|
||||
const instanceId = instancePage.instanceId
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
@@ -53,7 +50,15 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const instanceRoot = ref('')
|
||||
const instanceRootQuery = useQuery(
|
||||
computed(() => ({
|
||||
queryKey: instanceKeys.rootPath(instancePage.instanceId.value),
|
||||
queryFn: () => get_full_path(instancePage.instanceId.value),
|
||||
enabled: !!instancePage.instanceId.value,
|
||||
staleTime: Infinity,
|
||||
})),
|
||||
)
|
||||
const instanceRoot = computed(() => instanceRootQuery.data.value ?? '')
|
||||
const items = ref<FileItem[]>([])
|
||||
/** True until the first directory read for the current instance path finishes (initial load only). */
|
||||
const firstPaintPending = ref(true)
|
||||
@@ -62,12 +67,7 @@ const error = ref<Error | null>(null)
|
||||
const currentPath = ref('')
|
||||
const editingFile = ref<EditingFile | null>(null)
|
||||
|
||||
debug('setup: start, instance.id =', props.instance.id)
|
||||
|
||||
instanceRoot.value = await get_full_path(props.instance.id)
|
||||
debug('setup: instanceRoot =', instanceRoot.value)
|
||||
await refresh()
|
||||
debug('setup: refresh complete, items =', items.value.length, 'error =', error.value)
|
||||
debug('setup: start, instance.id =', instanceId.value)
|
||||
|
||||
function resolvePath(relativePath: string): string {
|
||||
return relativePath ? `${instanceRoot.value}/${relativePath}` : instanceRoot.value
|
||||
@@ -113,21 +113,39 @@ async function listDirectory(dirPath: string): Promise<FileItem[]> {
|
||||
return results.filter((item): item is FileItem => item !== null)
|
||||
}
|
||||
|
||||
const directoryQuery = useQuery(
|
||||
computed(() => ({
|
||||
queryKey: instanceKeys.files(instancePage.instanceId.value, currentPath.value),
|
||||
queryFn: () => listDirectory(currentPath.value),
|
||||
enabled: !!instanceRoot.value,
|
||||
staleTime: 30_000,
|
||||
})),
|
||||
)
|
||||
|
||||
watch(
|
||||
directoryQuery.data,
|
||||
(data) => {
|
||||
if (!data) return
|
||||
items.value = data
|
||||
firstPaintPending.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(directoryQuery.isFetching, (fetching) => {
|
||||
loading.value = fetching
|
||||
})
|
||||
watch(directoryQuery.error, (queryError) => {
|
||||
error.value = queryError
|
||||
if (queryError) items.value = []
|
||||
})
|
||||
|
||||
await instanceRootQuery.suspense()
|
||||
await directoryQuery.refetch()
|
||||
firstPaintPending.value = false
|
||||
|
||||
async function refresh() {
|
||||
debug('refresh: called, currentPath =', currentPath.value, 'instanceRoot =', instanceRoot.value)
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
items.value = await listDirectory(currentPath.value)
|
||||
debug('refresh: success, items =', items.value.length)
|
||||
} catch (e) {
|
||||
debug('refresh: error =', e)
|
||||
error.value = e instanceof Error ? e : new Error(String(e))
|
||||
items.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
firstPaintPending.value = false
|
||||
}
|
||||
await directoryQuery.refetch()
|
||||
}
|
||||
|
||||
function navigateTo(path: string) {
|
||||
@@ -221,7 +239,7 @@ async function handleWriteFile(path: string, content: string) {
|
||||
|
||||
async function handleDownloadFile(path: string, _fileName: string) {
|
||||
await invoke('plugin:files|file_save_as', {
|
||||
instanceId: props.instance.id,
|
||||
instanceId: instanceId.value,
|
||||
filePath: path,
|
||||
})
|
||||
}
|
||||
@@ -275,7 +293,7 @@ async function handleUploadFiles(files: File[]) {
|
||||
async function handleExtractFile(path: string, override: boolean, dry: boolean) {
|
||||
try {
|
||||
return await invoke('plugin:files|file_extract_zip', {
|
||||
instanceId: props.instance.id,
|
||||
instanceId: instanceId.value,
|
||||
filePath: path,
|
||||
overrideConflicts: override,
|
||||
dryRun: dry,
|
||||
@@ -293,7 +311,7 @@ debug('setup: registering instance_listener')
|
||||
const unlistenInstances = await instance_listener(
|
||||
async (event: { event: string; instance_id: string }) => {
|
||||
debug('instance_listener: event =', event.event, 'path =', event.instance_id)
|
||||
if (event.instance_id === props.instance.id && event.event === 'synced') {
|
||||
if (event.instance_id === instanceId.value && event.event === 'synced') {
|
||||
debug('instance_listener: synced event matched, calling refresh')
|
||||
await refresh()
|
||||
}
|
||||
@@ -305,16 +323,13 @@ onUnmounted(() => {
|
||||
unlistenInstances()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance.id,
|
||||
async () => {
|
||||
debug('watch instance.id: changed to', props.instance.id)
|
||||
firstPaintPending.value = true
|
||||
instanceRoot.value = await get_full_path(props.instance.id)
|
||||
currentPath.value = ''
|
||||
await refresh()
|
||||
},
|
||||
)
|
||||
watch(instanceId, async () => {
|
||||
debug('watch instance.id: changed to', instanceId.value)
|
||||
firstPaintPending.value = true
|
||||
currentPath.value = ''
|
||||
await instanceRootQuery.refetch()
|
||||
await refresh()
|
||||
})
|
||||
|
||||
provideFileManager({
|
||||
items,
|
||||
@@ -1,9 +0,0 @@
|
||||
import Files from './Files.vue'
|
||||
import Index from './Index.vue'
|
||||
import Logs from './Logs.vue'
|
||||
import Mods from './Mods.vue'
|
||||
import Overview from './Overview.vue'
|
||||
import Share from './share/index.vue'
|
||||
import Worlds from './Worlds.vue'
|
||||
|
||||
export { Files, Index, Logs, Mods, Overview, Share, Worlds }
|
||||
@@ -0,0 +1,8 @@
|
||||
import Content from './content/index.vue'
|
||||
import Files from './files/index.vue'
|
||||
import Index from './layout.vue'
|
||||
import Logs from './logs/index.vue'
|
||||
import Share from './share/index.vue'
|
||||
import Worlds from './worlds/index.vue'
|
||||
|
||||
export { Content, Files, Index, Logs, Share, Worlds }
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
export interface InstancePageContext {
|
||||
readonly instanceId: ComputedRef<string>
|
||||
readonly instance: ComputedRef<GameInstance>
|
||||
readonly linkedProject: ComputedRef<Labrinth.Projects.v3.Project | undefined>
|
||||
readonly isServerInstance: ComputedRef<boolean>
|
||||
readonly offline: Readonly<Ref<boolean>>
|
||||
readonly playing: ComputedRef<boolean>
|
||||
readonly loading: Readonly<Ref<boolean>>
|
||||
readonly stopping: Readonly<Ref<boolean>>
|
||||
refreshInstance: () => Promise<void>
|
||||
refreshPlayState: () => Promise<void>
|
||||
play: (source: string) => Promise<void>
|
||||
stop: (source: string) => Promise<void>
|
||||
playServer: () => Promise<void>
|
||||
openSettings: (tab?: number) => void
|
||||
browseContent: (projectType?: string) => Promise<void>
|
||||
browseServers: () => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectInstancePage, provideInstancePage] =
|
||||
createContext<InstancePageContext>('InstancePage')
|
||||
+274
-479
@@ -11,7 +11,7 @@
|
||||
ref="settingsModal"
|
||||
:instance="instance"
|
||||
:offline="offline"
|
||||
@unlinked="fetchInstance"
|
||||
@unlinked="refreshInstance"
|
||||
/>
|
||||
<UpdateToPlayModal ref="updateToPlayModal" :instance="instance" />
|
||||
<SharedInstanceUpdateModal
|
||||
@@ -65,32 +65,20 @@
|
||||
:shared-instance-role="instance.shared_instance?.role"
|
||||
:shared-instance-signed-out="sharedInstanceSignedOut"
|
||||
:shared-instance-update-available="showSharedInstanceUpdateAdmonition"
|
||||
@published="fetchInstance"
|
||||
@published="refreshInstance"
|
||||
@delete="requestInstanceDeletion"
|
||||
@review-update="reviewSharedInstanceUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div :class="['p-6 pt-4', { 'min-h-0 flex-1 overflow-y-auto': isFixedRender }]">
|
||||
<RouterView v-slot="{ Component }" :key="instance.id" :route="displayedInstanceRoute">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<template v-if="Component">
|
||||
<Suspense
|
||||
:key="instance.id"
|
||||
@pending="subpagePending = true"
|
||||
@resolve="subpagePending = false"
|
||||
>
|
||||
<component
|
||||
:is="Component"
|
||||
:instance="instance"
|
||||
:options="options"
|
||||
:offline="offline"
|
||||
:playing="playing"
|
||||
:installed="instance.install_stage !== 'installed'"
|
||||
:is-server-instance="isServerInstance"
|
||||
:open-settings="() => settingsModal?.show(1)"
|
||||
v-bind="contentSubpageProps"
|
||||
@play="updatePlayState"
|
||||
@stop="() => stopInstance('InstanceSubpage')"
|
||||
></component>
|
||||
<component :is="Component" />
|
||||
</Suspense>
|
||||
</template>
|
||||
</RouterView>
|
||||
@@ -102,45 +90,24 @@
|
||||
<template #edit> <EditIcon /> Edit </template>
|
||||
<template #copy_path> <ClipboardCopyIcon /> Copy path </template>
|
||||
<template #open_folder> <FolderOpenIcon /> Open folder </template>
|
||||
<template #copy_link> <ClipboardCopyIcon /> Copy link </template>
|
||||
<template #open_link> <GlobeIcon /> Open in Modrinth <ExternalIcon /> </template>
|
||||
<template #copy_names><EditIcon />Copy names</template>
|
||||
<template #copy_slugs><HashIcon />Copy slugs</template>
|
||||
<template #copy_links><GlobeIcon />Copy links</template>
|
||||
<template #toggle><EditIcon />Toggle selected</template>
|
||||
<template #disable><XIcon />Disable selected</template>
|
||||
<template #enable><CheckCircleIcon />Enable selected</template>
|
||||
<template #hide_show><EyeIcon />Show/Hide unselected</template>
|
||||
<template #update_all
|
||||
><UpdatedIcon />Update {{ selected.length > 0 ? 'selected' : 'all' }}</template
|
||||
>
|
||||
<template #filter_update><UpdatedIcon />Select Updatable</template>
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
BoxesIcon,
|
||||
CheckCircleIcon,
|
||||
ClipboardCopyIcon,
|
||||
EditIcon,
|
||||
ExternalIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
HashIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StopCircleIcon,
|
||||
TerminalSquareIcon,
|
||||
UpdatedIcon,
|
||||
UserPlusIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
commonMessages,
|
||||
injectAuth,
|
||||
injectNotificationManager,
|
||||
NavTabs,
|
||||
useLoadingBarToken,
|
||||
@@ -148,17 +115,15 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { useOnline } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import { computed, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { computed, type ComputedRef, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ExportModal from '@/components/ui/ExportModal.vue'
|
||||
import InstanceAdmonitions from '@/components/ui/instance/instance-admonitions/index.vue'
|
||||
import InstancePageHeader from '@/components/ui/instance-page-header/index.vue'
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import InstanceSettingsModal from '@/components/ui/modal/InstanceSettingsModal.vue'
|
||||
import UpdateToPlayModal from '@/components/ui/modal/UpdateToPlayModal.vue'
|
||||
import SharedInstanceInstallModal from '@/components/ui/shared-instances/shared-instance-install-modal/index.vue'
|
||||
import SharedInstanceUpdateModal from '@/components/ui/shared-instances/SharedInstanceUpdateModal.vue'
|
||||
@@ -168,7 +133,6 @@ import {
|
||||
} from '@/composables/instances/use-server-status-query'
|
||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
import { instance_listener, process_listener } from '@/helpers/events'
|
||||
import {
|
||||
getSharedInstanceUnavailableReason,
|
||||
@@ -178,69 +142,126 @@ import {
|
||||
isSharedInstanceUnavailableError,
|
||||
type SharedInstanceUnavailableReason,
|
||||
} from '@/helpers/install'
|
||||
import {
|
||||
can_current_user_use_shared_instances,
|
||||
get,
|
||||
get_full_path,
|
||||
kill,
|
||||
remove,
|
||||
run,
|
||||
} from '@/helpers/instance'
|
||||
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { get_full_path, kill, remove, run } from '@/helpers/instance'
|
||||
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { createInstanceShortcut, showInstanceInFolder } from '@/helpers/utils.js'
|
||||
import { refreshWorlds, type ServerStatus } from '@/helpers/worlds'
|
||||
import type { ServerStatus } from '@/helpers/worlds'
|
||||
import { useRootBreadcrumb } from '@/providers/breadcrumbs'
|
||||
import { provideInstanceBackup } from '@/providers/instance-backup'
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
import { provideSharedInstanceState, useSharedInstanceState } from './use-shared-instance-state'
|
||||
import InstanceAdmonitions from './components/admonitions/index.vue'
|
||||
import InstancePageHeader from './components/page-header/index.vue'
|
||||
import InstanceSettingsModal from './components/settings-modal/index.vue'
|
||||
import { provideInstancePage } from './instance-context'
|
||||
import {
|
||||
instanceContentQueryOptions,
|
||||
instanceDetailQueryOptions,
|
||||
instanceKeys,
|
||||
instanceLinkedProjectQueryOptions,
|
||||
instanceProcessesQueryOptions,
|
||||
} from './query-options'
|
||||
import { createSharedInstanceContext, provideSharedInstance } from './shared-instance-context'
|
||||
|
||||
dayjs.extend(relativeTime)
|
||||
|
||||
const { addNotification, handleError } = injectNotificationManager()
|
||||
const { playServerProject } = injectServerInstall()
|
||||
const auth = injectAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const router = useRouter()
|
||||
const displayedInstanceRoute = shallowRef(router.currentRoute.value)
|
||||
const themeStore = useTheming()
|
||||
const showInstancePlayTime = computed(() => themeStore.getFeatureFlag('show_instance_play_time'))
|
||||
const contentSubpageRouteNames = new Set(['Mods', 'ModsFilter'])
|
||||
|
||||
const offline = ref(!navigator.onLine)
|
||||
window.addEventListener('offline', () => {
|
||||
offline.value = true
|
||||
})
|
||||
window.addEventListener('online', () => {
|
||||
offline.value = false
|
||||
})
|
||||
|
||||
const initialInstanceId = String(displayedInstanceRoute.value.params.id ?? '')
|
||||
const instance = ref<GameInstance | undefined>(
|
||||
queryClient.getQueryData<GameInstance>(['instances', 'summary', initialInstanceId]),
|
||||
const online = useOnline()
|
||||
const offline = computed(() => !online.value)
|
||||
const instanceId = computed(() => String(route.params.id ?? ''))
|
||||
const instanceQuery = useQuery(
|
||||
computed(() => ({
|
||||
...instanceDetailQueryOptions(instanceId.value),
|
||||
enabled: !!instanceId.value,
|
||||
})),
|
||||
)
|
||||
useQuery(
|
||||
computed(() => ({
|
||||
...instanceContentQueryOptions(instanceId.value, (error) => handleError(error)),
|
||||
enabled: !!instanceId.value,
|
||||
})),
|
||||
)
|
||||
const instance = computed(() => instanceQuery.data.value)
|
||||
const linkedProjectId = computed(() => instance.value?.link?.project_id ?? '')
|
||||
const linkedProjectQuery = useQuery(
|
||||
computed(() => ({
|
||||
...instanceLinkedProjectQueryOptions(linkedProjectId.value),
|
||||
enabled: !!linkedProjectId.value && !offline.value,
|
||||
})),
|
||||
)
|
||||
const linkedProjectV3 = computed(() => linkedProjectQuery.data.value ?? undefined)
|
||||
const isServerInstance = computed(() => linkedProjectV3.value?.minecraft_server != null)
|
||||
const processesQuery = useQuery(
|
||||
computed(() => ({
|
||||
...instanceProcessesQueryOptions(instanceId.value),
|
||||
enabled: !!instanceId.value,
|
||||
})),
|
||||
)
|
||||
const playing = computed(() => (processesQuery.data.value?.length ?? 0) > 0)
|
||||
|
||||
async function ensureCriticalContent(targetInstanceId: string) {
|
||||
await queryClient.ensureQueryData(
|
||||
instanceContentQueryOptions(targetInstanceId, (error) => handleError(error)),
|
||||
)
|
||||
}
|
||||
|
||||
async function ensureCriticalInstanceData(targetInstanceId: string) {
|
||||
await Promise.all([
|
||||
queryClient.ensureQueryData(instanceDetailQueryOptions(targetInstanceId)),
|
||||
ensureCriticalContent(targetInstanceId),
|
||||
])
|
||||
}
|
||||
|
||||
function isUnmanagedInstanceError(error: unknown) {
|
||||
return error instanceof Error && error.message.includes('is not managed')
|
||||
}
|
||||
|
||||
try {
|
||||
await ensureCriticalInstanceData(instanceId.value)
|
||||
} catch (error) {
|
||||
if (isUnmanagedInstanceError(error)) await router.replace('/')
|
||||
else handleError(error)
|
||||
}
|
||||
|
||||
onBeforeRouteUpdate(async (to, from) => {
|
||||
const targetInstanceId = String(to.params.id ?? '')
|
||||
const currentInstanceId = String(from.params.id ?? '')
|
||||
if (!targetInstanceId || targetInstanceId === currentInstanceId) return
|
||||
|
||||
try {
|
||||
await ensureCriticalInstanceData(targetInstanceId)
|
||||
} catch (error) {
|
||||
if (isUnmanagedInstanceError(error)) return { path: '/' }
|
||||
handleError(error)
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
useRootBreadcrumb({
|
||||
slot: 'instance',
|
||||
id: () => `instance:${String(displayedInstanceRoute.value.params.id ?? '')}`,
|
||||
id: () => `instance:${instanceId.value}`,
|
||||
label: () => instance.value?.name ?? formatMessage(commonMessages.loadingLabel),
|
||||
visual: () => ({
|
||||
type: 'image',
|
||||
src: instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : undefined,
|
||||
alt: instance.value?.name,
|
||||
tintBy: instance.value?.id ?? String(displayedInstanceRoute.value.params.id ?? ''),
|
||||
tintBy: instance.value?.id ?? instanceId.value,
|
||||
}),
|
||||
to: () => `/instance/${encodeURIComponent(String(displayedInstanceRoute.value.params.id ?? ''))}`,
|
||||
to: () => `/instance/${encodeURIComponent(instanceId.value)}`,
|
||||
})
|
||||
|
||||
const preloadedContent = ref<InstanceContentData | null>(null)
|
||||
const playing = ref(false)
|
||||
const loading = ref(false)
|
||||
const checkingSharedInstanceLaunch = ref(false)
|
||||
const subpagePending = ref(false)
|
||||
@@ -250,16 +271,15 @@ const updateToPlayModal = ref<InstanceType<typeof UpdateToPlayModal>>()
|
||||
const sharedInstanceUpdateModal = ref<InstanceType<typeof SharedInstanceUpdateModal>>()
|
||||
const sharedInstanceReportModal = ref<InstanceType<typeof SharedInstanceInstallModal>>()
|
||||
const deleteConfirmModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
|
||||
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
|
||||
const selectedInstanceToDelete = ref<GameInstance | null>(null)
|
||||
const hiddenSharedInstanceUpdateKey = ref<string | null>(null)
|
||||
|
||||
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
|
||||
|
||||
useLoadingBarToken(subpagePending)
|
||||
useLoadingBarToken(computed(() => instanceQuery.isPending.value && !instance.value))
|
||||
|
||||
const isServerInstance = ref(false)
|
||||
const linkedProjectV3 = ref<Labrinth.Projects.v3.Project>()
|
||||
const selected = ref<unknown[]>([])
|
||||
const minecraftServer = computed(() => linkedProjectV3.value?.minecraft_server)
|
||||
const javaServerPingData = computed(() => linkedProjectV3.value?.minecraft_java_server?.ping?.data)
|
||||
const liveServerStatusOnline = ref(false)
|
||||
@@ -270,9 +290,12 @@ const recentPlays = computed(
|
||||
const playersOnline = ref<number | undefined>(undefined)
|
||||
const ping = ref<number | undefined>(undefined)
|
||||
const loadingServerPing = ref(false)
|
||||
const activeInstanceId = ref<string>()
|
||||
const sharedInstanceState = useSharedInstanceState(instance, offline, notifySharedInstanceError)
|
||||
provideSharedInstanceState(sharedInstanceState)
|
||||
const sharedInstanceState = createSharedInstanceContext(
|
||||
instance,
|
||||
offline,
|
||||
notifySharedInstanceError,
|
||||
)
|
||||
provideSharedInstance(sharedInstanceState)
|
||||
const {
|
||||
actionsLocked: sharedInstanceActionsLocked,
|
||||
expectedUserId: sharedInstanceExpectedUserId,
|
||||
@@ -296,19 +319,6 @@ const showSharedInstanceUpdateAdmonition = computed(
|
||||
sharedInstanceUpdateKey.value !== hiddenSharedInstanceUpdateKey.value,
|
||||
)
|
||||
|
||||
watch(
|
||||
() => router.currentRoute.value,
|
||||
(nextRoute) => {
|
||||
if (
|
||||
nextRoute.path.startsWith('/instance') &&
|
||||
(!instance.value || nextRoute.params.id === instance.value.id)
|
||||
) {
|
||||
displayedInstanceRoute.value = nextRoute
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function applyServerStatus(status: ServerStatus) {
|
||||
playersOnline.value = status.players?.online
|
||||
ping.value = status.ping
|
||||
@@ -323,124 +333,66 @@ function resetServerStatus() {
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
function isContentSubpageRoute(routeName = displayedInstanceRoute.value.name) {
|
||||
return typeof routeName === 'string' && contentSubpageRouteNames.has(routeName)
|
||||
}
|
||||
|
||||
async function fetchInstance() {
|
||||
const requestedInstanceId = route.params.id as string
|
||||
const requestedRouteName = route.name
|
||||
|
||||
const nextInstance = await get(requestedInstanceId).catch(handleError)
|
||||
let nextLinkedProjectV3: Labrinth.Projects.v3.Project | undefined
|
||||
let nextIsServerInstance = false
|
||||
|
||||
const contentPreloadPromise =
|
||||
nextInstance && isContentSubpageRoute(requestedRouteName)
|
||||
? loadInstanceContentData(nextInstance.id, undefined, handleError)
|
||||
: Promise.resolve(null)
|
||||
|
||||
if (!offline.value && nextInstance?.link && nextInstance.link.project_id) {
|
||||
try {
|
||||
nextLinkedProjectV3 = await get_project_v3(nextInstance.link.project_id, 'must_revalidate')
|
||||
|
||||
if (nextLinkedProjectV3?.minecraft_server != null) {
|
||||
nextIsServerInstance = true
|
||||
}
|
||||
} catch (error) {
|
||||
handleError(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
let nextPreloadedContent = await contentPreloadPromise
|
||||
let nextRoute = router.currentRoute.value
|
||||
if (nextRoute.params.id !== requestedInstanceId) return
|
||||
|
||||
if (nextInstance && isContentSubpageRoute(nextRoute.name) && !nextPreloadedContent) {
|
||||
nextPreloadedContent = await loadInstanceContentData(nextInstance.id, undefined, handleError)
|
||||
nextRoute = router.currentRoute.value
|
||||
if (nextRoute.params.id !== requestedInstanceId) return
|
||||
}
|
||||
|
||||
instance.value = nextInstance ?? undefined
|
||||
if (nextInstance) {
|
||||
queryClient.setQueryData(['instances', 'summary', nextInstance.id], nextInstance)
|
||||
}
|
||||
displayedInstanceRoute.value = nextRoute
|
||||
sharedInstanceState.reset()
|
||||
sharedInstanceState.refreshAvailability()
|
||||
linkedProjectV3.value = nextLinkedProjectV3
|
||||
isServerInstance.value = nextIsServerInstance
|
||||
preloadedContent.value = nextPreloadedContent
|
||||
activeInstanceId.value = nextInstance?.id
|
||||
resetServerStatus()
|
||||
|
||||
fetchDeferredData(nextInstance?.id)
|
||||
|
||||
if (nextInstance) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['worlds', nextInstance.id],
|
||||
queryFn: () => refreshWorlds(nextInstance.id),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function fetchDeferredData(instanceId?: string) {
|
||||
const serverAddress = linkedProjectV3.value?.minecraft_java_server?.address
|
||||
if (isServerInstance.value && serverAddress) {
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, serverAddress)
|
||||
if (cachedStatus) {
|
||||
applyServerStatus(cachedStatus)
|
||||
} else {
|
||||
playersOnline.value = undefined
|
||||
ping.value = undefined
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
fetchCachedServerStatus(queryClient, serverAddress)
|
||||
.then((status) => {
|
||||
if (
|
||||
activeInstanceId.value !== instanceId ||
|
||||
linkedProjectV3.value?.minecraft_java_server?.address !== serverAddress
|
||||
)
|
||||
return
|
||||
applyServerStatus(status)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to fetch server status for ${serverAddress}:`, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (activeInstanceId.value !== instanceId) return
|
||||
loadingServerPing.value = true
|
||||
})
|
||||
} else {
|
||||
loadingServerPing.value = true
|
||||
}
|
||||
|
||||
updatePlayState()
|
||||
}
|
||||
|
||||
async function updatePlayState() {
|
||||
if (!route.params.id) return
|
||||
const runningProcesses = await get_by_instance_id(route.params.id as string).catch(handleError)
|
||||
|
||||
playing.value = Array.isArray(runningProcesses) && runningProcesses.length > 0
|
||||
}
|
||||
|
||||
await fetchInstance()
|
||||
const serverAddress = computed(() => linkedProjectV3.value?.minecraft_java_server?.address)
|
||||
watch(
|
||||
() => route.params.id,
|
||||
async () => {
|
||||
if (route.params.id && route.path.startsWith('/instance')) {
|
||||
await fetchInstance()
|
||||
[instanceId, serverAddress, isServerInstance],
|
||||
([requestedInstanceId, address, serverInstance]) => {
|
||||
resetServerStatus()
|
||||
if (serverInstance && address) {
|
||||
const cachedStatus = getFreshCachedServerStatus(queryClient, address)
|
||||
if (cachedStatus) {
|
||||
applyServerStatus(cachedStatus)
|
||||
} else {
|
||||
playersOnline.value = undefined
|
||||
ping.value = undefined
|
||||
loadingServerPing.value = false
|
||||
}
|
||||
|
||||
fetchCachedServerStatus(queryClient, address)
|
||||
.then((status) => {
|
||||
if (instanceId.value !== requestedInstanceId || serverAddress.value !== address) return
|
||||
applyServerStatus(status)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(`Failed to fetch server status for ${address}:`, error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (instanceId.value !== requestedInstanceId) return
|
||||
loadingServerPing.value = true
|
||||
})
|
||||
} else {
|
||||
loadingServerPing.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const basePath = computed(
|
||||
() => `/instance/${encodeURIComponent(displayedInstanceRoute.value.params.id as string)}`,
|
||||
async function refreshInstance() {
|
||||
await Promise.all([instanceQuery.refetch(), sharedInstanceState.refreshAvailability()])
|
||||
}
|
||||
|
||||
async function refreshPlayState() {
|
||||
await processesQuery.refetch()
|
||||
}
|
||||
|
||||
watch(
|
||||
instanceQuery.error,
|
||||
(error) => {
|
||||
if (!error) return
|
||||
if (error.message.includes('is not managed')) void router.replace('/')
|
||||
else handleError(error)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(
|
||||
linkedProjectQuery.error,
|
||||
(error) => {
|
||||
if (error) handleError(error)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const basePath = computed(() => `/instance/${encodeURIComponent(instanceId.value)}`)
|
||||
|
||||
/**
|
||||
* Per-route layout mode.
|
||||
@@ -451,25 +403,10 @@ const basePath = computed(
|
||||
* Used by tabs whose content (e.g. the log console) needs a bounded height to resolve `h-full`.
|
||||
*/
|
||||
const renderMode = computed<'scroll' | 'fixed'>(() =>
|
||||
displayedInstanceRoute.value.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
|
||||
route.meta.renderMode === 'fixed' ? 'fixed' : 'scroll',
|
||||
)
|
||||
const isFixedRender = computed(() => renderMode.value === 'fixed')
|
||||
const contentSubpageProps = computed(() =>
|
||||
isContentSubpageRoute() ? { preloadedContent: preloadedContent.value } : {},
|
||||
)
|
||||
const { data: canCurrentUserUseSharedInstances } = useQuery({
|
||||
queryKey: computed(() => ['shared-instance-eligibility', auth.user.value?.id]),
|
||||
queryFn: can_current_user_use_shared_instances,
|
||||
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
const currentUserCanUseSharedInstances = computed(
|
||||
() => !auth.session_token.value || canCurrentUserUseSharedInstances.value !== false,
|
||||
)
|
||||
const currentUserCanUseSharedInstances = sharedInstanceState.currentUserCanUseSharedInstances
|
||||
const showShareTab = computed(() => {
|
||||
const linkType = instance.value?.link?.type
|
||||
|
||||
@@ -534,12 +471,13 @@ const options = ref<InstanceType<typeof ContextMenu> | null>(null)
|
||||
|
||||
const launchInstance = async (context: string) => {
|
||||
if (!instance.value || instance.value.quarantined) return
|
||||
const currentInstance = instance.value
|
||||
loading.value = true
|
||||
try {
|
||||
await run(route.params.id as string)
|
||||
playing.value = true
|
||||
await run(currentInstance.id)
|
||||
queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [true])
|
||||
} catch (err) {
|
||||
handleSevereError(err, { instanceId: route.params.id as string })
|
||||
handleSevereError(err, { instanceId: currentInstance.id })
|
||||
}
|
||||
loading.value = false
|
||||
|
||||
@@ -555,7 +493,7 @@ async function handleSharedInstanceUnavailable(
|
||||
reason: SharedInstanceUnavailableReason | null = null,
|
||||
) {
|
||||
notifySharedInstanceUnavailable(reason, sharedInstanceUnavailableManager.value)
|
||||
await fetchInstance()
|
||||
await refreshInstance()
|
||||
setSharedInstanceUnavailable(reason)
|
||||
}
|
||||
|
||||
@@ -574,7 +512,7 @@ function reviewSharedInstanceUpdate(event: MouseEvent) {
|
||||
currentInstance,
|
||||
preview,
|
||||
async () => {
|
||||
await fetchInstance()
|
||||
await refreshInstance()
|
||||
},
|
||||
event,
|
||||
)
|
||||
@@ -618,7 +556,7 @@ const startInstance = async (context: string) => {
|
||||
|
||||
if (preview?.updateAvailable && sharedInstanceUpdateModal.value) {
|
||||
sharedInstanceUpdateModal.value.show(instance.value, preview, async () => {
|
||||
await fetchInstance()
|
||||
await refreshInstance()
|
||||
await launchInstance(context)
|
||||
})
|
||||
return
|
||||
@@ -628,7 +566,7 @@ const startInstance = async (context: string) => {
|
||||
if (updateToPlayModal.value?.hasUpdate) {
|
||||
if (isSharedInstanceMember) {
|
||||
updateToPlayModal.value.show(instance.value, null, async () => {
|
||||
await fetchInstance()
|
||||
await refreshInstance()
|
||||
await launchInstance(context)
|
||||
})
|
||||
} else {
|
||||
@@ -641,15 +579,16 @@ const startInstance = async (context: string) => {
|
||||
}
|
||||
|
||||
const stopInstance = async (context: string) => {
|
||||
const currentInstance = instance.value
|
||||
if (!currentInstance) return
|
||||
stopping.value = true
|
||||
await kill(route.params.id as string).catch(handleError)
|
||||
await kill(currentInstance.id).catch(handleError)
|
||||
stopping.value = false
|
||||
playing.value = false
|
||||
queryClient.setQueryData(instanceKeys.processes(currentInstance.id), [])
|
||||
|
||||
if (!instance.value) return
|
||||
trackEvent('InstanceStop', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
loader: currentInstance.loader,
|
||||
game_version: currentInstance.game_version,
|
||||
source: context,
|
||||
})
|
||||
}
|
||||
@@ -660,26 +599,48 @@ const handlePlayServer = async () => {
|
||||
try {
|
||||
await playServerProject(instance.value.link.project_id)
|
||||
} finally {
|
||||
await updatePlayState()
|
||||
await refreshPlayState()
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openSettings(tab?: number) {
|
||||
settingsModal.value?.show(tab)
|
||||
}
|
||||
|
||||
async function browseContent(projectType?: string) {
|
||||
const currentInstance = instance.value
|
||||
if (!currentInstance || currentInstance.quarantined) return
|
||||
await router.push({
|
||||
path: `/browse/${projectType ?? (currentInstance.loader === 'vanilla' ? 'resourcepack' : 'mod')}`,
|
||||
query: { i: currentInstance.id },
|
||||
})
|
||||
}
|
||||
|
||||
async function browseServers() {
|
||||
if (!instance.value || instance.value.quarantined) return
|
||||
await router.push({
|
||||
path: '/browse/server',
|
||||
query: { i: instance.value.id, from: 'worlds' },
|
||||
})
|
||||
}
|
||||
|
||||
const repairInstance = async () => {
|
||||
if (instance.value.quarantined) return
|
||||
const currentInstance = instance.value
|
||||
if (!currentInstance || currentInstance.quarantined) return
|
||||
if (
|
||||
instance.value.install_stage !== 'pack_installed' &&
|
||||
(instance.value.link?.type === 'modrinth_modpack' ||
|
||||
instance.value.link?.type === 'server_project_modpack')
|
||||
currentInstance.install_stage !== 'pack_installed' &&
|
||||
(currentInstance.link?.type === 'modrinth_modpack' ||
|
||||
currentInstance.link?.type === 'server_project_modpack')
|
||||
) {
|
||||
await install_pack_to_existing_instance(instance.value.id, {
|
||||
await install_pack_to_existing_instance(currentInstance.id, {
|
||||
type: 'fromVersionId',
|
||||
project_id: instance.value.link.project_id ?? instance.value.link.server_project_id ?? '',
|
||||
version_id: instance.value.link.version_id ?? instance.value.link.content_version_id ?? '',
|
||||
title: instance.value.name,
|
||||
project_id: currentInstance.link.project_id ?? currentInstance.link.server_project_id ?? '',
|
||||
version_id: currentInstance.link.version_id ?? currentInstance.link.content_version_id ?? '',
|
||||
title: currentInstance.name,
|
||||
}).catch(handleError)
|
||||
} else {
|
||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||
await install_existing_instance(currentInstance.id, false).catch(handleError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,15 +747,10 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
|
||||
await stopInstance('InstancePageContextMenu')
|
||||
break
|
||||
case 'add_content':
|
||||
await router.push({
|
||||
path: `/browse/${instance.value?.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||||
query: { i: route.params.id },
|
||||
})
|
||||
await browseContent(instance.value?.loader === 'vanilla' ? 'datapack' : 'mod')
|
||||
break
|
||||
case 'edit':
|
||||
await router.push({
|
||||
path: `/instance/${encodeURIComponent(route.params.id as string)}/options`,
|
||||
})
|
||||
openSettings()
|
||||
break
|
||||
case 'open_folder':
|
||||
if (instance.value) await showInstanceInFolder(instance.value.id)
|
||||
@@ -809,41 +765,79 @@ const handleOptionsClick = async (args: { option: string; item: unknown }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const unlistenInstances = await instance_listener(
|
||||
async (event: { instance_id: string; event: string }) => {
|
||||
if (event.instance_id !== route.params.id) return
|
||||
let unlistenInstances: (() => void) | null = null
|
||||
let unlistenProcesses: (() => void) | null = null
|
||||
let instancePageAlive = true
|
||||
|
||||
provideInstancePage({
|
||||
instanceId,
|
||||
instance: instance as ComputedRef<GameInstance>,
|
||||
linkedProject: linkedProjectV3,
|
||||
isServerInstance,
|
||||
offline,
|
||||
playing,
|
||||
loading,
|
||||
stopping,
|
||||
refreshInstance,
|
||||
refreshPlayState,
|
||||
play: startInstance,
|
||||
stop: stopInstance,
|
||||
playServer: handlePlayServer,
|
||||
openSettings,
|
||||
browseContent,
|
||||
browseServers,
|
||||
})
|
||||
provideInstanceBackup(() => instance.value!)
|
||||
|
||||
function destroyInstanceConsole(targetInstanceId: string) {
|
||||
void useInstanceConsole(targetInstanceId).destroy()
|
||||
queryClient.removeQueries({ queryKey: instanceKeys.console(targetInstanceId), exact: true })
|
||||
}
|
||||
|
||||
watch(instanceId, (currentInstanceId, previousInstanceId) => {
|
||||
if (!previousInstanceId || previousInstanceId === currentInstanceId) return
|
||||
destroyInstanceConsole(previousInstanceId)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
void instance_listener(async (event: { instance_id: string; event: string }) => {
|
||||
if (event.instance_id !== instanceId.value) return
|
||||
if (event.event === 'removed' || route.path === '/') {
|
||||
if (route.path !== '/') {
|
||||
await router.push({ path: '/' })
|
||||
}
|
||||
if (route.path !== '/') await router.push({ path: '/' })
|
||||
return
|
||||
}
|
||||
instance.value = await get(route.params.id as string).catch((err) => {
|
||||
if (String(err).includes('not managed')) {
|
||||
router.push({ path: '/' })
|
||||
return undefined
|
||||
}
|
||||
return handleError(err)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: instanceKeys.detail(event.instance_id),
|
||||
exact: true,
|
||||
})
|
||||
if (!instance.value?.link?.project_id) {
|
||||
linkedProjectV3.value = undefined
|
||||
isServerInstance.value = false
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (instancePageAlive) unlistenInstances = unlisten
|
||||
else unlisten()
|
||||
})
|
||||
.catch(handleError)
|
||||
|
||||
const unlistenProcesses = await process_listener((e: { event: string; instance_id: string }) => {
|
||||
if (e.event === 'finished' && e.instance_id === route.params.id) {
|
||||
playing.value = false
|
||||
}
|
||||
void process_listener((event: { event: string; instance_id: string }) => {
|
||||
if (event.instance_id !== instanceId.value) return
|
||||
if (event.event === 'finished') {
|
||||
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [])
|
||||
useInstanceConsole(event.instance_id).invalidate()
|
||||
void queryClient.invalidateQueries({ queryKey: instanceKeys.logs(event.instance_id) })
|
||||
} else if (event.event === 'launched') {
|
||||
queryClient.setQueryData(instanceKeys.processes(event.instance_id), [true])
|
||||
}
|
||||
})
|
||||
.then((unlisten) => {
|
||||
if (instancePageAlive) unlistenProcesses = unlisten
|
||||
else unlisten()
|
||||
})
|
||||
.catch(handleError)
|
||||
})
|
||||
|
||||
const icon = computed(() =>
|
||||
instance.value?.icon_path ? convertFileSrc(instance.value.icon_path) : null,
|
||||
)
|
||||
|
||||
const settingsModal = ref<InstanceType<typeof InstanceSettingsModal>>()
|
||||
|
||||
const timePlayed = computed(() => {
|
||||
return instance.value
|
||||
? instance.value.recent_time_played + instance.value.submitted_time_played
|
||||
@@ -851,210 +845,11 @@ const timePlayed = computed(() => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
unlistenProcesses()
|
||||
unlistenInstances()
|
||||
const instanceId = displayedInstanceRoute.value.params.id
|
||||
if (instanceId) {
|
||||
const { destroy } = useInstanceConsole(instanceId)
|
||||
destroy()
|
||||
instancePageAlive = false
|
||||
unlistenProcesses?.()
|
||||
unlistenInstances?.()
|
||||
if (instanceId.value) {
|
||||
destroyInstanceConsole(instanceId.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.instance-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
Button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.side-cards {
|
||||
position: fixed;
|
||||
width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
min-height: calc(100vh - 3.25rem);
|
||||
max-height: calc(100vh - 3.25rem);
|
||||
overflow-y: auto;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: unset;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.instance-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
background: var(--color-raised-bg);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 1.25rem;
|
||||
color: var(--color-contrast);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.metadata {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.instance-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
overflow: auto;
|
||||
gap: 1rem;
|
||||
min-height: 100%;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.instance-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
width: fit-content;
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
.pages-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--gap-xs);
|
||||
|
||||
.btn {
|
||||
font-size: 100%;
|
||||
font-weight: 400;
|
||||
background: inherit;
|
||||
transition: all ease-in-out 0.1s;
|
||||
width: 100%;
|
||||
color: var(--color-primary);
|
||||
box-shadow: none;
|
||||
|
||||
&.router-link-exact-active {
|
||||
box-shadow: var(--shadow-inset-lg);
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
box-shadow: var(--shadow-inset-lg);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 1.3rem;
|
||||
height: 1.3rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.instance-nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: left;
|
||||
padding: 1rem;
|
||||
gap: 0.5rem;
|
||||
height: min-content;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.instance-button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0 1rem 0.5rem 20rem;
|
||||
width: calc(100% - 20rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.stats {
|
||||
grid-area: stats;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-md);
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
gap: var(--gap-xs);
|
||||
--stat-strong-size: 1.25rem;
|
||||
|
||||
strong {
|
||||
font-size: var(--stat-strong-size);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
height: var(--stat-strong-size);
|
||||
width: var(--stat-strong-size);
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 750px) {
|
||||
flex-direction: row;
|
||||
column-gap: var(--gap-md);
|
||||
margin-top: var(--gap-xs);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
margin-top: 0;
|
||||
|
||||
.stat-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+45
-54
@@ -11,51 +11,20 @@ import {
|
||||
injectNotificationManager,
|
||||
provideConsoleManager,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed, onUnmounted, ref, shallowRef, triggerRef, watch, watchEffect } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useInstanceConsole } from '@/composables/useInstanceConsole'
|
||||
import { log_listener, process_listener } from '@/helpers/events.js'
|
||||
import { delete_logs_by_filename, get_output_by_filename } from '@/helpers/logs.js'
|
||||
|
||||
import { injectInstancePage } from '../instance-context'
|
||||
import { instanceKeys } from '../query-options'
|
||||
|
||||
const client = injectModrinthClient()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
|
||||
const props = defineProps({
|
||||
instance: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {}
|
||||
},
|
||||
},
|
||||
offline: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
installed: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const instanceId = computed(() => route.params.id)
|
||||
const instancePage = injectInstancePage()
|
||||
const instanceId = instancePage.instanceId
|
||||
const {
|
||||
liveConsole,
|
||||
historicalConsole,
|
||||
@@ -66,7 +35,17 @@ const {
|
||||
clearLive,
|
||||
} = useInstanceConsole(instanceId.value)
|
||||
|
||||
await hydrate()
|
||||
const consoleHydrationQuery = useQuery({
|
||||
queryKey: computed(() => instanceKeys.console(instanceId.value)),
|
||||
queryFn: async () => {
|
||||
await hydrate()
|
||||
return true
|
||||
},
|
||||
staleTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
|
||||
await consoleHydrationQuery.suspense()
|
||||
|
||||
function buildLogList(rawLogs) {
|
||||
return [
|
||||
@@ -88,18 +67,29 @@ function buildLogList(rawLogs) {
|
||||
}
|
||||
|
||||
const logs = ref(buildLogList([]))
|
||||
|
||||
void getHistoricalLogs()
|
||||
.then((allLogs) => {
|
||||
logs.value = buildLogList(allLogs)
|
||||
})
|
||||
.catch(handleError)
|
||||
const historicalLogsQuery = useQuery({
|
||||
queryKey: computed(() => instanceKeys.logs(instanceId.value)),
|
||||
queryFn: getHistoricalLogs,
|
||||
staleTime: 0,
|
||||
})
|
||||
watch(
|
||||
historicalLogsQuery.data,
|
||||
(allLogs) => {
|
||||
if (allLogs) logs.value = buildLogList(allLogs)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
watch(historicalLogsQuery.error, (error) => {
|
||||
if (error) handleError(error)
|
||||
})
|
||||
|
||||
const selectedLogIndex = ref(0)
|
||||
const isLive = computed(() => selectedLogIndex.value === 0)
|
||||
|
||||
const filteredLogs = computed(() =>
|
||||
props.playing ? logs.value.filter((l) => l.live || l.name !== 'latest.log') : logs.value,
|
||||
instancePage.playing.value
|
||||
? logs.value.filter((l) => l.live || l.name !== 'latest.log')
|
||||
: logs.value,
|
||||
)
|
||||
|
||||
const logSources = computed(() =>
|
||||
@@ -140,16 +130,16 @@ const selectedLog = computed(() => filteredLogs.value[selectedLogIndex.value])
|
||||
const deleteDisabled = computed(() => {
|
||||
const log = selectedLog.value
|
||||
if (!log || log.live) return true
|
||||
return log.filename === 'latest.log' && props.playing
|
||||
return log.filename === 'latest.log' && instancePage.playing.value
|
||||
})
|
||||
|
||||
async function deleteSelectedLog() {
|
||||
const log = selectedLog.value
|
||||
if (!log || log.live) return
|
||||
await delete_logs_by_filename(props.instance.id, log.log_type, log.filename)
|
||||
await delete_logs_by_filename(instanceId.value, log.log_type, log.filename)
|
||||
invalidate()
|
||||
const freshLogs = await getHistoricalLogs()
|
||||
logs.value = buildLogList(freshLogs)
|
||||
const { data } = await historicalLogsQuery.refetch()
|
||||
if (data) logs.value = buildLogList(data)
|
||||
selectedLogIndex.value = 0
|
||||
}
|
||||
|
||||
@@ -166,7 +156,7 @@ provideConsoleManager({
|
||||
onDelete: deleteSelectedLog,
|
||||
deleteDisabled,
|
||||
deleteDisabledTooltip: 'Cannot delete latest.log while the instance is running',
|
||||
shareDisabled: computed(() => props.offline),
|
||||
shareDisabled: instancePage.offline,
|
||||
emptyStateType: 'instance',
|
||||
crashAnalysis,
|
||||
onDismissCrash: () => {
|
||||
@@ -186,7 +176,7 @@ watch(selectedLogIndex, async (newIndex) => {
|
||||
return
|
||||
}
|
||||
|
||||
const output = await get_output_by_filename(props.instance.id, log.log_type, log.filename).catch(
|
||||
const output = await get_output_by_filename(instanceId.value, log.log_type, log.filename).catch(
|
||||
handleError,
|
||||
)
|
||||
if (output) {
|
||||
@@ -197,7 +187,7 @@ watch(selectedLogIndex, async (newIndex) => {
|
||||
|
||||
selectedLogIndex.value = 0
|
||||
|
||||
if (!props.playing) {
|
||||
if (!instancePage.playing.value) {
|
||||
void analyseForCrash()
|
||||
}
|
||||
|
||||
@@ -216,12 +206,13 @@ const unlistenProcesses = await process_listener(async (e) => {
|
||||
if (e.event === 'launched') {
|
||||
liveConsole.clear()
|
||||
invalidate()
|
||||
void historicalLogsQuery.refetch()
|
||||
selectedLogIndex.value = 0
|
||||
}
|
||||
if (e.event === 'finished') {
|
||||
invalidate()
|
||||
const freshLogs = await getHistoricalLogs()
|
||||
logs.value = buildLogList(freshLogs)
|
||||
const { data } = await historicalLogsQuery.refetch()
|
||||
if (data) logs.value = buildLogList(data)
|
||||
void analyseForCrash()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { queryOptions } from '@tanstack/vue-query'
|
||||
|
||||
import { get_project_v3 } from '@/helpers/cache.js'
|
||||
import { get as getInstance } from '@/helpers/instance'
|
||||
import { loadInstanceContentData } from '@/helpers/instance-content'
|
||||
import { get_by_instance_id } from '@/helpers/process'
|
||||
import { refreshWorlds } from '@/helpers/worlds'
|
||||
|
||||
export const instanceKeys = {
|
||||
all: ['instances'] as const,
|
||||
detail: (instanceId: string) => [...instanceKeys.all, 'summary', instanceId] as const,
|
||||
processes: (instanceId: string) => [...instanceKeys.all, 'processes', instanceId] as const,
|
||||
content: (instanceId: string) => [...instanceKeys.all, 'content', instanceId] as const,
|
||||
rootPath: (instanceId: string) => [...instanceKeys.detail(instanceId), 'root-path'] as const,
|
||||
files: (instanceId: string, path: string) =>
|
||||
[...instanceKeys.detail(instanceId), 'files', path] as const,
|
||||
console: (instanceId: string) => [...instanceKeys.detail(instanceId), 'console'] as const,
|
||||
logs: (instanceId: string) => [...instanceKeys.detail(instanceId), 'logs'] as const,
|
||||
installedProjectIds: (instanceId: string, source: 'content' | 'worlds') =>
|
||||
[...instanceKeys.detail(instanceId), 'installed-project-ids', source] as const,
|
||||
linkedContent: (instanceId: string) => ['linkedModpackContent', instanceId] as const,
|
||||
worlds: (instanceId: string) => ['worlds', instanceId] as const,
|
||||
linkedProject: (projectId: string) => ['project', 'v3', projectId] as const,
|
||||
sharedEligibility: (userId: string | null | undefined) =>
|
||||
['shared-instance-eligibility', userId] as const,
|
||||
sharedUpdatePreview: (instanceId: string, userId: string | null | undefined) =>
|
||||
[...instanceKeys.detail(instanceId), 'shared-update-preview', userId] as const,
|
||||
sharedMembers: (instanceId: string) => ['sharedInstanceUsers', instanceId] as const,
|
||||
}
|
||||
|
||||
export function instanceDetailQueryOptions(instanceId: string) {
|
||||
return queryOptions({
|
||||
queryKey: instanceKeys.detail(instanceId),
|
||||
queryFn: async () => {
|
||||
const instance = await getInstance(instanceId)
|
||||
if (!instance) throw new Error(`Instance ${instanceId} is not managed`)
|
||||
return instance
|
||||
},
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function instanceProcessesQueryOptions(instanceId: string) {
|
||||
return queryOptions({
|
||||
queryKey: instanceKeys.processes(instanceId),
|
||||
queryFn: async () => {
|
||||
const processes = await get_by_instance_id(instanceId)
|
||||
return Array.isArray(processes) ? processes : []
|
||||
},
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
|
||||
export function instanceLinkedProjectQueryOptions(projectId: string) {
|
||||
return queryOptions({
|
||||
queryKey: instanceKeys.linkedProject(projectId),
|
||||
queryFn: () => get_project_v3(projectId, 'must_revalidate'),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function instanceContentQueryOptions(
|
||||
instanceId: string,
|
||||
onError?: (error: Error) => unknown,
|
||||
) {
|
||||
return queryOptions({
|
||||
queryKey: instanceKeys.content(instanceId),
|
||||
queryFn: () => loadInstanceContentData(instanceId, undefined, onError),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
export function instanceWorldsQueryOptions(instanceId: string) {
|
||||
return queryOptions({
|
||||
queryKey: instanceKeys.worlds(instanceId),
|
||||
queryFn: () => refreshWorlds(instanceId),
|
||||
staleTime: 0,
|
||||
})
|
||||
}
|
||||
@@ -55,20 +55,7 @@
|
||||
|
||||
<div v-else-if="membersTableLoading" class="h-64" aria-hidden="true" />
|
||||
|
||||
<SharedInstanceMembersTable
|
||||
v-else-if="showMembersTable"
|
||||
:rows="members.rows.value"
|
||||
:actions-locked="sharedInstanceActionsLocked"
|
||||
:invite-disabled="!hasRemainingUserSlots"
|
||||
:invite-pending="inviteLink.pending.value"
|
||||
:push-update-disabled="
|
||||
instance.install_stage !== 'installed' || publishState !== 'idle' || !!offline
|
||||
"
|
||||
:push-update-pending="publishState !== 'idle'"
|
||||
@invite="showInvitePlayers"
|
||||
@remove="showRemoveMemberModal"
|
||||
@push-update="reviewUpdate"
|
||||
/>
|
||||
<SharedInstanceMembersTable v-else-if="showMembersTable" />
|
||||
|
||||
<SharedInstanceShareEmptyState
|
||||
v-else-if="sharedInstanceUnavailable"
|
||||
@@ -156,8 +143,8 @@ import {
|
||||
type InvitePlayersUser,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, toRef, watch } from 'vue'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import ModrinthAccountRequiredModal from '@/components/ui/modal/ModrinthAccountRequiredModal.vue'
|
||||
import SharedInstancePublishModal from '@/components/ui/shared-instances/SharedInstancePublishModal.vue'
|
||||
@@ -166,16 +153,16 @@ import {
|
||||
isSharedInstancesApiError,
|
||||
isSharedInstanceUnavailableError,
|
||||
} from '@/helpers/install'
|
||||
import { can_current_user_use_shared_instances, edit } from '@/helpers/instance'
|
||||
import { edit } from '@/helpers/instance'
|
||||
import type { ModrinthAuthFlow } from '@/helpers/mr_auth.ts'
|
||||
import {
|
||||
sharedInstanceErrorMessages,
|
||||
useSharedInstanceErrors,
|
||||
} from '@/helpers/shared-instance-errors'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { provideInstanceBackup } from '@/providers/instance-backup'
|
||||
|
||||
import { injectSharedInstanceState } from '../use-shared-instance-state'
|
||||
import { injectInstancePage } from '../instance-context'
|
||||
import { injectSharedInstance } from '../shared-instance-context'
|
||||
import { provideSharedInstanceManagement } from './shared-instance-management-context'
|
||||
import SharedInstanceMembersTable from './shared-instance-members-table.vue'
|
||||
import SharedInstanceRemoveMemberModal from './shared-instance-remove-member-modal.vue'
|
||||
import SharedInstanceShareEmptyState from './shared-instance-share-empty-state.vue'
|
||||
@@ -184,10 +171,7 @@ import { useSharedInstanceInviteCandidates } from './use-shared-instance-invite-
|
||||
import { useSharedInstanceInviteLink } from './use-shared-instance-invite-link'
|
||||
import { useSharedInstanceMembers } from './use-shared-instance-members'
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
offline?: boolean
|
||||
}>()
|
||||
const instancePage = injectInstancePage()
|
||||
const auth = injectAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -196,8 +180,9 @@ const {
|
||||
notifySharedInstanceError,
|
||||
notifySharedInstanceUnavailable,
|
||||
} = useSharedInstanceErrors()
|
||||
const sharedInstanceState = injectSharedInstanceState()
|
||||
const instance = toRef(props, 'instance')
|
||||
const sharedInstanceState = injectSharedInstance()
|
||||
const instance = computed(() => instancePage.instance.value!)
|
||||
const offline = instancePage.offline
|
||||
const actionsLocked = sharedInstanceState.shareActionsLocked
|
||||
const sharedInstanceActionsLocked = actionsLocked
|
||||
const currentUserId = computed(() => auth.user.value?.id ?? null)
|
||||
@@ -224,16 +209,7 @@ function notifyOperationError(error: unknown) {
|
||||
}
|
||||
}
|
||||
|
||||
const eligibilityQuery = useQuery({
|
||||
queryKey: computed(() => ['shared-instance-eligibility', currentUserId.value]),
|
||||
queryFn: can_current_user_use_shared_instances,
|
||||
enabled: () => isSignedIn.value && !!currentUserId.value,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
const eligibilityQuery = sharedInstanceState.eligibilityQuery
|
||||
|
||||
const members = useSharedInstanceMembers({
|
||||
instance,
|
||||
@@ -257,7 +233,7 @@ const {
|
||||
actionsLocked,
|
||||
})
|
||||
const inviteLink = useSharedInstanceInviteLink(
|
||||
computed(() => props.instance.id),
|
||||
computed(() => instance.value.id),
|
||||
remainingUserSlots,
|
||||
notifyOperationError,
|
||||
)
|
||||
@@ -286,7 +262,7 @@ const unableToConnect = computed(
|
||||
const membersTableLoading = computed(
|
||||
() =>
|
||||
members.rows.value.length === 0 &&
|
||||
!!props.instance.shared_instance &&
|
||||
!!instance.value.shared_instance &&
|
||||
(members.query.data.value === undefined || members.query.isFetching.value) &&
|
||||
!sharedInstanceUnavailable.value &&
|
||||
!sharedInstanceActionsLocked.value,
|
||||
@@ -294,7 +270,7 @@ const membersTableLoading = computed(
|
||||
const showMembersTable = computed(
|
||||
() =>
|
||||
members.rows.value.length > 0 ||
|
||||
(!!props.instance.shared_instance &&
|
||||
(!!instance.value.shared_instance &&
|
||||
members.query.data.value !== undefined &&
|
||||
!members.query.isFetching.value &&
|
||||
!sharedInstanceUnavailable.value &&
|
||||
@@ -302,13 +278,13 @@ const showMembersTable = computed(
|
||||
)
|
||||
const requiresUnlink = computed(
|
||||
() =>
|
||||
props.instance.link?.type === 'imported_modpack' &&
|
||||
!props.instance.shared_instance &&
|
||||
instance.value.link?.type === 'imported_modpack' &&
|
||||
!instance.value.shared_instance &&
|
||||
!importedModpackUnlinked.value,
|
||||
)
|
||||
const importedModpackBackupTip = computed(() =>
|
||||
props.instance.link?.type === 'imported_modpack'
|
||||
? (props.instance.link.name ?? props.instance.link.filename ?? undefined)
|
||||
instance.value.link?.type === 'imported_modpack'
|
||||
? (instance.value.link.name ?? instance.value.link.filename ?? undefined)
|
||||
: undefined,
|
||||
)
|
||||
|
||||
@@ -395,9 +371,9 @@ async function showInvitePlayers(event?: MouseEvent) {
|
||||
}
|
||||
async function unlinkImportedModpack() {
|
||||
try {
|
||||
await edit(props.instance.id, { link: null as unknown as undefined })
|
||||
await edit(instance.value.id, { link: null as unknown as undefined })
|
||||
importedModpackUnlinked.value = true
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', props.instance.id] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
|
||||
if (await inviteLink.ensure()) invitePlayersModal.value?.show()
|
||||
} catch (error) {
|
||||
notifyOperationError(error)
|
||||
@@ -419,7 +395,7 @@ function userProfileLink(username: string) {
|
||||
return !username || username.includes('@') ? undefined : `/user/${encodeURIComponent(username)}`
|
||||
}
|
||||
async function requestAuth(flow: ModrinthAuthFlow) {
|
||||
await auth.requestSignIn(`/instance/${encodeURIComponent(props.instance.id)}/share`, flow, {
|
||||
await auth.requestSignIn(`/instance/${encodeURIComponent(instance.value.id)}/share`, flow, {
|
||||
showModal: false,
|
||||
})
|
||||
return !!auth.session_token.value
|
||||
@@ -428,6 +404,23 @@ function signInToShare(event?: MouseEvent) {
|
||||
void accountRequiredModal.value?.show(event)
|
||||
}
|
||||
|
||||
provideSharedInstanceManagement({
|
||||
rows: members.rows,
|
||||
actionsLocked: sharedInstanceActionsLocked,
|
||||
inviteDisabled: computed(() => !hasRemainingUserSlots.value),
|
||||
invitePending: inviteLink.pending,
|
||||
pushUpdateDisabled: computed(
|
||||
() =>
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
publishState.value !== 'idle' ||
|
||||
offline.value,
|
||||
),
|
||||
pushUpdatePending: computed(() => publishState.value !== 'idle'),
|
||||
invite: (event) => void showInvitePlayers(event),
|
||||
remove: showRemoveMemberModal,
|
||||
pushUpdate: reviewUpdate,
|
||||
})
|
||||
|
||||
watch(
|
||||
[eligibilityQuery.error, members.query.error],
|
||||
(errors) => {
|
||||
@@ -443,7 +436,7 @@ watch([eligibilityQuery.data, members.query.data], ([eligibility, memberRows]) =
|
||||
}
|
||||
})
|
||||
watch(
|
||||
() => props.instance.id,
|
||||
() => instance.value.id,
|
||||
() => {
|
||||
importedModpackUnlinked.value = false
|
||||
},
|
||||
@@ -455,6 +448,4 @@ watch(
|
||||
},
|
||||
{ immediate: true, flush: 'post' },
|
||||
)
|
||||
|
||||
provideInstanceBackup(() => props.instance)
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { ShareRow } from './shared-instance-share-types'
|
||||
|
||||
export interface SharedInstanceManagementContext {
|
||||
readonly rows: ComputedRef<ShareRow[]>
|
||||
readonly actionsLocked: Ref<boolean>
|
||||
readonly inviteDisabled: ComputedRef<boolean>
|
||||
readonly invitePending: Ref<boolean>
|
||||
readonly pushUpdateDisabled: ComputedRef<boolean>
|
||||
readonly pushUpdatePending: ComputedRef<boolean>
|
||||
invite: (event: MouseEvent) => void
|
||||
remove: (row: ShareRow) => void
|
||||
pushUpdate: (event: MouseEvent) => void
|
||||
}
|
||||
|
||||
export const [injectSharedInstanceManagement, provideSharedInstanceManagement] =
|
||||
createContext<SharedInstanceManagementContext>('InstanceSharePage')
|
||||
@@ -15,7 +15,7 @@
|
||||
<button
|
||||
class="flex !h-10 shrink-0 items-center gap-2 !border"
|
||||
:disabled="pushUpdateDisabled"
|
||||
@click="emit('push-update', $event)"
|
||||
@click="management.pushUpdate($event)"
|
||||
>
|
||||
<SpinnerIcon v-if="pushUpdatePending" class="animate-spin" aria-hidden="true" />
|
||||
<UploadIcon v-else aria-hidden="true" />
|
||||
@@ -26,7 +26,7 @@
|
||||
<button
|
||||
class="flex !h-10 shrink-0 items-center gap-2"
|
||||
:disabled="invitePending || inviteDisabled"
|
||||
@click="emit('invite', $event)"
|
||||
@click="management.invite($event)"
|
||||
>
|
||||
<SpinnerIcon v-if="invitePending" class="animate-spin" aria-hidden="true" />
|
||||
<UserPlusIcon v-else aria-hidden="true" />
|
||||
@@ -125,7 +125,7 @@
|
||||
v-tooltip="'Revoke access'"
|
||||
:aria-label="`Revoke access for ${row.username}`"
|
||||
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
|
||||
@click="emit('remove', row)"
|
||||
@click="management.remove(row)"
|
||||
>
|
||||
<XIcon aria-hidden="true" /></button
|
||||
></ButtonStyled>
|
||||
@@ -161,6 +161,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { injectSharedInstanceManagement } from './shared-instance-management-context'
|
||||
import {
|
||||
type MethodFilter,
|
||||
methodLabels,
|
||||
@@ -169,19 +170,15 @@ import {
|
||||
type ShareTableColumn,
|
||||
} from './shared-instance-share-types'
|
||||
|
||||
const props = defineProps<{
|
||||
rows: ShareRow[]
|
||||
actionsLocked?: boolean
|
||||
inviteDisabled?: boolean
|
||||
invitePending?: boolean
|
||||
pushUpdateDisabled?: boolean
|
||||
pushUpdatePending?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
invite: [event: MouseEvent]
|
||||
remove: [row: ShareRow]
|
||||
'push-update': [event: MouseEvent]
|
||||
}>()
|
||||
const management = injectSharedInstanceManagement()
|
||||
const {
|
||||
rows,
|
||||
actionsLocked,
|
||||
inviteDisabled,
|
||||
invitePending,
|
||||
pushUpdateDisabled,
|
||||
pushUpdatePending,
|
||||
} = management
|
||||
const search = ref('')
|
||||
const methodFilter = ref<MethodFilter>('all')
|
||||
const sortColumn = ref<string | undefined>('joined')
|
||||
@@ -194,7 +191,7 @@ const methodFilterOptions: Array<{ id: ShareMethod; label: string }> = [
|
||||
{ id: 'direct', label: methodLabels.direct },
|
||||
{ id: 'link', label: methodLabels.link },
|
||||
]
|
||||
const hasMultipleMethods = computed(() => new Set(props.rows.map((row) => row.method)).size > 1)
|
||||
const hasMultipleMethods = computed(() => new Set(rows.value.map((row) => row.method)).size > 1)
|
||||
const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
|
||||
const result: TableColumn<ShareTableColumn>[] = [
|
||||
{
|
||||
@@ -230,7 +227,7 @@ const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
|
||||
cellClass: 'whitespace-nowrap !px-2',
|
||||
},
|
||||
]
|
||||
if (!props.actionsLocked)
|
||||
if (!actionsLocked.value)
|
||||
result.push({
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
@@ -243,7 +240,7 @@ const columns = computed<TableColumn<ShareTableColumn>[]>(() => {
|
||||
})
|
||||
const filteredRows = computed(() => {
|
||||
const query = search.value.trim().toLowerCase()
|
||||
return props.rows.filter((row) => {
|
||||
return rows.value.filter((row) => {
|
||||
if (methodFilter.value !== 'all' && row.method !== methodFilter.value) return false
|
||||
if (!query) return true
|
||||
return [
|
||||
|
||||
@@ -12,13 +12,14 @@ import {
|
||||
} from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import { instanceKeys } from '../query-options'
|
||||
import {
|
||||
normalizeInviteKey,
|
||||
SHARED_INSTANCE_USER_LIMIT,
|
||||
type ShareRow,
|
||||
} from './shared-instance-share-types'
|
||||
|
||||
type MembersQueryKey = readonly ['sharedInstanceUsers', string]
|
||||
type MembersQueryKey = ReturnType<typeof instanceKeys.sharedMembers>
|
||||
|
||||
type OptimisticChange = {
|
||||
queryKey: MembersQueryKey
|
||||
@@ -48,7 +49,7 @@ export function useSharedInstanceMembers(options: {
|
||||
onError: (error: unknown) => void
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryKey = computed(() => ['sharedInstanceUsers', options.instance.value.id] as const)
|
||||
const queryKey = computed(() => instanceKeys.sharedMembers(options.instance.value.id))
|
||||
const invitingUserIds = new Set<string>()
|
||||
const removingUserIds = new Set<string>()
|
||||
const exclusiveMutationPending = ref(false)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { createContext, injectAuth } from '@modrinth/ui'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { useUserQuery } from '@/composables/users/use-user-query'
|
||||
import {
|
||||
getSharedInstanceUnavailableReason,
|
||||
install_get_shared_instance_update_preview,
|
||||
isSharedInstanceUnavailableError,
|
||||
type SharedInstanceUnavailableReason,
|
||||
} from '@/helpers/install'
|
||||
import { can_current_user_use_shared_instances } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import { instanceKeys } from './query-options'
|
||||
|
||||
export type SharedInstanceManager =
|
||||
| {
|
||||
type: 'user'
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
tintBy: string
|
||||
}
|
||||
| {
|
||||
type: 'server'
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
tintBy: string
|
||||
}
|
||||
|
||||
export function createSharedInstanceContext(
|
||||
instance: Ref<GameInstance | undefined>,
|
||||
offline: Ref<boolean>,
|
||||
notifyError: (error: unknown) => void,
|
||||
) {
|
||||
const auth = injectAuth()
|
||||
const queryClient = useQueryClient()
|
||||
const forcedUnavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
|
||||
|
||||
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
|
||||
const wrongAccount = computed(() => {
|
||||
if (auth.isReady && !auth.isReady.value) return false
|
||||
if (!expectedUserId.value) return false
|
||||
return auth.user.value?.id !== expectedUserId.value
|
||||
})
|
||||
const actionsLocked = computed(() => wrongAccount.value)
|
||||
const signedOut = computed(() => !auth.session_token.value)
|
||||
const managerUserId = computed(() => {
|
||||
const attachment = instance.value?.shared_instance
|
||||
if (!attachment) return null
|
||||
if (attachment.role === 'owner') {
|
||||
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
|
||||
}
|
||||
return attachment.manager_id ?? null
|
||||
})
|
||||
const managerUserQuery = useUserQuery(managerUserId)
|
||||
const manager = computed<SharedInstanceManager | null>(() => {
|
||||
const attachment = instance.value?.shared_instance
|
||||
if (!attachment) return null
|
||||
|
||||
if (attachment.server_manager_name) {
|
||||
return {
|
||||
type: 'server',
|
||||
name: attachment.server_manager_name,
|
||||
avatarUrl: attachment.server_manager_icon_url ?? undefined,
|
||||
tintBy: attachment.server_manager_name,
|
||||
}
|
||||
}
|
||||
|
||||
const user = managerUserQuery.data.value
|
||||
if (!user) return null
|
||||
return {
|
||||
type: 'user',
|
||||
name: user.username,
|
||||
avatarUrl: user.avatar_url ?? undefined,
|
||||
tintBy: user.id,
|
||||
}
|
||||
})
|
||||
const unavailableManager = computed(() => manager.value?.name ?? null)
|
||||
|
||||
const eligibilityQuery = useQuery({
|
||||
queryKey: computed(() => instanceKeys.sharedEligibility(auth.user.value?.id)),
|
||||
queryFn: can_current_user_use_shared_instances,
|
||||
enabled: () => !!auth.session_token.value && !!auth.user.value?.id,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
})
|
||||
const currentUserCanUseSharedInstances = computed(
|
||||
() => !auth.session_token.value || eligibilityQuery.data.value !== false,
|
||||
)
|
||||
|
||||
const updatePreviewQuery = useQuery({
|
||||
queryKey: computed(() =>
|
||||
instanceKeys.sharedUpdatePreview(instance.value?.id ?? '', auth.user.value?.id),
|
||||
),
|
||||
queryFn: () => install_get_shared_instance_update_preview(instance.value!.id),
|
||||
enabled: computed(
|
||||
() =>
|
||||
!!instance.value?.id &&
|
||||
!!instance.value.shared_instance &&
|
||||
!actionsLocked.value &&
|
||||
!offline.value &&
|
||||
(auth.isReady?.value ?? true) &&
|
||||
!!auth.session_token.value &&
|
||||
!!auth.user.value?.id,
|
||||
),
|
||||
retry: false,
|
||||
staleTime: 30_000,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
|
||||
watch(updatePreviewQuery.data, (preview) => {
|
||||
if (preview !== undefined) forcedUnavailableReason.value = null
|
||||
})
|
||||
watch(updatePreviewQuery.error, (error) => {
|
||||
if (!error) return
|
||||
if (isSharedInstanceUnavailableError(error)) {
|
||||
forcedUnavailableReason.value = getSharedInstanceUnavailableReason(error)
|
||||
} else {
|
||||
notifyError(error)
|
||||
}
|
||||
})
|
||||
|
||||
const unavailableReason = computed(() => forcedUnavailableReason.value)
|
||||
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
|
||||
const updatePreview = computed(() =>
|
||||
unavailableReason.value ? null : (updatePreviewQuery.data.value ?? null),
|
||||
)
|
||||
|
||||
watch(
|
||||
() => instance.value?.id,
|
||||
() => {
|
||||
forcedUnavailableReason.value = null
|
||||
},
|
||||
)
|
||||
|
||||
async function refreshAvailability() {
|
||||
forcedUnavailableReason.value = null
|
||||
if (!instance.value?.id) return
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: instanceKeys.sharedUpdatePreview(instance.value.id, auth.user.value?.id),
|
||||
})
|
||||
}
|
||||
|
||||
async function refreshUpdatePreview() {
|
||||
forcedUnavailableReason.value = null
|
||||
if (!instance.value?.id || !auth.user.value?.id) return null
|
||||
const result = await updatePreviewQuery.refetch({ throwOnError: true })
|
||||
return result.data ?? null
|
||||
}
|
||||
|
||||
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
|
||||
forcedUnavailableReason.value = reason
|
||||
}
|
||||
|
||||
return {
|
||||
actionsLocked,
|
||||
shareActionsLocked,
|
||||
unavailableReason,
|
||||
unavailableManager,
|
||||
manager,
|
||||
updatePreview,
|
||||
expectedUserId,
|
||||
wrongAccount,
|
||||
signedOut,
|
||||
eligibilityQuery,
|
||||
currentUserCanUseSharedInstances,
|
||||
refreshAvailability,
|
||||
refreshUpdatePreview,
|
||||
setUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
export type SharedInstanceContext = ReturnType<typeof createSharedInstanceContext>
|
||||
|
||||
export const [injectSharedInstance, provideSharedInstance] = createContext<SharedInstanceContext>(
|
||||
'InstancePage',
|
||||
'sharedInstance',
|
||||
)
|
||||
@@ -1,228 +0,0 @@
|
||||
import { injectAuth } from '@modrinth/ui'
|
||||
import { computed, inject, type InjectionKey, provide, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { useUserQuery } from '@/composables/users/use-user-query'
|
||||
import {
|
||||
getSharedInstanceUnavailableReason,
|
||||
install_get_shared_instance_update_preview,
|
||||
isSharedInstanceUnavailableError,
|
||||
type SharedInstanceUnavailableReason,
|
||||
} from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
export type SharedInstanceManager =
|
||||
| {
|
||||
type: 'user'
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
tintBy: string
|
||||
}
|
||||
| {
|
||||
type: 'server'
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
tintBy: string
|
||||
}
|
||||
|
||||
export function useSharedInstanceState(
|
||||
instance: Ref<GameInstance | undefined>,
|
||||
offline: Ref<boolean>,
|
||||
notifyError: (error: unknown) => void,
|
||||
) {
|
||||
const auth = injectAuth()
|
||||
const updatePreview =
|
||||
ref<Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>>(null)
|
||||
const updatePreviewLoaded = ref(false)
|
||||
const unavailableReason = ref<SharedInstanceUnavailableReason | null>(null)
|
||||
const availabilityCheckKey = ref<string | null>(null)
|
||||
const availabilityRefresh = ref(0)
|
||||
let availabilityRequestId = 0
|
||||
let availabilityRequest: {
|
||||
key: string
|
||||
promise: Promise<{
|
||||
preview: Awaited<ReturnType<typeof install_get_shared_instance_update_preview>>
|
||||
error: unknown | null
|
||||
}>
|
||||
} | null = null
|
||||
|
||||
const expectedUserId = computed(() => instance.value?.shared_instance?.linked_user_id ?? null)
|
||||
const wrongAccount = computed(() => {
|
||||
if (auth.isReady && !auth.isReady.value) return false
|
||||
if (!expectedUserId.value) return false
|
||||
return auth.user.value?.id !== expectedUserId.value
|
||||
})
|
||||
const actionsLocked = computed(() => wrongAccount.value)
|
||||
const shareActionsLocked = computed(() => actionsLocked.value || unavailableReason.value !== null)
|
||||
const signedOut = computed(() => !auth.session_token.value)
|
||||
const managerUserId = computed(() => {
|
||||
const attachment = instance.value?.shared_instance
|
||||
if (!attachment) return null
|
||||
if (attachment.role === 'owner') {
|
||||
return actionsLocked.value ? (attachment.linked_user_id ?? null) : null
|
||||
}
|
||||
return attachment.manager_id ?? null
|
||||
})
|
||||
const managerUserQuery = useUserQuery(managerUserId)
|
||||
const manager = computed<SharedInstanceManager | null>(() => {
|
||||
const attachment = instance.value?.shared_instance
|
||||
if (!attachment) return null
|
||||
|
||||
if (attachment.server_manager_name) {
|
||||
return {
|
||||
type: 'server',
|
||||
name: attachment.server_manager_name,
|
||||
avatarUrl: attachment.server_manager_icon_url ?? undefined,
|
||||
tintBy: attachment.server_manager_name,
|
||||
}
|
||||
}
|
||||
|
||||
const user = managerUserQuery.data.value
|
||||
if (!user) return null
|
||||
return {
|
||||
type: 'user',
|
||||
name: user.username,
|
||||
avatarUrl: user.avatar_url ?? undefined,
|
||||
tintBy: user.id,
|
||||
}
|
||||
})
|
||||
const unavailableManager = computed(() => manager.value?.name ?? null)
|
||||
|
||||
function reset() {
|
||||
availabilityRequestId++
|
||||
availabilityRequest = null
|
||||
availabilityCheckKey.value = null
|
||||
updatePreview.value = null
|
||||
updatePreviewLoaded.value = false
|
||||
unavailableReason.value = null
|
||||
}
|
||||
|
||||
function refreshAvailability() {
|
||||
availabilityCheckKey.value = null
|
||||
updatePreviewLoaded.value = false
|
||||
availabilityRefresh.value++
|
||||
}
|
||||
|
||||
function setUnavailable(reason: SharedInstanceUnavailableReason | null) {
|
||||
availabilityRequestId++
|
||||
availabilityRequest = null
|
||||
availabilityCheckKey.value = null
|
||||
updatePreview.value = null
|
||||
updatePreviewLoaded.value = false
|
||||
unavailableReason.value = reason
|
||||
}
|
||||
|
||||
async function checkAvailability(instanceId: string, key: string, throwError = false) {
|
||||
const requestId = ++availabilityRequestId
|
||||
let request = availabilityRequest
|
||||
if (!request || request.key !== key) {
|
||||
const promise = install_get_shared_instance_update_preview(instanceId).then(
|
||||
(preview) => ({ preview, error: null }),
|
||||
(error: unknown) => ({ preview: null, error }),
|
||||
)
|
||||
request = { key, promise }
|
||||
availabilityRequest = request
|
||||
void promise.finally(() => {
|
||||
if (availabilityRequest?.promise === promise) availabilityRequest = null
|
||||
})
|
||||
}
|
||||
|
||||
const result = await request.promise
|
||||
if (!isCurrentRequest(requestId, instanceId, key)) return null
|
||||
|
||||
if (result.error !== null) {
|
||||
updatePreviewLoaded.value = false
|
||||
if (isSharedInstanceUnavailableError(result.error)) {
|
||||
updatePreview.value = null
|
||||
unavailableReason.value = getSharedInstanceUnavailableReason(result.error)
|
||||
} else if (!throwError) {
|
||||
notifyError(result.error)
|
||||
}
|
||||
|
||||
if (throwError) throw result.error
|
||||
return null
|
||||
}
|
||||
|
||||
updatePreview.value = result.preview
|
||||
updatePreviewLoaded.value = true
|
||||
unavailableReason.value = null
|
||||
return result.preview
|
||||
}
|
||||
|
||||
async function refreshUpdatePreview() {
|
||||
const instanceId = instance.value?.id
|
||||
const userId = auth.user.value?.id
|
||||
if (!instanceId || !userId) return null
|
||||
|
||||
const key = `${instanceId}:${userId}`
|
||||
availabilityCheckKey.value = key
|
||||
return await checkAvailability(instanceId, key, true)
|
||||
}
|
||||
|
||||
function isCurrentRequest(requestId: number, instanceId: string, key: string) {
|
||||
return (
|
||||
requestId === availabilityRequestId &&
|
||||
instance.value?.id === instanceId &&
|
||||
availabilityCheckKey.value === key
|
||||
)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => ({
|
||||
refresh: availabilityRefresh.value,
|
||||
instanceId: instance.value?.id,
|
||||
role: instance.value?.shared_instance?.role,
|
||||
locked: actionsLocked.value,
|
||||
offline: offline.value,
|
||||
signedIn: !!auth.session_token.value,
|
||||
userId: auth.user.value?.id ?? null,
|
||||
authReady: auth.isReady?.value ?? true,
|
||||
}),
|
||||
async ({ instanceId, role, locked, offline, signedIn, userId, authReady }) => {
|
||||
if (!instanceId || !role || locked || offline || !authReady || !signedIn || !userId) {
|
||||
availabilityRequestId++
|
||||
availabilityRequest = null
|
||||
availabilityCheckKey.value = null
|
||||
updatePreview.value = null
|
||||
updatePreviewLoaded.value = false
|
||||
if (instanceId && role) unavailableReason.value = null
|
||||
return
|
||||
}
|
||||
|
||||
const key = `${instanceId}:${userId}`
|
||||
if (availabilityCheckKey.value === key) return
|
||||
availabilityCheckKey.value = key
|
||||
await checkAvailability(instanceId, key)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
return {
|
||||
actionsLocked,
|
||||
shareActionsLocked,
|
||||
unavailableReason,
|
||||
unavailableManager,
|
||||
manager,
|
||||
updatePreview,
|
||||
expectedUserId,
|
||||
wrongAccount,
|
||||
signedOut,
|
||||
reset,
|
||||
refreshAvailability,
|
||||
refreshUpdatePreview,
|
||||
setUnavailable,
|
||||
}
|
||||
}
|
||||
|
||||
export type SharedInstanceState = ReturnType<typeof useSharedInstanceState>
|
||||
|
||||
const sharedInstanceStateKey: InjectionKey<SharedInstanceState> = Symbol('shared-instance-state')
|
||||
|
||||
export function provideSharedInstanceState(state: SharedInstanceState) {
|
||||
provide(sharedInstanceStateKey, state)
|
||||
}
|
||||
|
||||
export function injectSharedInstanceState() {
|
||||
const state = inject(sharedInstanceStateKey)
|
||||
if (!state) throw new Error('Shared instance state has not been provided.')
|
||||
return state
|
||||
}
|
||||
+21
-43
@@ -42,12 +42,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<button class="!h-10 flex items-center gap-2" @click="instancePage.browseServers">
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
@@ -105,7 +100,7 @@
|
||||
:game-mode="world.type === 'singleplayer' ? GAME_MODES[world.game_mode] : undefined"
|
||||
:shortcut-instance-id="instance.id"
|
||||
@play="() => joinWorld(world)"
|
||||
@stop="() => emit('stop')"
|
||||
@stop="() => instancePage.stop('InstanceWorlds')"
|
||||
@refresh="() => refreshServer((world as ServerWorld).address)"
|
||||
@edit="
|
||||
() =>
|
||||
@@ -134,12 +129,7 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="
|
||||
router.push({ path: '/browse/server', query: { i: instance.id, from: 'worlds' } })
|
||||
"
|
||||
>
|
||||
<button class="!h-10 flex items-center gap-2" @click="instancePage.browseServers">
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseServers) }}</span>
|
||||
</button>
|
||||
@@ -166,9 +156,8 @@ import {
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { platform } from '@tauri-apps/plugin-os'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import type ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import AddServerModal from '@/components/ui/world/modal/AddServerModal.vue'
|
||||
import ConfirmRemoveWorldModal from '@/components/ui/world/modal/ConfirmRemoveWorldModal.vue'
|
||||
import EditServerModal from '@/components/ui/world/modal/EditServerModal.vue'
|
||||
@@ -178,7 +167,6 @@ import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project, get_project_v3 } from '@/helpers/cache.js'
|
||||
import { instance_listener } from '@/helpers/events'
|
||||
import { get_game_versions } from '@/helpers/tags'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { ensureManagedServerWorldExists, getServerAddress } from '@/helpers/worlds'
|
||||
import {
|
||||
delete_world,
|
||||
@@ -194,7 +182,6 @@ import {
|
||||
refreshServerData,
|
||||
refreshServers,
|
||||
refreshWorld,
|
||||
refreshWorlds,
|
||||
remove_server_from_instance,
|
||||
resolveManagedServerWorld,
|
||||
type ServerData,
|
||||
@@ -209,6 +196,9 @@ import {
|
||||
import { injectServerInstall } from '@/providers/server-install'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
|
||||
import { injectInstancePage } from '../instance-context'
|
||||
import { instanceKeys, instanceWorldsQueryOptions } from '../query-options'
|
||||
|
||||
const messages = defineMessages({
|
||||
searchWorldsPlaceholder: {
|
||||
id: 'app.instance.worlds.search-worlds-placeholder',
|
||||
@@ -256,7 +246,7 @@ const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { playServerProject } = injectServerInstall()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const instancePage = injectInstancePage()
|
||||
|
||||
const addServerModal = ref<InstanceType<typeof AddServerModal>>()
|
||||
const editServerModal = ref<InstanceType<typeof EditServerModal>>()
|
||||
@@ -265,25 +255,12 @@ const removeWorldModal = ref<InstanceType<typeof ConfirmRemoveWorldModal>>()
|
||||
|
||||
const worldToRemove = ref<World | null>(null)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: 'play', world: World): void
|
||||
(event: 'stop'): void
|
||||
}>()
|
||||
const instance = computed(() => instancePage.instance.value!)
|
||||
const playing = instancePage.playing
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
options: InstanceType<typeof ContextMenu> | null
|
||||
offline: boolean
|
||||
playing: boolean
|
||||
installed: boolean
|
||||
}>()
|
||||
|
||||
const instance = computed(() => props.instance)
|
||||
const playing = computed(() => props.playing)
|
||||
|
||||
function play(world: World) {
|
||||
if (props.instance.quarantined) return
|
||||
emit('play', world)
|
||||
function play() {
|
||||
if (instance.value.quarantined) return
|
||||
void instancePage.refreshPlayState()
|
||||
}
|
||||
|
||||
const selectedFilters = ref<string[]>([])
|
||||
@@ -319,11 +296,12 @@ const hadNoWorlds = ref(true)
|
||||
const startingInstance = ref(false)
|
||||
const worldPlaying = ref<World>()
|
||||
|
||||
const worldsQuery = useQuery({
|
||||
queryKey: computed(() => ['worlds', instance.value.id]),
|
||||
queryFn: () => refreshWorlds(instance.value.id),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const worldsQuery = useQuery(
|
||||
computed(() => ({
|
||||
...instanceWorldsQueryOptions(instancePage.instanceId.value),
|
||||
enabled: !!instancePage.instanceId.value,
|
||||
})),
|
||||
)
|
||||
|
||||
const worldsReadyPending = useReadyState(worldsQuery)
|
||||
|
||||
@@ -497,7 +475,7 @@ async function refreshAllWorlds() {
|
||||
}
|
||||
}
|
||||
|
||||
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.value.id] })
|
||||
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.value.id) })
|
||||
await refreshServers(
|
||||
worlds.value,
|
||||
serverData.value,
|
||||
@@ -592,7 +570,7 @@ async function joinWorld(world: World) {
|
||||
} else if (world.type === 'singleplayer') {
|
||||
await start_join_singleplayer_world(instance.value.id, world.path).catch(handleJoinError)
|
||||
}
|
||||
play(world)
|
||||
play()
|
||||
startingInstance.value = false
|
||||
}
|
||||
|
||||
@@ -141,7 +141,6 @@ export default new createRouter({
|
||||
path: '/instance/:id',
|
||||
name: 'Instance',
|
||||
component: Instance.Index,
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
path: 'worlds',
|
||||
@@ -155,22 +154,22 @@ export default new createRouter({
|
||||
},
|
||||
{
|
||||
path: '',
|
||||
name: 'Mods',
|
||||
component: Instance.Mods,
|
||||
name: 'InstanceContent',
|
||||
component: Instance.Content,
|
||||
},
|
||||
{
|
||||
path: 'projects/:type',
|
||||
name: 'ModsFilter',
|
||||
component: Instance.Mods,
|
||||
name: 'InstanceContentFilter',
|
||||
component: Instance.Content,
|
||||
},
|
||||
{
|
||||
path: 'files',
|
||||
name: 'Files',
|
||||
name: 'InstanceFiles',
|
||||
component: Instance.Files,
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'Logs',
|
||||
name: 'InstanceLogs',
|
||||
component: Instance.Logs,
|
||||
meta: {
|
||||
renderMode: 'fixed',
|
||||
|
||||
Reference in New Issue
Block a user