mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 18:14:49 +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:
@@ -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>
|
||||
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<StackedAdmonitions v-bind="$attrs" :items="stackItems" class="w-full">
|
||||
<template #item="{ item, dismissible }">
|
||||
<InstanceAdmonitionsSharedInstanceStale
|
||||
v-if="item.kind === 'shared-instance-stale'"
|
||||
:instance="instance"
|
||||
@published="emit('published')"
|
||||
/>
|
||||
<InstanceAdmonitionsSharedInstanceUpdateAvailable
|
||||
v-else-if="item.kind === 'shared-instance-update-available'"
|
||||
:instance-name="instance.name"
|
||||
@review="emit('review-update', $event)"
|
||||
/>
|
||||
<InstanceAdmonitionsSharedInstanceWrongAccount
|
||||
v-else-if="item.kind === 'shared-instance-wrong-account'"
|
||||
:expected-user-id="sharedInstanceExpectedUserId"
|
||||
:role="sharedInstanceRole"
|
||||
:signed-out="sharedInstanceSignedOut"
|
||||
/>
|
||||
<InstanceAdmonitionsSharedInstanceUnavailable
|
||||
v-else-if="item.kind === 'shared-instance-unavailable'"
|
||||
:reason="displayedSharedInstanceUnavailableReason"
|
||||
:manager="sharedInstanceUnavailableManager"
|
||||
:dismissible="dismissible"
|
||||
@dismiss="sharedInstanceUnavailableDismissed = true"
|
||||
@delete="emit('delete')"
|
||||
/>
|
||||
</template>
|
||||
</StackedAdmonitions>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { StackedAdmonitions } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
|
||||
import type { GameInstance } from '@/helpers/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,
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
sharedInstanceUnavailableReason?: SharedInstanceUnavailableReason | null
|
||||
sharedInstanceUnavailableManager?: string | null
|
||||
sharedInstanceWrongAccount?: boolean
|
||||
sharedInstanceExpectedUserId?: string | null
|
||||
sharedInstanceRole?: SharedInstanceRole | null
|
||||
sharedInstanceSignedOut?: boolean
|
||||
sharedInstanceUpdateAvailable?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
published: []
|
||||
delete: []
|
||||
'review-update': [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const sharedInstanceWrongAccount = computed(() => props.sharedInstanceWrongAccount ?? false)
|
||||
const displayedSharedInstanceUnavailableReason = computed<SharedInstanceUnavailableReason | null>(
|
||||
() =>
|
||||
props.instance.quarantined ? 'quarantined' : (props.sharedInstanceUnavailableReason ?? null),
|
||||
)
|
||||
const sharedInstanceUnavailableDismissed = ref(false)
|
||||
const showSharedInstancePublishAdmonition = computed(
|
||||
() =>
|
||||
!sharedInstanceWrongAccount.value &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
props.instance.shared_instance?.role === 'owner' &&
|
||||
props.instance.shared_instance.status === 'stale',
|
||||
)
|
||||
const showSharedInstanceUpdateAdmonition = computed(
|
||||
() =>
|
||||
!sharedInstanceWrongAccount.value &&
|
||||
!displayedSharedInstanceUnavailableReason.value &&
|
||||
props.instance.install_stage === 'installed' &&
|
||||
props.sharedInstanceRole === 'member' &&
|
||||
props.sharedInstanceUpdateAvailable === true,
|
||||
)
|
||||
|
||||
const stackItems = computed<InstanceAdmonitionItem[]>(() => {
|
||||
const items: InstanceAdmonitionItem[] = []
|
||||
|
||||
if (sharedInstanceWrongAccount.value) {
|
||||
items.push({
|
||||
id: 'shared-instance-wrong-account',
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
kind: 'shared-instance-wrong-account',
|
||||
})
|
||||
}
|
||||
|
||||
const unavailableReason = displayedSharedInstanceUnavailableReason.value
|
||||
const sharedInstanceQuarantined = unavailableReason === 'quarantined'
|
||||
if (
|
||||
unavailableReason &&
|
||||
(sharedInstanceQuarantined || !sharedInstanceUnavailableDismissed.value)
|
||||
) {
|
||||
items.push({
|
||||
id: 'shared-instance-unavailable',
|
||||
type: 'warning',
|
||||
dismissible: !sharedInstanceQuarantined,
|
||||
kind: 'shared-instance-unavailable',
|
||||
})
|
||||
}
|
||||
|
||||
if (showSharedInstancePublishAdmonition.value) {
|
||||
items.push({
|
||||
id: 'shared-instance-stale',
|
||||
type: 'warning',
|
||||
dismissible: false,
|
||||
kind: 'shared-instance-stale',
|
||||
})
|
||||
}
|
||||
|
||||
if (showSharedInstanceUpdateAdmonition.value) {
|
||||
items.push({
|
||||
id: 'shared-instance-update-available',
|
||||
type: 'info',
|
||||
dismissible: false,
|
||||
kind: 'shared-instance-update-available',
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [props.instance.id, displayedSharedInstanceUnavailableReason.value],
|
||||
() => {
|
||||
sharedInstanceUnavailableDismissed.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { defineMessages } from '@modrinth/ui'
|
||||
|
||||
export const instanceAdmonitionsMessages = defineMessages({
|
||||
sharedInstanceChangesHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.changes-header',
|
||||
defaultMessage: "Your changes haven't been shared yet",
|
||||
},
|
||||
sharedInstanceChangesBody: {
|
||||
id: 'app.instance.admonitions.shared-instance.changes-body',
|
||||
defaultMessage: "Your local instance is ahead of the users you've shared it with.",
|
||||
},
|
||||
sharedInstancePublishButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.publish-button',
|
||||
defaultMessage: 'Push update',
|
||||
},
|
||||
sharedInstancePublishingButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.publishing-button',
|
||||
defaultMessage: 'Pushing...',
|
||||
},
|
||||
sharedInstanceReviewingButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.reviewing-button',
|
||||
defaultMessage: 'Reviewing...',
|
||||
},
|
||||
sharedInstanceUpdateAvailableHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.update-available-header',
|
||||
defaultMessage: 'An update is available',
|
||||
},
|
||||
sharedInstanceUpdateAvailableBody: {
|
||||
id: 'app.instance.admonitions.shared-instance.update-available-body',
|
||||
defaultMessage:
|
||||
'An update is required to play {name}. Please update to latest version to launch the game.',
|
||||
},
|
||||
sharedInstanceReviewUpdateButton: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-update-button',
|
||||
defaultMessage: 'Review update',
|
||||
},
|
||||
sharedInstanceReviewHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-header',
|
||||
defaultMessage: 'Review changes',
|
||||
},
|
||||
sharedInstanceReviewAdmonitionHeader: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-admonition-header',
|
||||
defaultMessage: 'Push update',
|
||||
},
|
||||
sharedInstanceReviewDescription: {
|
||||
id: 'app.instance.admonitions.shared-instance.review-description',
|
||||
defaultMessage:
|
||||
'Review the content changes that will be shared with everyone using this instance.',
|
||||
},
|
||||
sharedInstanceAddedLabel: {
|
||||
id: 'app.instance.admonitions.shared-instance.added-label',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
sharedInstanceRemovedLabel: {
|
||||
id: 'app.instance.admonitions.shared-instance.removed-label',
|
||||
defaultMessage: 'Removed',
|
||||
},
|
||||
sharedInstanceWrongAccountHeader: {
|
||||
id: 'app.instance.shared-instance-wrong-account.warning-header',
|
||||
defaultMessage: 'You are using the wrong Modrinth account',
|
||||
},
|
||||
sharedInstanceSignedOutHeader: {
|
||||
id: 'app.instance.shared-instance-wrong-account.signed-out-header',
|
||||
defaultMessage: 'You need to sign in to Modrinth',
|
||||
},
|
||||
sharedInstanceWrongAccountSignInAs: {
|
||||
id: 'app.instance.shared-instance-wrong-account.sign-in-as-label',
|
||||
defaultMessage: 'Sign in as',
|
||||
},
|
||||
sharedInstanceWrongAccountUserBody: {
|
||||
id: 'app.instance.shared-instance-wrong-account.user-admonition-body-v2',
|
||||
defaultMessage: 'to receive updates for this shared instance.',
|
||||
},
|
||||
sharedInstanceWrongAccountOwnerBody: {
|
||||
id: 'app.instance.shared-instance-wrong-account.owner-admonition-body-v2',
|
||||
defaultMessage: "to manage this shared instance. You won't be able to push updates to users.",
|
||||
},
|
||||
sharedInstanceWrongAccountFallbackUsername: {
|
||||
id: 'app.instance.shared-instance-wrong-account.fallback-username',
|
||||
defaultMessage: 'the linked account',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<Admonition
|
||||
type="info"
|
||||
inline-actions
|
||||
:header="formatMessage(messages.sharedInstanceChangesHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.sharedInstanceChangesBody) }}
|
||||
<template #actions>
|
||||
<ButtonStyled color="blue">
|
||||
<button class="!h-10" :disabled="isPublishButtonDisabled" @click="reviewChanges">
|
||||
<SpinnerIcon
|
||||
v-if="isReviewingPublish || isPublishing"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<UploadIcon v-else aria-hidden="true" />
|
||||
{{
|
||||
isPublishing
|
||||
? formatMessage(messages.sharedInstancePublishingButton)
|
||||
: isReviewingPublish
|
||||
? formatMessage(messages.sharedInstanceReviewingButton)
|
||||
: formatMessage(messages.sharedInstancePublishButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
|
||||
<SharedInstancePublishModal
|
||||
ref="publishModal"
|
||||
:instance="instance"
|
||||
@published="emit('published')"
|
||||
@state-change="publishState = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon, UploadIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
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 './messages'
|
||||
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
published: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const publishModal = ref<InstanceType<typeof SharedInstancePublishModal>>()
|
||||
const publishState = ref<'idle' | 'reviewing' | 'publishing'>('idle')
|
||||
const isPublishing = computed(() => publishState.value === 'publishing')
|
||||
const isReviewingPublish = computed(() => publishState.value === 'reviewing')
|
||||
|
||||
const isPublishButtonDisabled = computed(() => isPublishing.value || isReviewingPublish.value)
|
||||
|
||||
function reviewChanges(e?: MouseEvent) {
|
||||
publishModal.value?.show(e)
|
||||
}
|
||||
</script>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<Admonition
|
||||
type="warning"
|
||||
:inline-actions="reason === 'quarantined'"
|
||||
:header="formatMessage(sharedInstanceUnavailableTitleMessage(reason ?? null))"
|
||||
:dismissible="dismissible"
|
||||
@dismiss="emit('dismiss')"
|
||||
>
|
||||
{{ formatSharedInstanceUnavailable(reason ?? null, manager) }}
|
||||
<template v-if="reason === 'quarantined'" #actions>
|
||||
<ButtonStyled color="orange">
|
||||
<button class="!h-10" @click="emit('delete')">
|
||||
<TrashIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.deleteInstance) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import type { SharedInstanceUnavailableReason } from '@/helpers/install'
|
||||
import {
|
||||
sharedInstanceUnavailableTitleMessage,
|
||||
useSharedInstanceErrors,
|
||||
} from '@/helpers/shared-instance-errors'
|
||||
|
||||
defineProps<{
|
||||
reason?: SharedInstanceUnavailableReason | null
|
||||
manager?: string | null
|
||||
dismissible?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
dismiss: []
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { formatSharedInstanceUnavailable } = useSharedInstanceErrors()
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteInstance: {
|
||||
id: 'instance.locked.delete-button',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<template>
|
||||
<Admonition
|
||||
type="info"
|
||||
inline-actions
|
||||
:header="formatMessage(messages.sharedInstanceUpdateAvailableHeader)"
|
||||
>
|
||||
{{ formatMessage(messages.sharedInstanceUpdateAvailableBody, { name: instanceName }) }}
|
||||
<template #actions>
|
||||
<ButtonStyled color="blue">
|
||||
<button class="!h-10" @click="emit('review', $event)">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.sharedInstanceReviewUpdateButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon } from '@modrinth/assets'
|
||||
import { Admonition, ButtonStyled, useVIntl } from '@modrinth/ui'
|
||||
|
||||
import { instanceAdmonitionsMessages as messages } from './messages'
|
||||
|
||||
defineProps<{
|
||||
instanceName: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
review: [event: MouseEvent]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
</script>
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
<template>
|
||||
<Admonition type="warning" :header="formatMessage(headerMessage)">
|
||||
<span class="flex flex-wrap items-center gap-x-1.5 gap-y-1">
|
||||
<span>{{ formatMessage(messages.sharedInstanceWrongAccountSignInAs) }}</span>
|
||||
<span
|
||||
v-if="sharedInstanceExpectedUser"
|
||||
class="inline-flex max-w-full min-w-0 items-center gap-1.5 align-middle font-semibold text-contrast"
|
||||
>
|
||||
<Avatar
|
||||
:src="sharedInstanceExpectedUser.avatarUrl"
|
||||
:alt="sharedInstanceExpectedUsername"
|
||||
:tint-by="sharedInstanceExpectedUser.tintBy"
|
||||
size="20px"
|
||||
circle
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceExpectedUsername }}</span>
|
||||
</span>
|
||||
<span v-else class="font-semibold">{{ sharedInstanceExpectedUsername }}</span>
|
||||
<span>{{ formatMessage(bodyMessage) }}</span>
|
||||
</span>
|
||||
</Admonition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Admonition, Avatar, useVIntl } from '@modrinth/ui'
|
||||
import { useQuery } from '@tanstack/vue-query'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { get_user } from '@/helpers/cache'
|
||||
|
||||
import { instanceAdmonitionsMessages as messages } from './messages'
|
||||
import type { SharedInstanceRole } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
expectedUserId?: string | null
|
||||
role?: SharedInstanceRole | null
|
||||
signedOut?: boolean
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const expectedUserId = computed(() => props.expectedUserId ?? null)
|
||||
const expectedUserQuery = useQuery({
|
||||
queryKey: computed(() => ['user', expectedUserId.value]),
|
||||
queryFn: async () => {
|
||||
if (!expectedUserId.value) return null
|
||||
|
||||
return await get_user(expectedUserId.value, 'bypass').catch(() => null)
|
||||
},
|
||||
enabled: () => !!expectedUserId.value,
|
||||
staleTime: 30_000,
|
||||
})
|
||||
const sharedInstanceExpectedUser = computed(() => {
|
||||
const user = expectedUserQuery.data.value
|
||||
if (!user) return null
|
||||
|
||||
return {
|
||||
username: user.username,
|
||||
avatarUrl: user.avatar_url ?? undefined,
|
||||
tintBy: user.id,
|
||||
}
|
||||
})
|
||||
const sharedInstanceExpectedUsername = computed(
|
||||
() =>
|
||||
sharedInstanceExpectedUser.value?.username ||
|
||||
formatMessage(messages.sharedInstanceWrongAccountFallbackUsername),
|
||||
)
|
||||
const headerMessage = computed(() =>
|
||||
props.signedOut
|
||||
? messages.sharedInstanceSignedOutHeader
|
||||
: messages.sharedInstanceWrongAccountHeader,
|
||||
)
|
||||
const bodyMessage = computed(() =>
|
||||
props.role === 'owner'
|
||||
? messages.sharedInstanceWrongAccountOwnerBody
|
||||
: messages.sharedInstanceWrongAccountUserBody,
|
||||
)
|
||||
</script>
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { StackedAdmonitionItem } from '@modrinth/ui'
|
||||
|
||||
export type InstanceAdmonitionKind =
|
||||
| 'shared-instance-stale'
|
||||
| 'shared-instance-update-available'
|
||||
| 'shared-instance-unavailable'
|
||||
| 'shared-instance-wrong-account'
|
||||
|
||||
export type InstanceAdmonitionItem = StackedAdmonitionItem & {
|
||||
kind: InstanceAdmonitionKind
|
||||
}
|
||||
|
||||
export type SharedInstanceRole = 'owner' | 'member'
|
||||
@@ -0,0 +1,409 @@
|
||||
<template>
|
||||
<PageHeader :title="instance.name">
|
||||
<template #leading>
|
||||
<Avatar :src="iconSrc" :alt="instance.name" size="64px" :tint-by="instance.id" />
|
||||
</template>
|
||||
|
||||
<template v-if="instance.shared_instance || instance.quarantined" #badges>
|
||||
<PageHeaderBadgeItem
|
||||
v-if="instance.quarantined"
|
||||
:icon="LockIcon"
|
||||
aria-label="Locked instance information"
|
||||
class="!border-orange !bg-highlight-orange !text-orange"
|
||||
>
|
||||
Locked
|
||||
</PageHeaderBadgeItem>
|
||||
<PageHeaderBadgeItem
|
||||
v-else
|
||||
:tooltip="sharedInstanceTooltip"
|
||||
aria-label="Shared instance information"
|
||||
class="!border-blue !bg-highlight-blue !text-blue"
|
||||
>
|
||||
Shared
|
||||
<UnknownIcon class="block size-4 shrink-0 text-current" aria-hidden="true" />
|
||||
</PageHeaderBadgeItem>
|
||||
</template>
|
||||
|
||||
<template #metadata>
|
||||
<div v-if="isServerInstance" class="flex flex-wrap items-center gap-2">
|
||||
<InstanceHeaderServerMetadata
|
||||
:loading-server-ping="loadingServerPing"
|
||||
:players-online="playersOnline"
|
||||
:status-online="statusOnline"
|
||||
:recent-plays="recentPlays"
|
||||
:ping="ping"
|
||||
:minecraft-server="minecraftServer"
|
||||
:linked-project-v3="linkedProjectV3"
|
||||
:instance-id="instance.id"
|
||||
/>
|
||||
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
|
||||
{{ sharedInstanceManagerLabel }}
|
||||
<Avatar
|
||||
:src="sharedInstanceManager.avatarUrl"
|
||||
:alt="sharedInstanceManager.name"
|
||||
:tint-by="sharedInstanceManager.tintBy"
|
||||
size="24px"
|
||||
:circle="sharedInstanceManager.type === 'user'"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
|
||||
</PageHeaderMetadataItem>
|
||||
</div>
|
||||
<PageHeaderMetadata v-else>
|
||||
<PageHeaderMetadataItem :icon="Gamepad2Icon" tooltip="Minecraft version">
|
||||
Minecraft {{ instance.game_version }}
|
||||
</PageHeaderMetadataItem>
|
||||
<PageHeaderMetadataItem
|
||||
v-if="sharedInstanceManager?.type !== 'user'"
|
||||
:icon="ServerLoaderIcon"
|
||||
:icon-props="{ loader: loaderDisplayName }"
|
||||
tooltip="Mod loader"
|
||||
>
|
||||
{{ loaderLabel }}
|
||||
</PageHeaderMetadataItem>
|
||||
<PageHeaderMetadataItem
|
||||
v-if="showInstancePlayTime"
|
||||
:icon="TimerIcon"
|
||||
tooltip="Total playtime"
|
||||
>
|
||||
{{ playtimeLabel }}
|
||||
</PageHeaderMetadataItem>
|
||||
<PageHeaderMetadataItem v-if="sharedInstanceManager" :action="sharedInstanceManagerAction">
|
||||
{{ sharedInstanceManagerLabel }}
|
||||
<Avatar
|
||||
:src="sharedInstanceManager.avatarUrl"
|
||||
:alt="sharedInstanceManager.name"
|
||||
:tint-by="sharedInstanceManager.tintBy"
|
||||
size="24px"
|
||||
:circle="sharedInstanceManager.type === 'user'"
|
||||
no-shadow
|
||||
/>
|
||||
<span class="min-w-0 truncate">{{ sharedInstanceManager.name }}</span>
|
||||
</PageHeaderMetadataItem>
|
||||
</PageHeaderMetadata>
|
||||
</template>
|
||||
|
||||
<template #actions>
|
||||
<PageHeaderActions>
|
||||
<ButtonStyled v-if="isInstalling" color="brand" size="large">
|
||||
<button type="button" disabled>
|
||||
{{ formatMessage(commonMessages.installingLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="playing" color="red" size="large">
|
||||
<button type="button" :disabled="stopping" @click="emit('stop')">
|
||||
<StopCircleIcon />
|
||||
{{
|
||||
stopping ? formatMessage(messages.stopping) : formatMessage(commonMessages.stopButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="instance.quarantined" color="brand" size="large">
|
||||
<button v-tooltip="formatMessage(messages.lockedPlayTooltip)" type="button" disabled>
|
||||
<PlayIcon />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else-if="instance.install_stage !== 'installed'" color="brand" size="large">
|
||||
<button type="button" @click="emit('repair')">
|
||||
<DownloadIcon />
|
||||
{{ formatMessage(messages.repair) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<JoinedButtons
|
||||
v-else-if="!loading && isServerInstance"
|
||||
:actions="serverPlayActions"
|
||||
color="brand"
|
||||
size="large"
|
||||
/>
|
||||
<ButtonStyled v-else-if="!loading" color="brand" size="large">
|
||||
<button type="button" @click="emit('play')">
|
||||
<PlayIcon />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else color="brand" size="large">
|
||||
<button type="button" disabled>{{ formatMessage(messages.starting) }}</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled circular size="large">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.instanceSettings)"
|
||||
type="button"
|
||||
:aria-label="formatMessage(messages.instanceSettings)"
|
||||
@click="emit('settings')"
|
||||
>
|
||||
<SettingsIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular size="large" type="transparent">
|
||||
<TeleportOverflowMenu
|
||||
:options="moreActions"
|
||||
:tooltip="formatMessage(messages.moreActions)"
|
||||
:aria-label="formatMessage(messages.moreActions)"
|
||||
>
|
||||
<MoreVerticalIcon />
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</PageHeaderActions>
|
||||
</template>
|
||||
</PageHeader>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
DownloadIcon,
|
||||
ExternalIcon,
|
||||
FolderOpenIcon,
|
||||
LockIcon,
|
||||
MoreVerticalIcon,
|
||||
PackageIcon,
|
||||
PlayIcon,
|
||||
ReportIcon,
|
||||
SettingsIcon,
|
||||
StopCircleIcon,
|
||||
TagCategoryGamepad2Icon as Gamepad2Icon,
|
||||
TimerIcon,
|
||||
UnknownIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
type JoinedButtonAction,
|
||||
JoinedButtons,
|
||||
LoaderIcon as ServerLoaderIcon,
|
||||
PageHeader,
|
||||
PageHeaderActions,
|
||||
PageHeaderBadgeItem,
|
||||
PageHeaderMetadata,
|
||||
PageHeaderMetadataItem,
|
||||
type ServerLoader,
|
||||
TeleportOverflowMenu,
|
||||
type TeleportOverflowMenuItem,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
import InstanceHeaderServerMetadata from './instance-page-header-server-metadata.vue'
|
||||
|
||||
const messages = defineMessages({
|
||||
createShortcut: {
|
||||
id: 'instance.action.create-shortcut',
|
||||
defaultMessage: 'Create shortcut',
|
||||
},
|
||||
exportModpack: {
|
||||
id: 'instance.action.export-modpack',
|
||||
defaultMessage: 'Export modpack',
|
||||
},
|
||||
instanceSettings: {
|
||||
id: 'instance.action.settings',
|
||||
defaultMessage: 'Instance settings',
|
||||
},
|
||||
launchInstance: {
|
||||
id: 'instance.action.launch-instance',
|
||||
defaultMessage: 'Launch instance',
|
||||
},
|
||||
moreActions: {
|
||||
id: 'instance.action.more-actions',
|
||||
defaultMessage: 'More actions',
|
||||
},
|
||||
neverPlayed: {
|
||||
id: 'instance.playtime.never-played',
|
||||
defaultMessage: 'Never played',
|
||||
},
|
||||
openFolder: {
|
||||
id: 'instance.action.open-folder',
|
||||
defaultMessage: 'Open folder',
|
||||
},
|
||||
repair: {
|
||||
id: 'instance.action.repair',
|
||||
defaultMessage: 'Repair',
|
||||
},
|
||||
lockedPlayTooltip: {
|
||||
id: 'instance.locked.play-tooltip',
|
||||
defaultMessage: 'This instance has been locked',
|
||||
},
|
||||
starting: {
|
||||
id: 'instance.action.starting',
|
||||
defaultMessage: 'Starting...',
|
||||
},
|
||||
stopping: {
|
||||
id: 'instance.action.stopping',
|
||||
defaultMessage: 'Stopping...',
|
||||
},
|
||||
sharedInstanceTooltip: {
|
||||
id: 'instance.shared-instance.tooltip',
|
||||
defaultMessage: "This instance's content is being managed by someone else.",
|
||||
},
|
||||
sharedInstanceOwnerTooltip: {
|
||||
id: 'instance.shared-instance.owner-tooltip',
|
||||
defaultMessage: "This instance's content is being shared to other users.",
|
||||
},
|
||||
})
|
||||
const router = useRouter()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
instance: GameInstance
|
||||
iconSrc?: string | null
|
||||
isServerInstance?: boolean
|
||||
showInstancePlayTime?: boolean
|
||||
timePlayed?: number
|
||||
playing?: boolean
|
||||
loading?: boolean
|
||||
stopping?: boolean
|
||||
loadingServerPing?: boolean
|
||||
playersOnline?: number
|
||||
statusOnline?: boolean
|
||||
recentPlays?: number
|
||||
ping?: number
|
||||
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
|
||||
linkedProjectV3?: Labrinth.Projects.v3.Project
|
||||
sharedInstanceManager?: {
|
||||
type: 'user' | 'server'
|
||||
name: string
|
||||
avatarUrl?: string
|
||||
tintBy: string
|
||||
} | null
|
||||
}>(),
|
||||
{
|
||||
iconSrc: null,
|
||||
isServerInstance: false,
|
||||
showInstancePlayTime: false,
|
||||
timePlayed: 0,
|
||||
playing: false,
|
||||
loading: false,
|
||||
stopping: false,
|
||||
loadingServerPing: false,
|
||||
playersOnline: undefined,
|
||||
statusOnline: false,
|
||||
recentPlays: undefined,
|
||||
ping: undefined,
|
||||
minecraftServer: undefined,
|
||||
linkedProjectV3: undefined,
|
||||
sharedInstanceManager: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
repair: []
|
||||
stop: []
|
||||
play: []
|
||||
playServer: []
|
||||
settings: []
|
||||
openFolder: []
|
||||
export: []
|
||||
createShortcut: []
|
||||
report: [event?: MouseEvent]
|
||||
}>()
|
||||
|
||||
const installingStages = [
|
||||
'installing',
|
||||
'pack_installing',
|
||||
'pack_installed',
|
||||
'not_installed',
|
||||
'minecraft_installing',
|
||||
]
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const isInstalling = computed(() => installingStages.includes(props.instance.install_stage))
|
||||
const loaderDisplayName = computed(() => formatLoaderLabel(props.instance.loader) as ServerLoader)
|
||||
const loaderLabel = computed(() =>
|
||||
[loaderDisplayName.value, props.instance.loader_version].filter(Boolean).join(' '),
|
||||
)
|
||||
const sharedInstanceTooltip = computed(() =>
|
||||
formatMessage(
|
||||
props.instance.shared_instance?.role === 'owner'
|
||||
? messages.sharedInstanceOwnerTooltip
|
||||
: messages.sharedInstanceTooltip,
|
||||
),
|
||||
)
|
||||
const sharedInstanceManagerLabel = computed(() =>
|
||||
props.sharedInstanceManager?.type === 'server' ? 'Linked to' : 'Managed by',
|
||||
)
|
||||
const sharedInstanceManagerAction = computed(() => {
|
||||
const manager = props.sharedInstanceManager
|
||||
if (manager?.type !== 'user') return undefined
|
||||
return () => router.push(`/user/${encodeURIComponent(manager.name)}`)
|
||||
})
|
||||
const playtimeLabel = computed(() => {
|
||||
if (props.timePlayed <= 0) return formatMessage(messages.neverPlayed)
|
||||
|
||||
const hours = Math.floor(props.timePlayed / 3600)
|
||||
if (hours >= 1) {
|
||||
return `${hours} hour${hours > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
const minutes = Math.floor(props.timePlayed / 60)
|
||||
if (minutes >= 1) {
|
||||
return `${minutes} minute${minutes > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
const seconds = Math.floor(props.timePlayed)
|
||||
return `${seconds} second${seconds > 1 ? 's' : ''}`
|
||||
})
|
||||
const serverPlayActions = computed<JoinedButtonAction[]>(() => [
|
||||
{
|
||||
id: 'join_server',
|
||||
label: formatMessage(commonMessages.playButton),
|
||||
icon: PlayIcon,
|
||||
action: () => emit('playServer'),
|
||||
},
|
||||
{
|
||||
id: 'launch_instance',
|
||||
label: formatMessage(messages.launchInstance),
|
||||
icon: PlayIcon,
|
||||
action: () => emit('play'),
|
||||
},
|
||||
])
|
||||
const moreActions = computed<TeleportOverflowMenuItem[]>(() => {
|
||||
const actions: TeleportOverflowMenuItem[] = [
|
||||
{
|
||||
id: 'open-folder',
|
||||
label: formatMessage(messages.openFolder),
|
||||
icon: FolderOpenIcon,
|
||||
action: () => emit('openFolder'),
|
||||
},
|
||||
]
|
||||
|
||||
if (!props.instance.quarantined) {
|
||||
actions.push(
|
||||
{
|
||||
id: 'export-mrpack',
|
||||
label: formatMessage(messages.exportModpack),
|
||||
icon: PackageIcon,
|
||||
action: () => emit('export'),
|
||||
},
|
||||
{
|
||||
id: 'create-shortcut',
|
||||
label: formatMessage(messages.createShortcut),
|
||||
icon: ExternalIcon,
|
||||
action: () => emit('createShortcut'),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (props.instance.shared_instance?.role === 'member') {
|
||||
actions.push(
|
||||
{ divider: true },
|
||||
{
|
||||
id: 'report-shared-instance',
|
||||
label: formatMessage(commonMessages.reportButton),
|
||||
icon: ReportIcon,
|
||||
color: 'red',
|
||||
action: (event) => emit('report', event),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return actions
|
||||
})
|
||||
</script>
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="flex items-center flex-wrap gap-2">
|
||||
<template v-if="loadingServerPing">
|
||||
<ServerOnlinePlayers
|
||||
v-if="playersOnline !== undefined"
|
||||
:online="playersOnline"
|
||||
:status-online="statusOnline"
|
||||
hide-label
|
||||
/>
|
||||
<ServerRecentPlays :recent-plays="recentPlays ?? 0" hide-label />
|
||||
<div
|
||||
v-if="
|
||||
(playersOnline !== undefined || recentPlays !== undefined) &&
|
||||
(minecraftServer?.region || ping)
|
||||
"
|
||||
class="w-1.5 h-1.5 rounded-full bg-surface-5"
|
||||
></div>
|
||||
<ServerPing v-if="ping" :ping="ping" />
|
||||
</template>
|
||||
|
||||
<ServerRegion v-if="minecraftServer?.region" :region="minecraftServer?.region" />
|
||||
|
||||
<div v-if="minecraftServer?.region || ping" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
|
||||
|
||||
<div v-if="linkedProjectV3" class="flex gap-1.5 items-center font-medium text-primary">
|
||||
Linked to
|
||||
<Avatar
|
||||
:src="linkedProjectV3.icon_url"
|
||||
:alt="linkedProjectV3.name"
|
||||
:tint-by="instanceId"
|
||||
size="24px"
|
||||
/>
|
||||
<router-link
|
||||
:to="`/project/${linkedProjectV3.slug ?? linkedProjectV3.id}`"
|
||||
class="hover:underline text-primary truncate"
|
||||
>
|
||||
{{ linkedProjectV3.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
Avatar,
|
||||
ServerOnlinePlayers,
|
||||
ServerPing,
|
||||
ServerRecentPlays,
|
||||
ServerRegion,
|
||||
} from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
loadingServerPing?: boolean
|
||||
playersOnline?: number
|
||||
statusOnline?: boolean
|
||||
recentPlays?: number
|
||||
ping?: number
|
||||
minecraftServer?: Labrinth.Projects.v3.Project['minecraft_server']
|
||||
linkedProjectV3?: Labrinth.Projects.v3.Project
|
||||
instanceId?: string
|
||||
}>()
|
||||
</script>
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="danger" max-width="500px">
|
||||
<Admonition type="critical" :header="formatMessage(messages.admonitionHeader)">
|
||||
<IntlFormatted :message-id="messages.admonitionBody" :values="{ code: inviteCode }">
|
||||
<template #monospace="{ children }">
|
||||
<code class="font-mono"><component :is="() => children" /></code>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</Admonition>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="confirm">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.revokeButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
IntlFormatted,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const inviteCode = ref('')
|
||||
|
||||
const emit = defineEmits<{
|
||||
revoke: [inviteCode: string]
|
||||
}>()
|
||||
|
||||
function show(code: string) {
|
||||
inviteCode.value = code
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
modal.value?.hide()
|
||||
emit('revoke', inviteCode.value)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'instance.settings.sharing.revoke-invite.header',
|
||||
defaultMessage: 'Revoke invite',
|
||||
},
|
||||
admonitionHeader: {
|
||||
id: 'instance.settings.sharing.revoke-invite.admonition-header',
|
||||
defaultMessage: 'This action cannot be undone',
|
||||
},
|
||||
admonitionBody: {
|
||||
id: 'instance.settings.sharing.revoke-invite.admonition-body',
|
||||
defaultMessage:
|
||||
'The invite link <monospace>{code}</monospace> will stop working immediately. People who already joined will keep access.',
|
||||
},
|
||||
revokeButton: {
|
||||
id: 'instance.settings.sharing.revoke-invite.confirm',
|
||||
defaultMessage: 'Revoke invite',
|
||||
},
|
||||
})
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,450 @@
|
||||
<script setup lang="ts">
|
||||
import { CopyIcon, EditIcon, PlusIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
Chips,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
OverflowMenu,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, edit_icon, list, remove } from '@/helpers/instance'
|
||||
|
||||
import type { GameInstance } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteConfirmModal = ref()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
type ReleaseChannel = GameInstance['update_channel']
|
||||
const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
|
||||
|
||||
const title = ref(instance.value.name)
|
||||
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
|
||||
const groups = ref([...instance.value.groups])
|
||||
const savingReleaseChannel = ref(false)
|
||||
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.update_channel)
|
||||
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
|
||||
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
|
||||
)
|
||||
|
||||
const newCategoryInput = ref('')
|
||||
|
||||
const installing = computed(() => instance.value.install_stage !== 'installed')
|
||||
|
||||
async function duplicateInstance() {
|
||||
await install_duplicate_instance(instance.value.id).catch(handleError)
|
||||
trackEvent('InstanceDuplicate', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
|
||||
const allInstances = ref((await list()) as GameInstance[])
|
||||
const availableGroups = computed(() => [
|
||||
...new Set([...allInstances.value.flatMap((instance) => instance.groups), ...groups.value]),
|
||||
])
|
||||
|
||||
function formatReleaseChannelLabel(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
case 'release':
|
||||
return formatMessage(messages.updateChannelRelease)
|
||||
case 'beta':
|
||||
return formatMessage(messages.updateChannelBeta)
|
||||
case 'alpha':
|
||||
return formatMessage(messages.updateChannelAlpha)
|
||||
}
|
||||
}
|
||||
|
||||
function formatReleaseChannelDescription(channel: ReleaseChannel) {
|
||||
switch (channel) {
|
||||
case 'release':
|
||||
return formatMessage(messages.updateChannelReleaseDescription)
|
||||
case 'beta':
|
||||
return formatMessage(messages.updateChannelBetaDescription)
|
||||
case 'alpha':
|
||||
return formatMessage(messages.updateChannelAlphaDescription)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [instance.value.id, instance.value.update_channel] as const,
|
||||
() => {
|
||||
if (!savingReleaseChannel.value) {
|
||||
selectedReleaseChannel.value = instance.value.update_channel
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
watch(selectedReleaseChannel, async (channel, previousChannel) => {
|
||||
const previousReleaseChannel = previousChannel ?? instance.value.update_channel
|
||||
if (channel === instance.value.update_channel) return
|
||||
|
||||
savingReleaseChannel.value = true
|
||||
const instanceId = instance.value.id
|
||||
await edit(instanceId, { update_channel: channel })
|
||||
.then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instanceId] }))
|
||||
.catch((error) => {
|
||||
selectedReleaseChannel.value = previousReleaseChannel
|
||||
handleError(error)
|
||||
})
|
||||
savingReleaseChannel.value = false
|
||||
})
|
||||
|
||||
async function resetIcon() {
|
||||
try {
|
||||
await edit_icon(instance.value.id, null)
|
||||
icon.value = undefined
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
async function setIcon() {
|
||||
const value = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'Image',
|
||||
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (!value) return
|
||||
|
||||
try {
|
||||
await edit_icon(instance.value.id, value)
|
||||
icon.value = value
|
||||
trackEvent('InstanceSetIcon')
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
const editInstanceObject = computed(() => ({
|
||||
name: title.value.trim().substring(0, 32) ?? 'Instance',
|
||||
groups: groups.value.map((x) => x.trim().substring(0, 32)).filter((x) => x.length > 0),
|
||||
}))
|
||||
|
||||
const toggleGroup = (group: string) => {
|
||||
if (groups.value.includes(group)) {
|
||||
groups.value = groups.value.filter((x) => x !== group)
|
||||
} else {
|
||||
groups.value.push(group)
|
||||
}
|
||||
}
|
||||
|
||||
const addCategory = () => {
|
||||
const text = newCategoryInput.value.trim()
|
||||
|
||||
if (text.length > 0) {
|
||||
groups.value.push(text.substring(0, 32))
|
||||
newCategoryInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
[title, groups, groups],
|
||||
async () => {
|
||||
if (removing.value) return
|
||||
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const removing = ref(false)
|
||||
async function removeInstance() {
|
||||
removing.value = true
|
||||
const path = instance.value.id
|
||||
|
||||
trackEvent('InstanceRemove', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
|
||||
await router.push({ path: '/' })
|
||||
await remove(path).catch(handleError)
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
name: {
|
||||
id: 'instance.settings.tabs.general.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
libraryGroups: {
|
||||
id: 'instance.settings.tabs.general.library-groups',
|
||||
defaultMessage: 'Library groups',
|
||||
},
|
||||
libraryGroupsDescription: {
|
||||
id: 'instance.settings.tabs.general.library-groups.description',
|
||||
defaultMessage:
|
||||
'Library groups allow you to organize your instances into different sections in your library.',
|
||||
},
|
||||
libraryGroupsEnterName: {
|
||||
id: 'instance.settings.tabs.general.library-groups.enter-name',
|
||||
defaultMessage: 'Enter group name',
|
||||
},
|
||||
libraryGroupsCreate: {
|
||||
id: 'instance.settings.tabs.general.library-groups.create',
|
||||
defaultMessage: 'Create new group',
|
||||
},
|
||||
editIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.select',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
replaceIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.replace',
|
||||
defaultMessage: 'Replace icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.remove',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
duplicateInstance: {
|
||||
id: 'instance.settings.tabs.general.duplicate-instance',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
},
|
||||
duplicateInstanceDescription: {
|
||||
id: 'instance.settings.tabs.general.duplicate-instance.description',
|
||||
defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.',
|
||||
},
|
||||
duplicateButtonTooltipInstalling: {
|
||||
id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing',
|
||||
defaultMessage: 'Cannot duplicate while installing.',
|
||||
},
|
||||
duplicateButton: {
|
||||
id: 'instance.settings.tabs.general.duplicate-button',
|
||||
defaultMessage: 'Duplicate',
|
||||
},
|
||||
updateChannel: {
|
||||
id: 'instance.settings.tabs.general.update-channel',
|
||||
defaultMessage: 'Update channel',
|
||||
},
|
||||
updateChannelReleaseDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.release.description',
|
||||
defaultMessage: 'Only release versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelBetaDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.beta.description',
|
||||
defaultMessage: 'Release and beta versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelAlphaDescription: {
|
||||
id: 'instance.settings.tabs.general.update-channel.alpha.description',
|
||||
defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.',
|
||||
},
|
||||
updateChannelRelease: {
|
||||
id: 'instance.settings.tabs.general.update-channel.release',
|
||||
defaultMessage: 'Release',
|
||||
},
|
||||
updateChannelBeta: {
|
||||
id: 'instance.settings.tabs.general.update-channel.beta',
|
||||
defaultMessage: 'Beta',
|
||||
},
|
||||
updateChannelAlpha: {
|
||||
id: 'instance.settings.tabs.general.update-channel.alpha',
|
||||
defaultMessage: 'Alpha',
|
||||
},
|
||||
selectUpdateChannelAriaLabel: {
|
||||
id: 'instance.settings.tabs.general.update-channel.select',
|
||||
defaultMessage: 'Select update channel',
|
||||
},
|
||||
deleteInstance: {
|
||||
id: 'instance.settings.tabs.general.delete',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
deleteInstanceDescription: {
|
||||
id: 'instance.settings.tabs.general.delete.description',
|
||||
defaultMessage:
|
||||
'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.',
|
||||
},
|
||||
deleteInstanceButton: {
|
||||
id: 'instance.settings.tabs.general.delete.button',
|
||||
defaultMessage: 'Delete instance',
|
||||
},
|
||||
deletingInstanceButton: {
|
||||
id: 'instance.settings.tabs.general.deleting.button',
|
||||
defaultMessage: 'Deleting...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ConfirmDeleteInstanceModal ref="deleteConfirmModal" @delete="removeInstance" />
|
||||
<div class="block">
|
||||
<div class="float-end ml-10 relative group w-fit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-lg font-semibold text-contrast">Icon</span>
|
||||
<div class="group relative w-fit">
|
||||
<OverflowMenu
|
||||
v-tooltip="formatMessage(messages.editIcon)"
|
||||
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
|
||||
:options="[
|
||||
{
|
||||
id: 'select',
|
||||
action: () => setIcon(),
|
||||
},
|
||||
{
|
||||
id: 'remove',
|
||||
color: 'danger',
|
||||
action: () => resetIcon(),
|
||||
shown: !!icon,
|
||||
},
|
||||
]"
|
||||
>
|
||||
<Avatar
|
||||
:src="icon ? convertFileSrc(icon) : icon"
|
||||
size="108px"
|
||||
class="transition-[filter] group-hover:brightness-75"
|
||||
:tint-by="instance.id"
|
||||
no-shadow
|
||||
/>
|
||||
<div
|
||||
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
|
||||
>
|
||||
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
|
||||
</div>
|
||||
<template #select>
|
||||
<UploadIcon />
|
||||
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
|
||||
</template>
|
||||
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
|
||||
</OverflowMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="instance-name" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</label>
|
||||
<div class="flex">
|
||||
<StyledInput
|
||||
id="instance-name"
|
||||
v-model="title"
|
||||
autocomplete="off"
|
||||
:maxlength="80"
|
||||
wrapper-class="flex-grow"
|
||||
/>
|
||||
</div>
|
||||
<template v-if="instance.install_stage == 'installed'">
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.duplicateInstance) }}
|
||||
</h2>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
|
||||
aria-labelledby="duplicate-instance-label"
|
||||
:disabled="installing"
|
||||
class="w-max !shadow-none"
|
||||
@click="duplicateInstance"
|
||||
>
|
||||
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.duplicateInstanceDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.libraryGroups) }}
|
||||
</h2>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<Checkbox
|
||||
v-for="group in availableGroups"
|
||||
:key="group"
|
||||
:model-value="groups.includes(group)"
|
||||
:label="group"
|
||||
@click="toggleGroup(group)"
|
||||
/>
|
||||
<div class="flex gap-2 items-center">
|
||||
<StyledInput
|
||||
v-model="newCategoryInput"
|
||||
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
|
||||
class="w-full max-w-[300px]"
|
||||
@submit="() => addCategory"
|
||||
/>
|
||||
<ButtonStyled>
|
||||
<button class="w-fit !shadow-none" @click="() => addCategory()">
|
||||
<PlusIcon /> {{ formatMessage(messages.libraryGroupsCreate) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.libraryGroupsDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.updateChannel) }}
|
||||
</h2>
|
||||
<Chips
|
||||
v-model="selectedReleaseChannel"
|
||||
:items="releaseChannelOptions"
|
||||
:format-label="formatReleaseChannelLabel"
|
||||
:capitalize="false"
|
||||
:disabled-items="releaseChannelDisabledItems"
|
||||
:aria-label="formatMessage(messages.selectUpdateChannelAriaLabel)"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatReleaseChannelDescription(selectedReleaseChannel) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 mt-6">
|
||||
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
|
||||
{{ formatMessage(messages.deleteInstance) }}
|
||||
</h2>
|
||||
<ButtonStyled color="red">
|
||||
<button
|
||||
aria-labelledby="delete-instance-label"
|
||||
:disabled="removing"
|
||||
class="w-fit !shadow-none"
|
||||
@click="deleteConfirmModal.show()"
|
||||
>
|
||||
<SpinnerIcon v-if="removing" class="animate-spin" />
|
||||
<TrashIcon v-else />
|
||||
{{
|
||||
removing
|
||||
? formatMessage(messages.deletingInstanceButton)
|
||||
: formatMessage(messages.deleteInstanceButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.deleteInstanceDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
.hovering-icon-shadow {
|
||||
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
|
||||
import type { AppSettings, Hooks } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as AppSettings
|
||||
|
||||
const overrideHooks = ref(
|
||||
!!instance.value.hooks.pre_launch ||
|
||||
!!instance.value.hooks.wrapper ||
|
||||
!!instance.value.hooks.post_exit,
|
||||
)
|
||||
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
const editInstancePatch: {
|
||||
hooks?: Hooks
|
||||
} = {}
|
||||
|
||||
// When hooks are not overridden per-instance, we want to clear them
|
||||
editInstancePatch.hooks = overrideHooks.value ? hooks.value : {}
|
||||
|
||||
return editInstancePatch
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideHooks, hooks],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
const messages = defineMessages({
|
||||
hooks: {
|
||||
id: 'instance.settings.tabs.hooks.title',
|
||||
defaultMessage: 'Game launch hooks',
|
||||
},
|
||||
hooksDescription: {
|
||||
id: 'instance.settings.tabs.hooks.description',
|
||||
defaultMessage:
|
||||
'Hooks allow advanced users to run certain system commands before and after launching the game.',
|
||||
},
|
||||
customHooks: {
|
||||
id: 'instance.settings.tabs.hooks.custom-hooks',
|
||||
defaultMessage: 'Custom launch hooks',
|
||||
},
|
||||
preLaunch: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch',
|
||||
defaultMessage: 'Pre-launch',
|
||||
},
|
||||
preLaunchDescription: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch.description',
|
||||
defaultMessage: 'Ran before the instance is launched.',
|
||||
},
|
||||
preLaunchEnter: {
|
||||
id: 'instance.settings.tabs.hooks.pre-launch.enter',
|
||||
defaultMessage: 'Enter pre-launch command...',
|
||||
},
|
||||
wrapper: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper',
|
||||
defaultMessage: 'Wrapper',
|
||||
},
|
||||
wrapperDescription: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper.description',
|
||||
defaultMessage: 'Wrapper command for launching Minecraft.',
|
||||
},
|
||||
wrapperEnter: {
|
||||
id: 'instance.settings.tabs.hooks.wrapper.enter',
|
||||
defaultMessage: 'Enter wrapper command...',
|
||||
},
|
||||
postExit: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit',
|
||||
defaultMessage: 'Post-exit',
|
||||
},
|
||||
postExitDescription: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit.description',
|
||||
defaultMessage: 'Ran after the game closes.',
|
||||
},
|
||||
postExitEnter: {
|
||||
id: 'instance.settings.tabs.hooks.post-exit.enter',
|
||||
defaultMessage: 'Enter post-exit command...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h2 class="m-0 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.hooks) }}
|
||||
</h2>
|
||||
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.hooksDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.preLaunch) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="pre-launch"
|
||||
v-model="hooks.pre_launch"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.preLaunchEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.preLaunchDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.wrapper) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="wrapper"
|
||||
v-model="hooks.wrapper"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.wrapperEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.wrapperDescription) }}
|
||||
</p>
|
||||
|
||||
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.postExit) }}
|
||||
</h2>
|
||||
<StyledInput
|
||||
id="post-exit"
|
||||
v-model="hooks.post_exit"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideHooks"
|
||||
:placeholder="formatMessage(messages.postExitEnter)"
|
||||
wrapper-class="w-full my-2.5"
|
||||
/>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.postExitDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CodeIcon,
|
||||
CoffeeIcon,
|
||||
InfoIcon,
|
||||
MonitorIcon,
|
||||
UsersIcon,
|
||||
WrenchIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
Avatar,
|
||||
commonMessages,
|
||||
defineMessage,
|
||||
TabbedModal,
|
||||
type TabbedModalTab,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { convertFileSrc } from '@tauri-apps/api/core'
|
||||
import { computed, nextTick, ref, watch } from '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 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()
|
||||
|
||||
const props = defineProps<{
|
||||
instance: GameInstance
|
||||
offline?: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
unlinked: []
|
||||
}>()
|
||||
|
||||
const isMinecraftServer = ref(false)
|
||||
const handleUnlinked = () => emit('unlinked')
|
||||
|
||||
const instanceRef = computed(() => props.instance)
|
||||
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
|
||||
|
||||
function hide() {
|
||||
tabbedModal.value?.hide()
|
||||
}
|
||||
|
||||
provideInstanceSettings({
|
||||
instance: instanceRef,
|
||||
offline: props.offline,
|
||||
isMinecraftServer,
|
||||
onUnlinked: handleUnlinked,
|
||||
closeModal: hide,
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.instance,
|
||||
(instance) => {
|
||||
isMinecraftServer.value = false
|
||||
if (instance.link?.project_id) {
|
||||
get_project_v3(instance.link.project_id, 'must_revalidate')
|
||||
.then((project: Labrinth.Projects.v3.Project | undefined) => {
|
||||
if (project?.minecraft_server != null) {
|
||||
isMinecraftServer.value = true
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const tabs = computed<TabbedModalTab[]>(() => [
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.general',
|
||||
defaultMessage: 'General',
|
||||
}),
|
||||
icon: InfoIcon,
|
||||
content: GeneralSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.installation',
|
||||
defaultMessage: 'Installation',
|
||||
}),
|
||||
icon: WrenchIcon,
|
||||
content: InstallationSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.sharing',
|
||||
defaultMessage: 'Sharing',
|
||||
}),
|
||||
icon: UsersIcon,
|
||||
content: SharingSettings,
|
||||
shown: props.instance.shared_instance?.role === 'owner' && !props.instance.quarantined,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.window',
|
||||
defaultMessage: 'Window',
|
||||
}),
|
||||
icon: MonitorIcon,
|
||||
content: WindowSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.java',
|
||||
defaultMessage: 'Java and memory',
|
||||
}),
|
||||
icon: CoffeeIcon,
|
||||
content: JavaSettings,
|
||||
},
|
||||
{
|
||||
name: defineMessage({
|
||||
id: 'instance.settings.tabs.hooks',
|
||||
defaultMessage: 'Launch hooks',
|
||||
}),
|
||||
icon: CodeIcon,
|
||||
content: HooksSettings,
|
||||
},
|
||||
])
|
||||
|
||||
function getSupportedModpackLoaders() {
|
||||
return get_loaders().then((value: PlatformTag[]) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
|
||||
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
|
||||
)
|
||||
}
|
||||
|
||||
// Preload
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'fabric'],
|
||||
queryFn: () => get_loader_versions('fabric'),
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'forge'],
|
||||
queryFn: () => get_loader_versions('forge'),
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'quilt'],
|
||||
queryFn: () => get_loader_versions('quilt'),
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'neo'],
|
||||
queryFn: () => get_loader_versions('neo'),
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'game-versions'],
|
||||
queryFn: get_game_versions,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: ['instance-settings', 'loaders', 'modpack'],
|
||||
queryFn: getSupportedModpackLoaders,
|
||||
})
|
||||
useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', props.instance.id]),
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
enabled: computed(() => !!props.instance.link?.project_id && !props.offline),
|
||||
})
|
||||
|
||||
function show(tabIndex?: number) {
|
||||
if (props.instance.link?.project_id) {
|
||||
queryClient.prefetchQuery({
|
||||
queryKey: ['linkedModpackInfo', props.instance.id],
|
||||
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
|
||||
})
|
||||
}
|
||||
tabbedModal.value?.show()
|
||||
if (tabIndex !== undefined) {
|
||||
nextTick(() => tabbedModal.value?.setTab(tabIndex))
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<TabbedModal
|
||||
ref="tabbedModal"
|
||||
:tabs="tabs"
|
||||
:max-width="'min(928px, calc(95vw - 10rem))'"
|
||||
:width="'min(928px, calc(95vw - 10rem))'"
|
||||
>
|
||||
<template #title>
|
||||
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
|
||||
<Avatar
|
||||
:src="instance.icon_path ? convertFileSrc(instance.icon_path) : undefined"
|
||||
size="24px"
|
||||
:tint-by="props.instance.id"
|
||||
/>
|
||||
{{ instance.name }} <ChevronRightIcon />
|
||||
<span class="font-extrabold text-contrast">{{
|
||||
formatMessage(commonMessages.settingsLabel)
|
||||
}}</span>
|
||||
</span>
|
||||
</template>
|
||||
</TabbedModal>
|
||||
</template>
|
||||
+499
@@ -0,0 +1,499 @@
|
||||
<script setup lang="ts">
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import {
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
formatLoaderLabel,
|
||||
injectFilePicker,
|
||||
injectNotificationManager,
|
||||
InstallationSettingsLayout,
|
||||
provideInstallationSettings,
|
||||
useDebugLogger,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
|
||||
import { useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_versions, get_version } from '@/helpers/cache'
|
||||
import {
|
||||
install_existing_instance,
|
||||
install_pack_to_existing_instance,
|
||||
wait_for_install_job,
|
||||
} from '@/helpers/install'
|
||||
import {
|
||||
edit,
|
||||
get_linked_modpack_info,
|
||||
unlink_shared_instance,
|
||||
update_managed_modrinth_version,
|
||||
update_repair_modrinth,
|
||||
} from '@/helpers/instance'
|
||||
import { get_loader_versions } from '@/helpers/metadata'
|
||||
import { get_game_versions, get_loaders } from '@/helpers/tags'
|
||||
import { provideInstanceBackup } from '@/providers/instance-backup'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
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()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const debug = useDebugLogger('AppInstallationSettings')
|
||||
const themeStore = useTheming()
|
||||
|
||||
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
|
||||
const managedContentPolicy = useManagedContentPolicy(instance)
|
||||
const skipNonEssentialWarnings = computed(() =>
|
||||
themeStore.getFeatureFlag('skip_non_essential_warnings'),
|
||||
)
|
||||
|
||||
debug('metadata load: start', {
|
||||
instanceId: instance.value.id,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
installStage: instance.value.install_stage,
|
||||
})
|
||||
|
||||
function getSupportedModpackLoaders() {
|
||||
return get_loaders().then((value: PlatformTag[]) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
|
||||
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
|
||||
)
|
||||
}
|
||||
|
||||
const fabricVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'fabric'],
|
||||
queryFn: () => get_loader_versions('fabric') as Promise<Manifest>,
|
||||
})
|
||||
const forgeVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'forge'],
|
||||
queryFn: () => get_loader_versions('forge') as Promise<Manifest>,
|
||||
})
|
||||
const quiltVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'quilt'],
|
||||
queryFn: () => get_loader_versions('quilt') as Promise<Manifest>,
|
||||
})
|
||||
const neoforgeVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loader-versions', 'neo'],
|
||||
queryFn: () => get_loader_versions('neo') as Promise<Manifest>,
|
||||
})
|
||||
const gameVersionsQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'game-versions'],
|
||||
queryFn: () => get_game_versions() as Promise<GameVersionTag[]>,
|
||||
})
|
||||
const loadersQuery = useQuery({
|
||||
queryKey: ['instance-settings', 'loaders', 'modpack'],
|
||||
queryFn: getSupportedModpackLoaders,
|
||||
})
|
||||
|
||||
const metadataLoading = computed(() =>
|
||||
[
|
||||
fabricVersionsQuery,
|
||||
forgeVersionsQuery,
|
||||
quiltVersionsQuery,
|
||||
neoforgeVersionsQuery,
|
||||
gameVersionsQuery,
|
||||
loadersQuery,
|
||||
].some((query) => query.isLoading.value),
|
||||
)
|
||||
|
||||
debug('metadata queries configured', {
|
||||
instanceId: instance.value.id,
|
||||
loader: instance.value.loader,
|
||||
gameVersion: instance.value.game_version,
|
||||
})
|
||||
|
||||
const isModrinthLinkedModpack = computed(
|
||||
() =>
|
||||
instance.value.link?.type === 'modrinth_modpack' ||
|
||||
instance.value.link?.type === 'server_project_modpack' ||
|
||||
(instance.value.link?.type === 'shared_instance' &&
|
||||
!!instance.value.link.modpack_project_id &&
|
||||
!!instance.value.link.modpack_version_id),
|
||||
)
|
||||
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
|
||||
const isSharedInstanceManagedModpack = managedContentPolicy.isManagedModpack
|
||||
const canUnlinkSharedInstance = managedContentPolicy.canUnlink
|
||||
|
||||
const modpackInfoQuery = useQuery({
|
||||
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
|
||||
queryFn: () => get_linked_modpack_info(instance.value.id, 'must_revalidate'),
|
||||
enabled: computed(() => isModrinthLinkedModpack.value && !offline),
|
||||
})
|
||||
const modpackInfo = modpackInfoQuery.data
|
||||
|
||||
const repairing = ref(false)
|
||||
const reinstalling = ref(false)
|
||||
const unlinkingSharedInstance = ref(false)
|
||||
const installationSettingsBusy = computed(
|
||||
() =>
|
||||
instance.value.quarantined ||
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
repairing.value ||
|
||||
reinstalling.value ||
|
||||
unlinkingSharedInstance.value ||
|
||||
!!offline,
|
||||
)
|
||||
const installationSettingsBusyMessage = computed(() =>
|
||||
instance.value.quarantined ? formatMessage(messages.locked) : null,
|
||||
)
|
||||
|
||||
async function unlinkSharedInstance() {
|
||||
unlinkingSharedInstance.value = true
|
||||
try {
|
||||
await unlink_shared_instance(instance.value.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: instanceKeys.sharedMembers(instance.value.id),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
|
||||
onUnlinked()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
unlinkingSharedInstance.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
loaderVersion: {
|
||||
id: 'instance.settings.tabs.installation.loader-version',
|
||||
defaultMessage: '{loader} version',
|
||||
},
|
||||
locked: {
|
||||
id: 'instance.settings.tabs.installation.locked',
|
||||
defaultMessage: 'Installation settings are unavailable while this instance is locked.',
|
||||
},
|
||||
})
|
||||
|
||||
function getManifest(loader: string) {
|
||||
const map: Record<string, Manifest | undefined> = {
|
||||
fabric: fabricVersionsQuery.data.value,
|
||||
forge: forgeVersionsQuery.data.value,
|
||||
quilt: quiltVersionsQuery.data.value,
|
||||
neoforge: neoforgeVersionsQuery.data.value,
|
||||
}
|
||||
const manifest = map[loader]
|
||||
debug('getManifest:', {
|
||||
loader,
|
||||
hasManifest: !!manifest,
|
||||
gameVersions: manifest?.gameVersions?.length ?? 0,
|
||||
})
|
||||
return manifest
|
||||
}
|
||||
|
||||
async function installLocalModpackFromPicker() {
|
||||
const picked = await filePicker.pickModpackFile({ readFile: false })
|
||||
if (!picked?.path) return false
|
||||
|
||||
const job = await install_pack_to_existing_instance(instance.value.id, {
|
||||
type: 'fromFile',
|
||||
path: picked.path,
|
||||
}).catch(handleError)
|
||||
if (!job) return false
|
||||
|
||||
const completed = await wait_for_install_job(job.job_id).catch(handleError)
|
||||
return !!completed
|
||||
}
|
||||
|
||||
provideInstanceBackup(instance)
|
||||
|
||||
provideInstallationSettings({
|
||||
closeSettings: closeModal,
|
||||
loading: computed(() => metadataLoading.value || modpackInfoQuery.isLoading.value),
|
||||
installationInfo: computed(() => {
|
||||
const rows = [
|
||||
{
|
||||
label: formatMessage(commonMessages.platformLabel),
|
||||
value: formatLoaderLabel(instance.value.loader),
|
||||
},
|
||||
{
|
||||
label: formatMessage(commonMessages.gameVersionLabel),
|
||||
value: instance.value.game_version,
|
||||
},
|
||||
]
|
||||
if (instance.value.loader !== 'vanilla' && instance.value.loader_version) {
|
||||
rows.push({
|
||||
label: formatMessage(messages.loaderVersion, {
|
||||
loader: formatLoaderLabel(instance.value.loader),
|
||||
}),
|
||||
value: instance.value.loader_version,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}),
|
||||
isLinked: computed(
|
||||
() =>
|
||||
isModrinthLinkedModpack.value ||
|
||||
isImportedModpack.value ||
|
||||
isSharedInstanceManagedModpack.value,
|
||||
),
|
||||
isBusy: installationSettingsBusy,
|
||||
busyMessage: installationSettingsBusyMessage,
|
||||
skipNonEssentialWarnings,
|
||||
modpack: computed(() => {
|
||||
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
|
||||
return {
|
||||
iconUrl: instance.value.icon_path,
|
||||
title: instance.value.link.name ?? instance.value.name,
|
||||
versionNumber: instance.value.link.version_number ?? undefined,
|
||||
filename: instance.value.link.filename ?? undefined,
|
||||
}
|
||||
}
|
||||
if (!modpackInfo.value) return null
|
||||
return {
|
||||
iconUrl: modpackInfo.value.project.icon_url,
|
||||
title: modpackInfo.value.project.title,
|
||||
link: `/project/${modpackInfo.value.project.slug ?? modpackInfo.value.project.id}`,
|
||||
versionNumber: modpackInfo.value.version?.version_number,
|
||||
}
|
||||
}),
|
||||
currentPlatform: computed(() => instance.value.loader),
|
||||
currentGameVersion: computed(() => instance.value.game_version),
|
||||
currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
|
||||
availablePlatforms: computed(() => loadersQuery.data.value?.map((x) => x.name) ?? []),
|
||||
|
||||
resolveGameVersions(loader, showSnapshots) {
|
||||
const versions = gameVersionsQuery.data.value ?? []
|
||||
const filtered = versions.filter((item) => {
|
||||
if (loader === 'vanilla') return true
|
||||
const manifest = getManifest(loader)
|
||||
return !!manifest?.gameVersions?.some((x) => item.version === x.id)
|
||||
})
|
||||
const result = (
|
||||
showSnapshots ? filtered : filtered.filter((x) => x.version_type === 'release')
|
||||
).map((x) => ({ value: x.version, label: x.version }))
|
||||
debug('resolveGameVersions:', {
|
||||
loader,
|
||||
showSnapshots,
|
||||
totalVersions: versions.length,
|
||||
filteredVersions: filtered.length,
|
||||
resultVersions: result.length,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
resolveLoaderVersions(loader, gameVersion) {
|
||||
if (loader === 'vanilla' || !gameVersion) {
|
||||
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
const manifest = getManifest(loader)
|
||||
if (!manifest) {
|
||||
debug('resolveLoaderVersions: no manifest', { loader, gameVersion })
|
||||
return []
|
||||
}
|
||||
const entry = manifest.gameVersions?.find((item) => item.id === gameVersion)
|
||||
if (entry?.versionGroup) {
|
||||
const result =
|
||||
manifest.versionGroups?.find((group) => group.id === entry.versionGroup)?.loaders ?? []
|
||||
debug('resolveLoaderVersions: version group result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
versionGroup: entry.versionGroup,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const placeholder = manifest.gameVersions?.find((item) => item.id === '${modrinth.gameVersion}')
|
||||
if (placeholder) {
|
||||
const result = manifest.gameVersions?.some((item) => item.id === gameVersion)
|
||||
? placeholder.loaders
|
||||
: []
|
||||
debug('resolveLoaderVersions: placeholder result', {
|
||||
loader,
|
||||
gameVersion,
|
||||
count: result.length,
|
||||
})
|
||||
return result
|
||||
}
|
||||
const result = entry?.loaders ?? []
|
||||
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
|
||||
return result
|
||||
},
|
||||
|
||||
resolveHasSnapshots(loader) {
|
||||
const versions = gameVersionsQuery.data.value ?? []
|
||||
if (loader === 'vanilla') {
|
||||
const result = versions.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots: vanilla', { loader, result })
|
||||
return result
|
||||
}
|
||||
const manifest = getManifest(loader)
|
||||
const supported = versions.filter(
|
||||
(item) => !!manifest?.gameVersions?.some((x) => item.version === x.id),
|
||||
)
|
||||
const result = supported.some((x) => x.version_type !== 'release')
|
||||
debug('resolveHasSnapshots:', {
|
||||
loader,
|
||||
totalVersions: versions.length,
|
||||
supportedVersions: supported.length,
|
||||
result,
|
||||
})
|
||||
return result
|
||||
},
|
||||
|
||||
async save(platform, gameVersion, loaderVersionId) {
|
||||
debug('save: called', {
|
||||
instanceId: instance.value.id,
|
||||
platform,
|
||||
gameVersion,
|
||||
loaderVersionId,
|
||||
})
|
||||
const editInstancePatch: Record<string, string | undefined> = {
|
||||
loader: platform,
|
||||
game_version: gameVersion,
|
||||
}
|
||||
if (platform !== 'vanilla' && loaderVersionId) {
|
||||
editInstancePatch.loader_version = loaderVersionId
|
||||
}
|
||||
await edit(instance.value.id, editInstancePatch).catch(handleError)
|
||||
debug('save: edit complete', { editInstancePatch })
|
||||
},
|
||||
|
||||
afterSave: async () => {
|
||||
debug('afterSave: installing', { instanceId: instance.value.id })
|
||||
await install_existing_instance(instance.value.id, false).catch(handleError)
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('afterSave: done')
|
||||
},
|
||||
|
||||
async repair() {
|
||||
debug('repair: called', { instanceId: instance.value.id })
|
||||
repairing.value = true
|
||||
await install_existing_instance(instance.value.id, true).catch(handleError)
|
||||
repairing.value = false
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
debug('repair: done')
|
||||
},
|
||||
|
||||
async reinstallModpack() {
|
||||
debug('reinstallModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
let shouldTrack = false
|
||||
try {
|
||||
if (isImportedModpack.value) {
|
||||
shouldTrack = await installLocalModpackFromPicker()
|
||||
} else {
|
||||
await update_repair_modrinth(instance.value.id).catch(handleError)
|
||||
shouldTrack = true
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
if (shouldTrack) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
debug('reinstallModpack: done')
|
||||
},
|
||||
|
||||
async swapModpack() {
|
||||
debug('swapModpack: called', { instanceId: instance.value.id })
|
||||
reinstalling.value = true
|
||||
try {
|
||||
const installed = await installLocalModpackFromPicker()
|
||||
if (installed) {
|
||||
trackEvent('InstanceRepair', {
|
||||
loader: instance.value.loader,
|
||||
game_version: instance.value.game_version,
|
||||
})
|
||||
}
|
||||
} finally {
|
||||
reinstalling.value = false
|
||||
}
|
||||
debug('swapModpack: done')
|
||||
},
|
||||
|
||||
async unlinkModpack() {
|
||||
debug('unlinkModpack: called', { instanceId: instance.value.id })
|
||||
await edit(instance.value.id, {
|
||||
link: null as unknown as undefined,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
onUnlinked()
|
||||
debug('unlinkModpack: done')
|
||||
},
|
||||
|
||||
getCachedModpackVersions: () => null,
|
||||
async fetchModpackVersions() {
|
||||
debug('fetchModpackVersions: called', {
|
||||
projectId: instance.value.link?.project_id,
|
||||
})
|
||||
const versions = await get_project_versions(instance.value.link!.project_id!).catch(handleError)
|
||||
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
|
||||
return (versions ?? []) as Labrinth.Versions.v2.Version[]
|
||||
},
|
||||
|
||||
async getVersionChangelog(versionId: string) {
|
||||
debug('getVersionChangelog: called', { versionId })
|
||||
return (await get_version(versionId, 'must_revalidate').catch(
|
||||
() => null,
|
||||
)) as Labrinth.Versions.v2.Version | null
|
||||
},
|
||||
|
||||
async onModpackVersionConfirm(version) {
|
||||
debug('onModpackVersionConfirm: called', {
|
||||
versionId: version.id,
|
||||
instanceId: instance.value.id,
|
||||
})
|
||||
await update_managed_modrinth_version(instance.value.id, version.id)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ['linkedModpackInfo', instance.value.id],
|
||||
})
|
||||
debug('onModpackVersionConfirm: done')
|
||||
},
|
||||
|
||||
updaterModalProps: computed(() => ({
|
||||
isApp: true,
|
||||
currentVersionId: modpackInfo.value?.update_version_id ?? instance.value.link?.version_id ?? '',
|
||||
projectIconUrl: modpackInfo.value?.project?.icon_url,
|
||||
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
|
||||
currentGameVersion: instance.value.game_version,
|
||||
currentLoader: instance.value.loader,
|
||||
})),
|
||||
|
||||
isServer: false,
|
||||
isApp: true,
|
||||
showModpackVersionActions: computed(
|
||||
() =>
|
||||
isModrinthLinkedModpack.value &&
|
||||
!isMinecraftServer.value &&
|
||||
!isSharedInstanceManagedModpack.value,
|
||||
),
|
||||
isLocalFile: isImportedModpack,
|
||||
isManagedModpack: isSharedInstanceManagedModpack,
|
||||
managedModpackWarning: managedContentPolicy.managedModpackWarning,
|
||||
repairing,
|
||||
reinstalling,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<InstallationSettingsLayout>
|
||||
<template #extra>
|
||||
<SharedInstanceInstallationSettingsControls
|
||||
:can-unlink="canUnlinkSharedInstance"
|
||||
:busy="installationSettingsBusy"
|
||||
:unlinking="unlinkingSharedInstance"
|
||||
:unlink="unlinkSharedInstance"
|
||||
/>
|
||||
</template>
|
||||
</InstallationSettingsLayout>
|
||||
</template>
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
import { createContext } from '@modrinth/ui'
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
export interface InstanceSettingsContext {
|
||||
instance: ComputedRef<GameInstance>
|
||||
offline?: boolean
|
||||
isMinecraftServer: Ref<boolean>
|
||||
onUnlinked: () => void
|
||||
closeModal?: () => void
|
||||
}
|
||||
|
||||
export const [injectInstanceSettings, provideInstanceSettings] =
|
||||
createContext<InstanceSettingsContext>('InstanceSettingsModal', 'instanceSettings')
|
||||
@@ -0,0 +1,322 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
CoffeeIcon,
|
||||
FolderSearchIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XCircleIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
Slider,
|
||||
StyledInput,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { computed, readonly, ref, watch } from 'vue'
|
||||
|
||||
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
|
||||
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 type { AppSettings } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
|
||||
|
||||
const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(handleError))
|
||||
|
||||
const overrideJavaInstall = ref(!!instance.value.java_path)
|
||||
const javaPath = ref(instance.value.java_path ?? optimalJava?.path ?? '')
|
||||
|
||||
const activePath = computed(() =>
|
||||
overrideJavaInstall.value ? javaPath.value : (optimalJava?.path ?? ''),
|
||||
)
|
||||
|
||||
watch(overrideJavaInstall, (enabled) => {
|
||||
if (enabled && !javaPath.value) {
|
||||
javaPath.value = optimalJava?.path ?? ''
|
||||
}
|
||||
})
|
||||
|
||||
const { testingJava, javaTestResult, testJavaInstallationDebounced, testJavaInstallation } =
|
||||
useJavaTest()
|
||||
|
||||
const hoveringTest = ref(false)
|
||||
let hasInitialized = false
|
||||
|
||||
watch(
|
||||
activePath,
|
||||
(newPath) => {
|
||||
if (newPath && optimalJava?.parsed_version) {
|
||||
if (!hasInitialized) {
|
||||
testJavaInstallation(newPath, optimalJava?.parsed_version, false)
|
||||
hasInitialized = true
|
||||
} else {
|
||||
testJavaInstallationDebounced(newPath, optimalJava?.parsed_version)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const javaDetectionModal = ref<{ show: (version: number, current: object) => void } | null>(null)
|
||||
|
||||
async function handleBrowseJava() {
|
||||
const result = await open({ multiple: false })
|
||||
if (result) {
|
||||
javaPath.value = result
|
||||
}
|
||||
}
|
||||
|
||||
function handleDetectJava() {
|
||||
javaDetectionModal.value?.show(optimalJava?.parsed_version, { path: javaPath.value })
|
||||
}
|
||||
|
||||
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
|
||||
const javaArgs = ref(
|
||||
(instance.value.extra_launch_args ?? globalSettings.extra_launch_args).join(' '),
|
||||
)
|
||||
|
||||
const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0)
|
||||
const envVars = ref(
|
||||
(instance.value.custom_env_vars ?? globalSettings.custom_env_vars)
|
||||
.map((x) => x.join('='))
|
||||
.join(' '),
|
||||
)
|
||||
|
||||
const overrideMemorySettings = ref(!!instance.value.memory)
|
||||
const memory = ref(instance.value.memory ?? globalSettings.memory)
|
||||
const { maxMemory, snapPoints } = (await useMemorySlider().catch(handleError)) as unknown as {
|
||||
maxMemory: number
|
||||
snapPoints: number[]
|
||||
}
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
return {
|
||||
java_path:
|
||||
overrideJavaInstall.value && javaPath.value
|
||||
? javaPath.value.replace('java.exe', 'javaw.exe')
|
||||
: null,
|
||||
extra_launch_args: overrideJavaArgs.value
|
||||
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
|
||||
: null,
|
||||
custom_env_vars: overrideEnvVars.value
|
||||
? envVars.value
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((x) => x.split('=').filter(Boolean))
|
||||
: null,
|
||||
memory: overrideMemorySettings.value ? memory.value : null,
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[
|
||||
overrideJavaInstall,
|
||||
javaPath,
|
||||
overrideJavaArgs,
|
||||
javaArgs,
|
||||
overrideEnvVars,
|
||||
envVars,
|
||||
overrideMemorySettings,
|
||||
memory,
|
||||
],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
javaInstallation: {
|
||||
id: 'instance.settings.tabs.java.java-installation',
|
||||
defaultMessage: 'Java installation',
|
||||
},
|
||||
customJavaInstallation: {
|
||||
id: 'instance.settings.tabs.java.custom-java-installation',
|
||||
defaultMessage: 'Custom Java installation',
|
||||
},
|
||||
javaPathPlaceholder: {
|
||||
id: 'instance.settings.tabs.java.java-path-placeholder',
|
||||
defaultMessage: '/path/to/java',
|
||||
},
|
||||
javaMemory: {
|
||||
id: 'instance.settings.tabs.java.java-memory',
|
||||
defaultMessage: 'Memory allocated',
|
||||
},
|
||||
customMemoryAllocation: {
|
||||
id: 'instance.settings.tabs.java.custom-memory-allocation',
|
||||
defaultMessage: 'Custom memory allocation',
|
||||
},
|
||||
javaArguments: {
|
||||
id: 'instance.settings.tabs.java.java-arguments',
|
||||
defaultMessage: 'Java arguments',
|
||||
},
|
||||
customJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.custom-java-arguments',
|
||||
defaultMessage: 'Custom Java arguments',
|
||||
},
|
||||
enterJavaArguments: {
|
||||
id: 'instance.settings.tabs.java.enter-java-arguments',
|
||||
defaultMessage: 'Enter Java arguments...',
|
||||
},
|
||||
javaEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.environment-variables',
|
||||
defaultMessage: 'Environment variables',
|
||||
},
|
||||
customEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.custom-environment-variables',
|
||||
defaultMessage: 'Custom environment variables',
|
||||
},
|
||||
enterEnvironmentVariables: {
|
||||
id: 'instance.settings.tabs.java.enter-environment-variables',
|
||||
defaultMessage: 'Enter environmental variables...',
|
||||
},
|
||||
hooks: {
|
||||
id: 'instance.settings.tabs.java.hooks',
|
||||
defaultMessage: 'Hooks',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<JavaDetectionModal ref="javaDetectionModal" @submit="(val) => (javaPath = val.path)" />
|
||||
<h2 class="m-0 mb-2 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaInstallation) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideJavaInstall"
|
||||
:label="formatMessage(messages.customJavaInstallation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<div class="flex gap-4 p-4 bg-bg rounded-2xl">
|
||||
<div class="flex gap-3 items-start flex-1 min-w-0">
|
||||
<div
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-button-bg border-solid border-[1px] border-button-border p-2 mt-1 shrink-0 [&_svg]:h-full [&_svg]:w-full"
|
||||
>
|
||||
<CoffeeIcon />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 flex-1 min-w-0">
|
||||
<span class="font-semibold leading-none mt-2"
|
||||
>Java {{ optimalJava?.parsed_version }}</span
|
||||
>
|
||||
<div class="flex gap-2 items-center">
|
||||
<StyledInput
|
||||
:model-value="activePath"
|
||||
:disabled="!overrideJavaInstall"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.javaPathPlaceholder)"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
@update:model-value="(val) => (javaPath = String(val))"
|
||||
/>
|
||||
<ButtonStyled
|
||||
:color="
|
||||
!hoveringTest && !testingJava
|
||||
? javaTestResult === true
|
||||
? 'green'
|
||||
: 'red'
|
||||
: 'standard'
|
||||
"
|
||||
color-fill="text"
|
||||
>
|
||||
<button
|
||||
:disabled="!overrideJavaInstall || testingJava"
|
||||
@click="testJavaInstallation(activePath, optimalJava?.parsed_version, true)"
|
||||
@mouseenter="overrideJavaInstall && (hoveringTest = true)"
|
||||
@mouseleave="hoveringTest = false"
|
||||
>
|
||||
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
|
||||
<CheckCircleIcon
|
||||
v-else-if="javaTestResult === true && !hoveringTest"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
<XCircleIcon v-else-if="javaTestResult !== true && !hoveringTest" class="h-4 w-4" />
|
||||
<RefreshCwIcon v-else-if="overrideJavaInstall" class="h-4 w-4" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="overrideJavaInstall" class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleDetectJava">
|
||||
<SearchIcon />
|
||||
Detect
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button @click="handleBrowseJava">
|
||||
<FolderSearchIcon />
|
||||
Browse
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaMemory) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideMemorySettings"
|
||||
:label="formatMessage(messages.customMemoryAllocation)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<Slider
|
||||
id="max-memory"
|
||||
v-model="memory.maximum"
|
||||
:disabled="!overrideMemorySettings"
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaArguments) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideJavaArgs"
|
||||
:label="formatMessage(messages.customJavaArguments)"
|
||||
class="my-2"
|
||||
/>
|
||||
<StyledInput
|
||||
id="java-args"
|
||||
v-model="javaArgs"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideJavaArgs"
|
||||
:placeholder="formatMessage(messages.enterJavaArguments)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<h2 class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
{{ formatMessage(messages.javaEnvironmentVariables) }}
|
||||
</h2>
|
||||
<Checkbox
|
||||
v-model="overrideEnvVars"
|
||||
:label="formatMessage(messages.customEnvironmentVariables)"
|
||||
class="mb-2"
|
||||
/>
|
||||
<StyledInput
|
||||
id="env-vars"
|
||||
v-model="envVars"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideEnvVars"
|
||||
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<div v-if="canUnpublish" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
|
||||
<div>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="busy" @click="unpublishModal?.show()">
|
||||
<SpinnerIcon v-if="unpublishing" class="animate-spin" />
|
||||
<UnlinkIcon v-else class="size-5" />
|
||||
{{ formatMessage(unpublishing ? messages.unpublishingButton : messages.unpublishButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">{{ formatMessage(messages.unpublishDescription) }}</span>
|
||||
</div>
|
||||
<div v-if="canUnlink" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">{{
|
||||
formatMessage(messages.linkedTitle)
|
||||
}}</span>
|
||||
<div>
|
||||
<ButtonStyled color="orange">
|
||||
<button :disabled="busy" @click="unlinkModal?.show()">
|
||||
<SpinnerIcon v-if="unlinking" class="animate-spin" />
|
||||
<UnlinkIcon v-else class="size-5" />
|
||||
{{ formatMessage(unlinking ? messages.unlinkingButton : messages.unlinkButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<span class="text-primary">{{ formatMessage(messages.unlinkDescription) }}</span>
|
||||
</div>
|
||||
|
||||
<NewModal
|
||||
ref="unpublishModal"
|
||||
:header="formatMessage(messages.unpublishModalHeader)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
>
|
||||
<Admonition type="warning" :header="formatMessage(messages.unpublishModalAdmonitionHeader)">{{
|
||||
formatMessage(messages.unpublishModalBody)
|
||||
}}</Admonition>
|
||||
<template #actions
|
||||
><div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined"
|
||||
><button class="!border" @click="unpublishModal?.hide()">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button></ButtonStyled
|
||||
><ButtonStyled color="orange"
|
||||
><button :disabled="busy" @click="confirmUnpublish">
|
||||
<UnlinkIcon />{{ formatMessage(messages.unpublishButton) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div></template
|
||||
>
|
||||
</NewModal>
|
||||
<NewModal
|
||||
ref="unlinkModal"
|
||||
:header="formatMessage(messages.unlinkModalHeader)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.unlinkModalAdmonitionHeader)">{{
|
||||
formatMessage(messages.unlinkModalBody)
|
||||
}}</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
backup-name="Before unlinking shared instance"
|
||||
@update:buttons-disabled="backupBusy = $event"
|
||||
/>
|
||||
</div>
|
||||
<template #actions
|
||||
><div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined"
|
||||
><button class="!border" @click="unlinkModal?.hide()">
|
||||
<XIcon />{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button></ButtonStyled
|
||||
><ButtonStyled color="orange"
|
||||
><button :disabled="busy || backupBusy" @click="confirmUnlink">
|
||||
<UnlinkIcon />{{ formatMessage(messages.unlinkButton) }}
|
||||
</button></ButtonStyled
|
||||
>
|
||||
</div></template
|
||||
>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon, UnlinkIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
Admonition,
|
||||
ButtonStyled,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
InlineBackupCreator,
|
||||
NewModal,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
canUnpublish?: boolean
|
||||
canUnlink?: boolean
|
||||
busy: boolean
|
||||
unpublishing?: boolean
|
||||
unlinking?: boolean
|
||||
unpublish?: () => Promise<void>
|
||||
unlink?: () => Promise<void>
|
||||
}>(),
|
||||
{
|
||||
canUnpublish: false,
|
||||
canUnlink: false,
|
||||
unpublishing: false,
|
||||
unlinking: false,
|
||||
unpublish: undefined,
|
||||
unlink: undefined,
|
||||
},
|
||||
)
|
||||
const { formatMessage } = useVIntl()
|
||||
const unpublishModal = ref<InstanceType<typeof NewModal>>()
|
||||
const unlinkModal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const backupBusy = ref(false)
|
||||
|
||||
async function confirmUnpublish() {
|
||||
unpublishModal.value?.hide()
|
||||
await props.unpublish?.()
|
||||
}
|
||||
async function confirmUnlink() {
|
||||
unlinkModal.value?.hide()
|
||||
await props.unlink?.()
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'installation-settings.shared-instance.title',
|
||||
defaultMessage: 'Unpublish instance',
|
||||
},
|
||||
linkedTitle: {
|
||||
id: 'installation-settings.shared-instance.linked-title',
|
||||
defaultMessage: 'Linked shared instance',
|
||||
},
|
||||
unpublishButton: {
|
||||
id: 'installation-settings.shared-instance.unpublish-button',
|
||||
defaultMessage: 'Unpublish shared instance',
|
||||
},
|
||||
unpublishingButton: {
|
||||
id: 'installation-settings.shared-instance.unpublishing-button',
|
||||
defaultMessage: 'Unpublishing...',
|
||||
},
|
||||
unpublishDescription: {
|
||||
id: 'installation-settings.shared-instance.unpublish-description',
|
||||
defaultMessage:
|
||||
'Remove this shared instance from Modrinth and stop sending updates to anyone using it. Your local instance will not be affected.',
|
||||
},
|
||||
unlinkButton: {
|
||||
id: 'installation-settings.shared-instance.unlink-button',
|
||||
defaultMessage: 'Unlink shared instance',
|
||||
},
|
||||
unlinkingButton: {
|
||||
id: 'installation-settings.shared-instance.unlinking-button',
|
||||
defaultMessage: 'Unlinking...',
|
||||
},
|
||||
unlinkDescription: {
|
||||
id: 'installation-settings.shared-instance.unlink-description',
|
||||
defaultMessage: 'Disconnect this local instance from future shared updates.',
|
||||
},
|
||||
unpublishModalHeader: {
|
||||
id: 'installation-settings.unpublish-shared-instance.modal.header',
|
||||
defaultMessage: 'Unpublish shared instance',
|
||||
},
|
||||
unpublishModalAdmonitionHeader: {
|
||||
id: 'installation-settings.unpublish-shared-instance.modal.admonition-header',
|
||||
defaultMessage: 'Unpublishing shared instance',
|
||||
},
|
||||
unpublishModalBody: {
|
||||
id: 'installation-settings.unpublish-shared-instance.modal.admonition-body',
|
||||
defaultMessage:
|
||||
"This deletes the shared instance from Modrinth's servers. People using it in the Modrinth App will stop receiving updates, but your local instance and its content will stay on this device.",
|
||||
},
|
||||
unlinkModalHeader: {
|
||||
id: 'installation-settings.unlink-shared-instance.modal.header',
|
||||
defaultMessage: 'Unlink shared instance',
|
||||
},
|
||||
unlinkModalAdmonitionHeader: {
|
||||
id: 'installation-settings.unlink-shared-instance.modal.admonition-header',
|
||||
defaultMessage: 'Unlinking shared instance',
|
||||
},
|
||||
unlinkModalBody: {
|
||||
id: 'installation-settings.unlink-shared-instance.modal.admonition-body',
|
||||
defaultMessage:
|
||||
'This only affects your local instance. Your installed content will stay on this device, and the shared instance and other people using it will not be affected.',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8">
|
||||
<section class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h3 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.activeInvitesTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.activeInvitesDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Table :columns="inviteColumns" :data="activeInvites" row-key="id" table-min-width="36rem">
|
||||
<template #empty-state>
|
||||
<div class="flex h-40 items-center justify-center text-secondary">
|
||||
<SpinnerIcon
|
||||
v-if="activeInvitesQuery.isLoading.value"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<template v-else>
|
||||
{{ formatMessage(messages.noActiveInvites) }}
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell-id="{ row }">
|
||||
<CopyCode
|
||||
:text="`${config.siteUrl}/share/${encodeURIComponent(row.id)}`"
|
||||
:display-text="`/${row.id}`"
|
||||
/>
|
||||
</template>
|
||||
<template #cell-uses="{ row }">
|
||||
<span class="font-medium text-primary">{{ row.uses }}</span>
|
||||
<span> / {{ row.maxUses }}</span>
|
||||
</template>
|
||||
<template #cell-expiration="{ row }">
|
||||
<span v-tooltip="formatDateTime(row.expiration)" class="whitespace-nowrap">
|
||||
{{ formatRelativeTime(row.expiration) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #cell-actions="{ row }">
|
||||
<div class="flex justify-end">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.revokeInvite)"
|
||||
:aria-label="
|
||||
formatMessage(messages.revokeInviteWithCode, {
|
||||
code: row.id,
|
||||
})
|
||||
"
|
||||
:disabled="revokeInviteMutation.isPending.value || isBusy"
|
||||
class="text-secondary hover:!filter-none hover:text-red focus-visible:!filter-none"
|
||||
@click="revokeInviteModal?.show(row.id)"
|
||||
>
|
||||
<SpinnerIcon
|
||||
v-if="
|
||||
revokeInviteMutation.isPending.value &&
|
||||
revokeInviteMutation.variables.value?.inviteId === row.id
|
||||
"
|
||||
class="animate-spin"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<XIcon v-else aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</Table>
|
||||
</section>
|
||||
|
||||
<SharedInstanceInstallationSettingsControls
|
||||
can-unpublish
|
||||
:busy="isBusy"
|
||||
:unpublishing="unpublishing"
|
||||
:unpublish="unpublishSharedInstance"
|
||||
/>
|
||||
|
||||
<ConfirmRevokeSharedInstanceInviteModal ref="revokeInviteModal" @revoke="revokeInvite" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
CopyCode,
|
||||
defineMessages,
|
||||
Table,
|
||||
type TableColumn,
|
||||
useFormatDateTime,
|
||||
useRelativeTime,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { config } from '@/config'
|
||||
import {
|
||||
get_shared_instance_invites,
|
||||
revoke_shared_instance_invite,
|
||||
type SharedInstanceInvite,
|
||||
unpublish_shared_instance,
|
||||
} from '@/helpers/instance'
|
||||
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
|
||||
|
||||
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()
|
||||
const { formatMessage } = useVIntl()
|
||||
const queryClient = useQueryClient()
|
||||
const unpublishing = ref(false)
|
||||
const revokeInviteModal = ref<InstanceType<typeof ConfirmRevokeSharedInstanceInviteModal>>()
|
||||
const formatRelativeTime = useRelativeTime()
|
||||
const formatDateTime = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
|
||||
|
||||
type InviteTableColumn = 'id' | 'uses' | 'expiration' | 'actions'
|
||||
type ActiveInvitesQueryKey = readonly ['sharedInstanceInvites', string]
|
||||
|
||||
const activeInvitesQueryKey = computed(
|
||||
() => ['sharedInstanceInvites', instance.value.id] as const satisfies ActiveInvitesQueryKey,
|
||||
)
|
||||
const activeInvitesQuery = useQuery({
|
||||
queryKey: activeInvitesQueryKey,
|
||||
queryFn: async ({ queryKey }) => {
|
||||
try {
|
||||
return await get_shared_instance_invites(queryKey[1])
|
||||
} catch (error) {
|
||||
notifySharedInstanceError(error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
enabled: () => !!instance.value.id && !offline,
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: 'always',
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const activeInvites = computed(() => activeInvitesQuery.data.value ?? [])
|
||||
|
||||
const revokeInviteMutation = useMutation({
|
||||
mutationFn: ({ instanceId, inviteId }: { instanceId: string; inviteId: string }) =>
|
||||
revoke_shared_instance_invite(instanceId, inviteId),
|
||||
onSuccess: (_data, { instanceId, inviteId }) => {
|
||||
queryClient.setQueryData<SharedInstanceInvite[]>(
|
||||
['sharedInstanceInvites', instanceId],
|
||||
(invites = []) => invites.filter((invite) => invite.id !== inviteId),
|
||||
)
|
||||
},
|
||||
onError: notifySharedInstanceError,
|
||||
})
|
||||
|
||||
const isBusy = computed(
|
||||
() =>
|
||||
instance.value.install_stage !== 'installed' ||
|
||||
unpublishing.value ||
|
||||
revokeInviteMutation.isPending.value ||
|
||||
!!offline,
|
||||
)
|
||||
|
||||
const inviteColumns = computed<TableColumn<InviteTableColumn>[]>(() => [
|
||||
{
|
||||
key: 'id',
|
||||
label: formatMessage(messages.inviteCodeLabel),
|
||||
width: 'clamp(11rem, 34%, 19rem)',
|
||||
},
|
||||
{
|
||||
key: 'uses',
|
||||
label: formatMessage(messages.usesLabel),
|
||||
width: 'clamp(7rem, 18%, 10rem)',
|
||||
},
|
||||
{
|
||||
key: 'expiration',
|
||||
label: formatMessage(messages.expiresLabel),
|
||||
width: 'clamp(9rem, 28%, 14rem)',
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: formatMessage(messages.actionsLabel),
|
||||
align: 'right',
|
||||
width: '5.5rem',
|
||||
},
|
||||
])
|
||||
|
||||
function revokeInvite(inviteId: string) {
|
||||
if (revokeInviteMutation.isPending.value) return
|
||||
revokeInviteMutation.mutate({ instanceId: instance.value.id, inviteId })
|
||||
}
|
||||
|
||||
async function unpublishSharedInstance() {
|
||||
unpublishing.value = true
|
||||
try {
|
||||
await unpublish_shared_instance(instance.value.id)
|
||||
queryClient.setQueryData(instanceKeys.sharedMembers(instance.value.id), [])
|
||||
await queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instance.value.id] })
|
||||
onUnlinked()
|
||||
} catch (error) {
|
||||
notifySharedInstanceError(error)
|
||||
} finally {
|
||||
unpublishing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
activeInvitesTitle: {
|
||||
id: 'instance.settings.sharing.active-invites.title',
|
||||
defaultMessage: 'Active invites',
|
||||
},
|
||||
activeInvitesDescription: {
|
||||
id: 'instance.settings.sharing.active-invites.description',
|
||||
defaultMessage: 'Anyone with one of these invite links can join while it remains active.',
|
||||
},
|
||||
inviteCodeLabel: {
|
||||
id: 'instance.settings.sharing.active-invites.code',
|
||||
defaultMessage: 'Invite link',
|
||||
},
|
||||
usesLabel: {
|
||||
id: 'instance.settings.sharing.active-invites.uses',
|
||||
defaultMessage: 'Uses',
|
||||
},
|
||||
expiresLabel: {
|
||||
id: 'instance.settings.sharing.active-invites.expires',
|
||||
defaultMessage: 'Expires',
|
||||
},
|
||||
actionsLabel: {
|
||||
id: 'instance.settings.sharing.active-invites.actions',
|
||||
defaultMessage: 'Actions',
|
||||
},
|
||||
noActiveInvites: {
|
||||
id: 'instance.settings.sharing.active-invites.empty',
|
||||
defaultMessage: 'There are no active invites.',
|
||||
},
|
||||
revokeInvite: {
|
||||
id: 'instance.settings.sharing.active-invites.revoke',
|
||||
defaultMessage: 'Revoke invite',
|
||||
},
|
||||
revokeInviteWithCode: {
|
||||
id: 'instance.settings.sharing.active-invites.revoke-with-code',
|
||||
defaultMessage: 'Revoke invite {code}',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Checkbox,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
StyledInput,
|
||||
Toggle,
|
||||
useVIntl,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, type Ref, ref, watch } from 'vue'
|
||||
|
||||
import { edit } from '@/helpers/instance'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
|
||||
import type { AppSettings } from '../../../../helpers/types'
|
||||
import { injectInstanceSettings } from './instance-settings-context'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const { instance } = injectInstanceSettings()
|
||||
|
||||
const globalSettings = (await get().catch(handleError)) as AppSettings
|
||||
|
||||
const overrideWindowSettings = ref(
|
||||
!!instance.value.game_resolution || !!instance.value.force_fullscreen,
|
||||
)
|
||||
const resolution: Ref<[number, number]> = ref(
|
||||
instance.value.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
|
||||
)
|
||||
const fullscreenSetting: Ref<boolean> = ref(
|
||||
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
|
||||
)
|
||||
|
||||
const editInstanceObject = computed(() => {
|
||||
if (!overrideWindowSettings.value) {
|
||||
return {
|
||||
force_fullscreen: null,
|
||||
game_resolution: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
force_fullscreen: fullscreenSetting.value,
|
||||
game_resolution: fullscreenSetting.value ? null : resolution.value,
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
[overrideWindowSettings, resolution, fullscreenSetting],
|
||||
async () => {
|
||||
await edit(instance.value.id, editInstanceObject.value)
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const messages = defineMessages({
|
||||
customWindowSettings: {
|
||||
id: 'instance.settings.tabs.window.custom-window-settings',
|
||||
defaultMessage: 'Custom window settings',
|
||||
},
|
||||
fullscreen: {
|
||||
id: 'instance.settings.tabs.window.fullscreen',
|
||||
defaultMessage: 'Fullscreen',
|
||||
},
|
||||
fullscreenDescription: {
|
||||
id: 'instance.settings.tabs.window.fullscreen.description',
|
||||
defaultMessage: 'Make the game start in full screen when launched (using options.txt).',
|
||||
},
|
||||
width: {
|
||||
id: 'instance.settings.tabs.window.width',
|
||||
defaultMessage: 'Width',
|
||||
},
|
||||
widthDescription: {
|
||||
id: 'instance.settings.tabs.window.width.description',
|
||||
defaultMessage: 'The width of the game window when launched.',
|
||||
},
|
||||
enterWidth: {
|
||||
id: 'instance.settings.tabs.window.width.enter',
|
||||
defaultMessage: 'Enter width...',
|
||||
},
|
||||
height: {
|
||||
id: 'instance.settings.tabs.window.height',
|
||||
defaultMessage: 'Height',
|
||||
},
|
||||
heightDescription: {
|
||||
id: 'instance.settings.tabs.window.height.description',
|
||||
defaultMessage: 'The height of the game window when launched.',
|
||||
},
|
||||
enterHeight: {
|
||||
id: 'instance.settings.tabs.window.height.enter',
|
||||
defaultMessage: 'Enter height...',
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Checkbox
|
||||
v-model="overrideWindowSettings"
|
||||
:label="formatMessage(messages.customWindowSettings)"
|
||||
/>
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.fullscreen) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.fullscreenDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle
|
||||
id="fullscreen"
|
||||
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
|
||||
:disabled="!overrideWindowSettings"
|
||||
@update:model-value="
|
||||
(e) => {
|
||||
fullscreenSetting = e
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.width) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.widthDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="width"
|
||||
v-model="resolution[0]"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.enterWidth)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 justify-between">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="m-0 text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(messages.height) }}
|
||||
</h2>
|
||||
<p class="m-0">
|
||||
{{ formatMessage(messages.heightDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
<StyledInput
|
||||
id="height"
|
||||
v-model="resolution[1]"
|
||||
autocomplete="off"
|
||||
:disabled="!overrideWindowSettings || fullscreenSetting"
|
||||
type="number"
|
||||
:placeholder="formatMessage(messages.enterHeight)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user