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:
Calum H.
2026-08-04 16:52:00 +00:00
committed by GitHub
co-authored by tdgao
parent 99377f8436
commit fe0c97190a
42 changed files with 1009 additions and 1142 deletions
@@ -9,6 +9,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { instanceKeys } from '@/pages/instance/query-options'
const ITEM_SIZE = 52
const APPROX_USED_VERTICAL_SPACE = 513 // doesn't need to be exact lol just close enough so there's a little gap and no overflow
@@ -123,7 +124,7 @@ const getInstances = async () => {
const instances = await list().catch(handleError)
for (const instance of instances) {
queryClient.setQueryData(['instances', 'summary', instance.id], instance)
queryClient.setQueryData(instanceKeys.detail(instance.id), instance)
}
allInstances.value = instances.sort((a, b) => {
@@ -15,6 +15,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
import { instanceKeys } from '@/pages/instance/query-options'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
@@ -67,7 +68,7 @@ async function addServer(instance) {
try {
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
await queryClient.invalidateQueries({ queryKey: instanceKeys.worlds(instance.id) })
trackEvent('AddServerToInstance', {
server_name: serverName.value,
@@ -1,409 +0,0 @@
<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>
@@ -1,63 +0,0 @@
<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>
@@ -1,141 +0,0 @@
<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 './instance-admonitions-shared-instance-stale.vue'
import InstanceAdmonitionsSharedInstanceUnavailable from './instance-admonitions-shared-instance-unavailable.vue'
import InstanceAdmonitionsSharedInstanceUpdateAvailable from './instance-admonitions-shared-instance-update-available.vue'
import InstanceAdmonitionsSharedInstanceWrongAccount from './instance-admonitions-shared-instance-wrong-account.vue'
import type { InstanceAdmonitionItem, SharedInstanceRole } from './types'
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>
@@ -1,82 +0,0 @@
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',
},
})
@@ -1,66 +0,0 @@
<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 './instance-admonitions-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>
@@ -1,51 +0,0 @@
<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>
@@ -1,34 +0,0 @@
<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 './instance-admonitions-messages'
defineProps<{
instanceName: string
}>()
const emit = defineEmits<{
review: [event: MouseEvent]
}>()
const { formatMessage } = useVIntl()
</script>
@@ -1,78 +0,0 @@
<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 './instance-admonitions-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>
@@ -1,13 +0,0 @@
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'
@@ -1,450 +0,0 @@
<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 { injectInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
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>
@@ -1,157 +0,0 @@
<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 { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings, Hooks } from '../../../helpers/types'
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>
@@ -1,496 +0,0 @@
<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 SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
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 { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
import type { Manifest } from '../../../helpers/types'
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: ['sharedInstanceUsers', 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>
@@ -1,322 +0,0 @@
<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 { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings } from '../../../helpers/types'
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>
@@ -1,244 +0,0 @@
<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 ConfirmRevokeSharedInstanceInviteModal from '@/components/ui/shared-instances/ConfirmRevokeSharedInstanceInviteModal.vue'
import SharedInstanceInstallationSettingsControls from '@/components/ui/shared-instances/SharedInstanceInstallationSettingsControls.vue'
import { config } from '@/config'
import {
get_shared_instance_invites,
revoke_shared_instance_invite,
type SharedInstanceInvite,
unpublish_shared_instance,
} from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import { injectInstanceSettings } from '@/providers/instance-settings'
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(['sharedInstanceUsers', 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>
@@ -1,161 +0,0 @@
<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 { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings } from '../../../helpers/types'
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>
@@ -1,212 +0,0 @@
<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 GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import SharingSettings from '@/components/ui/instance_settings/SharingSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
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>
@@ -1,82 +0,0 @@
<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>
@@ -1,195 +0,0 @@
<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>