feat: incompat modal improvement (#6256)

* feat: incompat modal improvement

* feat: use ContentUpdaterModal and remove IncompatibilityWarningModal

* fix: lint

* fix: lint
This commit is contained in:
Calum H.
2026-06-05 16:56:05 +01:00
committed by GitHub
parent 707e219ff8
commit 7366c32df3
12 changed files with 336 additions and 258 deletions
+33 -2
View File
@@ -36,6 +36,7 @@ import {
ButtonStyled,
commonMessages,
ContentInstallModal,
ContentUpdaterModal,
CreationFlowModal,
defineMessages,
I18nDebugPanel,
@@ -75,7 +76,6 @@ import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import ErrorModal from '@/components/ui/ErrorModal.vue'
import FriendsList from '@/components/ui/friends/FriendsList.vue'
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
import IncompatibilityWarningModal from '@/components/ui/install_flow/IncompatibilityWarningModal.vue'
import UnknownPackWarningModal from '@/components/ui/install_flow/UnknownPackWarningModal.vue'
import MinecraftAuthErrorModal from '@/components/ui/minecraft-auth-error-modal/MinecraftAuthErrorModal.vue'
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
@@ -612,6 +612,16 @@ const {
handleModpackDuplicateCreateAnyway: handleContentInstallModpackDuplicateCreateAnyway,
handleModpackDuplicateGoToInstance: handleContentInstallModpackDuplicateGoToInstance,
setIncompatibilityWarningModal: setContentIncompatibilityWarningModal,
incompatibilityWarningVersions: contentInstallIncompatibilityWarningVersions,
incompatibilityWarningCurrentGameVersion: contentInstallIncompatibilityWarningCurrentGameVersion,
incompatibilityWarningCurrentLoader: contentInstallIncompatibilityWarningCurrentLoader,
incompatibilityWarningProjectType: contentInstallIncompatibilityWarningProjectType,
incompatibilityWarningProjectIconUrl: contentInstallIncompatibilityWarningProjectIconUrl,
incompatibilityWarningProjectName: contentInstallIncompatibilityWarningProjectName,
incompatibilityWarningMessage: contentInstallIncompatibilityWarningMessage,
incompatibilityWarningInstalling: contentInstallIncompatibilityWarningInstalling,
handleIncompatibilityWarningInstall: handleContentInstallIncompatibilityWarningInstall,
handleIncompatibilityWarningCancel: handleContentInstallIncompatibilityWarningCancel,
} = contentInstall
const serverInstall = createServerInstall({ router, handleError, popupNotificationManager })
@@ -633,6 +643,12 @@ const updateToPlayModal = ref()
const modrinthLoginFlowWaitModal = ref()
watch(incompatibilityWarningModal, (modal) => {
if (modal) {
setContentIncompatibilityWarningModal(modal)
}
})
setupAuthProvider(credentials, async (_redirectPath) => {
await signIn()
})
@@ -1631,7 +1647,22 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
@go-to-instance="handleModpackDuplicateGoToInstance"
/>
<AddServerToInstanceModal ref="addServerToInstanceModal" />
<IncompatibilityWarningModal ref="incompatibilityWarningModal" />
<ContentUpdaterModal
ref="incompatibilityWarningModal"
mode="incompatibility-warning"
:versions="contentInstallIncompatibilityWarningVersions"
:current-game-version="contentInstallIncompatibilityWarningCurrentGameVersion"
:current-loader="contentInstallIncompatibilityWarningCurrentLoader"
current-version-id=""
:is-app="true"
:project-type="contentInstallIncompatibilityWarningProjectType"
:project-icon-url="contentInstallIncompatibilityWarningProjectIconUrl"
:project-name="contentInstallIncompatibilityWarningProjectName"
:warning="contentInstallIncompatibilityWarningMessage"
:action-loading="contentInstallIncompatibilityWarningInstalling"
@update="handleContentInstallIncompatibilityWarningInstall"
@cancel="handleContentInstallIncompatibilityWarningCancel"
/>
<ModpackAlreadyInstalledModal
ref="contentInstallModpackAlreadyInstalledModal"
@create-anyway="handleContentInstallModpackDuplicateCreateAnyway"
@@ -1,185 +0,0 @@
<template>
<ModalWrapper ref="incompatibleModal" header="Incompatibility warning" :on-hide="onInstall">
<div class="modal-body">
<p>
This {{ versions?.length > 0 ? 'project' : 'version' }} is not compatible with the instance
you're trying to install it on. Are you sure you want to continue? Dependencies will not be
installed.
</p>
<table>
<thead>
<tr class="header">
<th>{{ instance?.name }}</th>
<th>{{ project.title }}</th>
</tr>
</thead>
<tbody>
<tr class="content">
<td class="data">{{ instance?.loader }} {{ instance?.game_version }}</td>
<td>
<Combobox
v-if="versions?.length > 1"
v-model="selectedVersionId"
:options="versionOptions"
:searchable="true"
placeholder="Select version"
force-direction="up"
:max-height="150"
/>
<span v-else>
<span>{{ selectedVersionLabel }}</span>
</span>
</td>
</tr>
</tbody>
</table>
<div class="button-group">
<ButtonStyled type="outlined">
<button @click="() => incompatibleModal.hide()"><XIcon />Cancel</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="installing" @click="install()">
<DownloadIcon /> {{ installing ? 'Installing' : 'Install' }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { DownloadIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
Combobox,
formatLoader,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { add_project_from_version as installMod } from '@/helpers/profile'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const instance = ref(null)
const project = ref(null)
const versions = ref(null)
const selectedVersion = ref(null)
const incompatibleModal = ref(null)
const installing = ref(false)
const onInstall = ref(() => {})
const selectedVersionLabel = computed(() => {
if (!selectedVersion.value) return ''
return `${selectedVersion.value.name} (${selectedVersion.value.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${selectedVersion.value.game_versions.join(', ')})`
})
const versionOptions = computed(() =>
(versions.value ?? []).map((version) => ({
value: version.id,
label: `${version.name} (${version.loaders
.map((name) => formatLoader(formatMessage, name))
.join(', ')} - ${version.game_versions.join(', ')})`,
})),
)
const selectedVersionId = computed({
get: () => selectedVersion.value?.id ?? null,
set: (value) => {
if (!value) return
selectedVersion.value = (versions.value ?? []).find((version) => version.id === value) ?? null
},
})
defineExpose({
show: (instanceVal, projectVal, projectVersions, selected, callback) => {
instance.value = instanceVal
versions.value = projectVersions ?? []
selectedVersion.value = selected ?? projectVersions?.[0] ?? null
project.value = projectVal
onInstall.value = callback
installing.value = false
incompatibleModal.value.show()
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
},
})
const install = async () => {
installing.value = true
await installMod(instance.value.path, selectedVersion.value.id, 'standalone').catch(handleError)
installing.value = false
onInstall.value(selectedVersion.value.id)
incompatibleModal.value.hide()
trackEvent('ProjectInstall', {
loader: instance.value.loader,
game_version: instance.value.game_version,
id: project.value,
version_id: selectedVersion.value.id,
project_type: project.value.project_type,
title: project.value.title,
source: 'ProjectIncompatibilityWarningModal',
})
}
</script>
<style lang="scss" scoped>
.data {
text-transform: capitalize;
}
table {
width: 100%;
border-radius: var(--radius-lg);
border-collapse: collapse;
box-shadow: 0 0 0 1px var(--color-button-bg);
}
th {
text-align: left;
padding: 1rem;
background-color: var(--color-bg);
overflow: hidden;
border-bottom: 1px solid var(--color-button-bg);
}
th:first-child {
border-top-left-radius: var(--radius-lg);
border-right: 1px solid var(--color-button-bg);
}
th:last-child {
border-top-right-radius: var(--radius-lg);
}
td {
padding: 1rem;
}
td:first-child {
border-right: 1px solid var(--color-button-bg);
}
.button-group {
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
}
</style>
@@ -149,6 +149,9 @@
"app.browse.server.installing": {
"message": "Installing"
},
"app.content-install.no-compatible-versions": {
"message": "No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically."
},
"app.creation-modal.installing-modpack.description": {
"message": "{fileName}"
},
@@ -1,6 +1,6 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ContentInstallInstance, ContentInstallProjectInfo, ContentItem } from '@modrinth/ui'
import { createContext } from '@modrinth/ui'
import { createContext, defineMessage, useVIntl } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
@@ -34,7 +34,7 @@ import {
} from '@/store/install.js'
interface ModalRef {
show: () => void
show: (initialVersionId?: string) => void
hide: () => void
}
@@ -42,19 +42,14 @@ interface ModpackAlreadyInstalledModalRef {
show: (instanceName: string, instancePath: string) => void
}
interface IncompatibilityWarningModalRef {
show: (
instance: GameInstance,
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
callback: (versionId?: string) => void,
) => void
}
const LOADER_ORDER = ['vanilla', 'fabric', 'quilt', 'neoforge', 'forge']
const SUPPORTED_LOADERS: Set<string> = new Set(['vanilla', 'forge', 'fabric', 'quilt', 'neoforge'])
const VANILLA_COMPATIBLE_LOADERS: Set<string> = new Set(['minecraft', 'datapack'])
const noCompatibleVersionsMessage = defineMessage({
id: 'app.content-install.no-compatible-versions',
defaultMessage:
'No available versions match {compatibilityLabel}. Select a version to install anyway. Dependencies will not be installed automatically.',
})
function sortLoaders(loaders: string[]): string[] {
return loaders.slice().sort((a, b) => {
@@ -91,7 +86,17 @@ export interface ContentInstallContext {
setModpackAlreadyInstalledModal: (ref: ModpackAlreadyInstalledModalRef) => void
handleModpackDuplicateCreateAnyway: () => Promise<void>
handleModpackDuplicateGoToInstance: (instancePath: string) => void
setIncompatibilityWarningModal: (ref: IncompatibilityWarningModalRef) => void
setIncompatibilityWarningModal: (ref: ModalRef) => void
incompatibilityWarningVersions: Ref<Labrinth.Versions.v2.Version[]>
incompatibilityWarningCurrentGameVersion: Ref<string>
incompatibilityWarningCurrentLoader: Ref<string>
incompatibilityWarningProjectType: Ref<string | undefined>
incompatibilityWarningProjectIconUrl: Ref<string | undefined>
incompatibilityWarningProjectName: Ref<string | undefined>
incompatibilityWarningMessage: Ref<string | undefined>
incompatibilityWarningInstalling: Ref<boolean>
handleIncompatibilityWarningInstall: (version: Labrinth.Versions.v2.Version) => Promise<void>
handleIncompatibilityWarningCancel: () => void
install: (
projectId: string,
versionId?: string | null,
@@ -113,6 +118,7 @@ export function createContentInstall(opts: {
router: Router
handleError: (err: unknown) => void
}): ContentInstallContext {
const { formatMessage } = useVIntl()
const instances = ref<ContentInstallInstance[]>([])
const compatibleLoaders = ref<string[]>([])
const gameVersions = ref<string[]>([])
@@ -124,6 +130,14 @@ export function createContentInstall(opts: {
const projectInfo = ref<ContentInstallProjectInfo | null>(null)
const installingItems = ref<Map<string, ContentItem[]>>(new Map())
const incompatibilityWarningVersions = ref<Labrinth.Versions.v2.Version[]>([])
const incompatibilityWarningCurrentGameVersion = ref('')
const incompatibilityWarningCurrentLoader = ref('')
const incompatibilityWarningProjectType = ref<string | undefined>(undefined)
const incompatibilityWarningProjectIconUrl = ref<string | undefined>(undefined)
const incompatibilityWarningProjectName = ref<string | undefined>(undefined)
const incompatibilityWarningMessage = ref<string | undefined>(undefined)
const incompatibilityWarningInstalling = ref(false)
function addInstallingItem(
instancePath: string,
@@ -239,11 +253,15 @@ export function createContentInstall(opts: {
let modalRef: ModalRef | null = null
let modpackAlreadyInstalledModalRef: ModpackAlreadyInstalledModalRef | null = null
let incompatibilityWarningModalRef: IncompatibilityWarningModalRef | null = null
let incompatibilityWarningModalRef: ModalRef | null = null
let currentProject: Labrinth.Projects.v2.Project | null = null
let currentVersions: Labrinth.Versions.v2.Version[] = []
let currentCallback: (versionId?: string) => void = () => {}
let profileMap: Record<string, GameInstance> = {}
let incompatibilityWarningInstance: GameInstance | null = null
let incompatibilityWarningProject: Labrinth.Projects.v2.Project | null = null
let incompatibilityWarningCallback: (versionId?: string) => void = () => {}
let incompatibilityWarningInstalled = false
let pendingModpackInstall: {
project: Labrinth.Projects.v2.Project
@@ -410,15 +428,35 @@ export function createContentInstall(opts: {
async function handleInstallToInstance(instance: ContentInstallInstance) {
const profile = profileMap[instance.id]
const storeInstance = instances.value.find((i) => i.id === instance.id)
if (storeInstance) storeInstance.installing = true
if (!currentProject || !profile) {
opts.handleError('No project or instance found')
return
}
const version = findPreferredVersion(currentVersions, currentProject, profile)
if (!version) {
if (storeInstance) storeInstance.installing = false
opts.handleError('No compatible version found')
if (currentVersions.length > 0 && incompatibilityWarningModalRef) {
const onIncompatibleInstall = (versionId?: string) => {
if (versionId && storeInstance) {
storeInstance.installed = true
}
currentCallback(versionId)
}
await showIncompatibilityWarning(
profile,
currentProject,
currentVersions,
currentVersions[0],
onIncompatibleInstall,
)
} else {
opts.handleError('No version found')
}
return
}
if (storeInstance) storeInstance.installing = true
const installedProjectIds: string[] = []
if (currentProject) {
addInstallingItem(instance.id, currentProject, version)
@@ -458,6 +496,73 @@ export function createContentInstall(opts: {
}
}
async function showIncompatibilityWarning(
instance: GameInstance,
project: Labrinth.Projects.v2.Project,
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
callback: (versionId?: string) => void,
) {
incompatibilityWarningInstance = instance
incompatibilityWarningProject = project
incompatibilityWarningCallback = callback
incompatibilityWarningInstalled = false
incompatibilityWarningInstalling.value = false
incompatibilityWarningVersions.value = versions
incompatibilityWarningCurrentGameVersion.value = instance.game_version ?? ''
incompatibilityWarningCurrentLoader.value = instance.loader ?? ''
incompatibilityWarningProjectType.value = project.project_type
incompatibilityWarningProjectIconUrl.value = project.icon_url ?? undefined
incompatibilityWarningProjectName.value = project.title
const compatibilityLabel =
project.project_type === 'resourcepack' || project.project_type === 'datapack'
? (instance.game_version ?? '')
: `${instance.loader ?? ''} ${instance.game_version ?? ''}`.trim()
incompatibilityWarningMessage.value = formatMessage(noCompatibleVersionsMessage, {
compatibilityLabel,
})
await nextTick()
incompatibilityWarningModalRef?.show(version.id)
trackEvent('ProjectInstallStart', { source: 'ProjectIncompatibilityWarningModal' })
}
async function handleIncompatibilityWarningInstall(version: Labrinth.Versions.v2.Version) {
if (!incompatibilityWarningInstance || !incompatibilityWarningProject) return
incompatibilityWarningInstalling.value = true
try {
await add_project_from_version(incompatibilityWarningInstance.path, version.id, 'standalone')
} catch (err) {
opts.handleError(err)
incompatibilityWarningInstalling.value = false
return
}
incompatibilityWarningInstalling.value = false
incompatibilityWarningInstalled = true
incompatibilityWarningCallback(version.id)
incompatibilityWarningModalRef?.hide()
trackEvent('ProjectInstall', {
loader: incompatibilityWarningInstance.loader,
game_version: incompatibilityWarningInstance.game_version,
id: incompatibilityWarningProject.id,
version_id: version.id,
project_type: incompatibilityWarningProject.project_type,
title: incompatibilityWarningProject.title,
source: 'ProjectIncompatibilityWarningModal',
})
}
function handleIncompatibilityWarningCancel() {
if (!incompatibilityWarningInstalled) {
incompatibilityWarningCallback()
}
incompatibilityWarningInstalled = false
}
async function handleCreateAndInstall(data: {
name: string
iconPath: string | null
@@ -614,7 +719,7 @@ export function createContentInstall(opts: {
removeInstallingItems(instancePath, installedProjectIds)
}
} else {
incompatibilityWarningModalRef?.show(instance, project, projectVersions, version, callback)
await showIncompatibilityWarning(instance, project, projectVersions, version, callback)
}
} else {
let versions = (
@@ -668,9 +773,19 @@ export function createContentInstall(opts: {
pendingModpackInstall = null
opts.router.push(`/instance/${encodeURIComponent(instancePath)}`)
},
setIncompatibilityWarningModal(ref: IncompatibilityWarningModalRef) {
setIncompatibilityWarningModal(ref: ModalRef) {
incompatibilityWarningModalRef = ref
},
incompatibilityWarningVersions,
incompatibilityWarningCurrentGameVersion,
incompatibilityWarningCurrentLoader,
incompatibilityWarningProjectType,
incompatibilityWarningProjectIconUrl,
incompatibilityWarningProjectName,
incompatibilityWarningMessage,
incompatibilityWarningInstalling,
handleIncompatibilityWarningInstall,
handleIncompatibilityWarningCancel,
install,
installingItems,
}
@@ -79,9 +79,6 @@
variant === 'outlined'
? 'bg-transparent border border-solid border-button-bg rounded-l-xl border-r-0'
: 'bg-surface-4 border-none rounded-xl',
{
'placeholder:text-sm': type === 'search',
},
]"
@input="onInput"
@focus="isFocused = true"
@@ -5,7 +5,7 @@
:button-class="buttonClass ?? 'flex flex-col gap-2 justify-start items-start'"
:content-class="contentClass"
title-wrapper-class="flex flex-col gap-2 justify-start items-start"
:open-by-default="!locked && (openByDefault !== undefined ? openByDefault : true)"
:open-by-default="openByDefault !== undefined ? openByDefault : true"
>
<template #title>
<slot name="header" :filter="filterType">
@@ -43,7 +43,14 @@ const buttonClass = computed(() => {
const contentClass = computed(() => (isApp.value ? 'mt-2 mb-3' : 'mb-4 mx-3'))
const innerPanelClass = computed(() => (isApp.value ? 'ml-2 mr-3' : 'p-1'))
function hasProvidedFilter(filterId: string): boolean {
return (ctx.providedFilters?.value ?? []).some((filter) => filter.type === filterId)
}
function getFilterOpenByDefault(filterId: string): boolean {
if (hasProvidedFilter(filterId)) {
return true
}
if (ctx.isServerType.value) {
return ![
'server_category_minecraft_server_meta',
@@ -8,12 +8,12 @@
>
<div class="flex flex-col gap-6">
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
{{ formatMessage(messages.admonitionBody, { count: props.count }) }}
{{ formatMessage(messages.admonitionBody, { count: visibleCount }) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
:backup-name="
props.backupTip ? `Before bulk update (${props.backupTip})` : 'Before bulk update'
visibleBackupTip ? `Before bulk update (${visibleBackupTip})` : 'Before bulk update'
"
:shift-click-hint-override="formatMessage(messages.shiftClickHint)"
@update:buttons-disabled="buttonsDisabled = $event"
@@ -35,7 +35,7 @@
@click="confirm"
>
<DownloadIcon />
{{ formatMessage(messages.updateButton, { count: props.count }) }}
{{ formatMessage(messages.updateButton, { count: visibleCount }) }}
</button>
</ButtonStyled>
</div>
@@ -97,8 +97,12 @@ const emit = defineEmits<{
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
const visibleCount = ref(props.count)
const visibleBackupTip = ref(props.backupTip)
function show() {
visibleCount.value = props.count
visibleBackupTip.value = props.backupTip
modal.value?.show()
}
@@ -3,7 +3,7 @@
ref="modal"
:header="
formatMessage(messages.header, {
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
})
"
:fade="props.variant === 'server' ? 'warning' : 'danger'"
@@ -41,8 +41,8 @@
<TrashIcon />
{{
formatMessage(messages.deleteButton, {
count: props.count,
itemType: formatContentTypeSentence(formatMessage, props.itemType, props.count),
count: visibleCount,
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
})
}}
</button>
@@ -110,8 +110,12 @@ const emit = defineEmits<{
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = ref(false)
const visibleCount = ref(props.count)
const visibleItemType = ref(props.itemType)
function show() {
visibleCount.value = props.count
visibleItemType.value = props.itemType
modal.value?.show()
}
@@ -98,14 +98,10 @@
v-for="inst in filteredInstances"
:key="inst.id"
class="flex items-center justify-between px-6 py-1.5"
:class="
!inst.compatible ? 'opacity-40' : inst.installed ? 'opacity-60' : 'hover:bg-surface-3'
"
:class="inst.installed ? 'opacity-60' : 'hover:bg-surface-3'"
>
<button
v-tooltip="
!inst.compatible ? 'This instance is not compatible with this project' : undefined
"
v-tooltip="!inst.compatible ? formatMessage(messages.incompatibleTooltip) : undefined"
class="flex min-w-0 cursor-pointer items-center gap-2.5 overflow-hidden border-0 bg-transparent p-0 text-left"
@click="emit('navigate', inst)"
>
@@ -120,8 +116,17 @@
{{ formatMessage(messages.installedBadge) }}
</button>
</ButtonStyled>
<ButtonStyled v-else-if="inst.compatible">
<button :disabled="inst.installing" @click="emit('install', inst)">
<ButtonStyled
v-else
:type="inst.compatible ? 'standard' : 'outlined'"
:color="inst.compatible ? 'standard' : 'orange'"
>
<button
v-tooltip="!inst.compatible ? formatMessage(messages.incompatibleTooltip) : undefined"
:disabled="inst.installing"
@click="emit('install', inst)"
>
<TriangleAlertIcon v-if="!inst.compatible" />
{{
inst.installing
? formatMessage(commonMessages.installingLabel)
@@ -247,6 +252,7 @@ import {
EyeIcon,
EyeOffIcon,
SearchIcon,
TriangleAlertIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
@@ -296,6 +302,11 @@ const messages = defineMessages({
id: 'instances.content-install.install-button',
defaultMessage: 'Install',
},
incompatibleTooltip: {
id: 'instances.content-install.incompatible-tooltip',
defaultMessage:
'This instance uses a different loader or game version than this project supports.',
},
selectIcon: {
id: 'instances.content-install.select-icon',
defaultMessage: 'Select icon',
@@ -452,7 +463,7 @@ function removeIcon() {
function resetState() {
tab.value = props.defaultTab ?? 'existing'
searchFilter.value = ''
hideUninstallable.value = true
hideUninstallable.value = false
instanceName.value = `New instance (${props.instances.length + 1})`
iconPath.value = null
iconPreviewUrl.value = null
@@ -471,7 +482,6 @@ function resetState() {
}
function handleHide() {
resetState()
emit('cancel')
}
@@ -3,20 +3,12 @@
ref="modal"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
:on-hide="handleModalHide"
no-padding
>
<template #title>
<Avatar v-if="projectIconUrl" :src="projectIconUrl" size="3rem" :tint-by="projectName" />
<span class="text-lg font-extrabold text-contrast">{{
header ??
formatMessage(
isModpack
? messages.switchModpackVersionHeader
: switchMode
? messages.switchVersionHeader
: messages.updateVersionHeader,
)
}}</span>
<span class="text-lg font-extrabold text-contrast">{{ header ?? defaultHeader }}</span>
</template>
<div
class="flex h-[min(550px,calc(95vh-10rem))] border-solid border-transparent border-[1px] border-b-surface-4"
@@ -99,7 +91,7 @@
</div>
<div
v-if="!isModpack"
v-if="!isModpack && !incompatibilityWarningMode"
class="absolute bottom-0 left-0 right-0 pointer-events-none flex flex-col items-center justify-end bg-gradient-to-b from-transparent to-bg-raised to-70% pb-3 h-24"
>
<div class="pointer-events-auto">
@@ -204,7 +196,14 @@
>
<TriangleAlertIcon class="size-6 shrink-0" />
<span>{{
formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
warning ??
formatMessage(
incompatibilityWarningMode
? messages.incompatibilityWarning
: isApp
? messages.updateWarningApp
: messages.updateWarningWeb,
)
}}</span>
</div>
<div class="flex flex-row gap-2 shrink-0 ml-auto">
@@ -214,26 +213,34 @@
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<ButtonStyled :color="incompatibilityWarningMode ? 'orange' : 'brand'">
<button
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
:disabled="
props.actionDisabled || !selectedVersion || selectedVersion.id === currentVersionId
actionLoading ||
props.actionDisabled ||
!selectedVersion ||
(!incompatibilityWarningMode && selectedVersion.id === currentVersionId)
"
@click="handleUpdate"
>
<DownloadIcon />
<SpinnerIcon v-if="actionLoading" class="size-5 animate-spin" />
<DownloadIcon v-else />
{{
formatMessage(
isDowngrade
? messages.downgradeToVersion
: switchMode
? messages.switchToVersion
: messages.updateToVersion,
{
version: selectedVersion?.version_number ?? '...',
},
)
actionLoading
? formatMessage(commonMessages.installingLabel)
: incompatibilityWarningMode
? formatMessage(messages.installAnywayButton)
: formatMessage(
isDowngrade
? messages.downgradeToVersion
: switchMode
? messages.switchToVersion
: messages.updateToVersion,
{
version: selectedVersion?.version_number ?? '...',
},
)
}}
</button>
</ButtonStyled>
@@ -270,7 +277,12 @@ import {
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
import {
capitalizeString,
formatVersionsForDisplay,
type GameVersionTag,
renderHighlightedString,
} from '@modrinth/utils'
import { useTimeoutFn } from '@vueuse/core'
import { computed, ref, watch } from 'vue'
@@ -282,6 +294,7 @@ import NewModal from '#ui/components/modal/NewModal.vue'
import VersionChannelIndicator from '#ui/components/version/VersionChannelIndicator.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { injectTags } from '#ui/providers'
import { commonMessages } from '#ui/utils/common-messages'
import {
versionChangesGameVersion,
@@ -290,12 +303,17 @@ import {
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ContentUpdaterModal')
const tags = injectTags(null)
const messages = defineMessages({
updateVersionHeader: {
id: 'instances.updater-modal.header',
defaultMessage: 'Update version',
},
incompatibilityWarningHeader: {
id: 'instances.updater-modal.incompatibility-warning-header',
defaultMessage: 'Choose version',
},
switchModpackVersionHeader: {
id: 'instances.updater-modal.header-modpack',
defaultMessage: 'Switch modpack version',
@@ -333,6 +351,11 @@ const messages = defineMessages({
id: 'instances.updater-modal.warning-web',
defaultMessage: 'Updating can break your world. Review version changelogs and back up first.',
},
incompatibilityWarning: {
id: 'instances.updater-modal.incompatibility-warning',
defaultMessage:
'This version is not marked as compatible with this instance. Dependencies will not be installed automatically.',
},
downgradeToVersion: {
id: 'instances.updater-modal.downgrade-to',
defaultMessage: 'Downgrade to {version}',
@@ -378,6 +401,10 @@ const messages = defineMessages({
id: 'instances.updater-modal.incompatible-update.proceed',
defaultMessage: 'Update anyway',
},
installAnywayButton: {
id: 'instances.updater-modal.install-anyway',
defaultMessage: 'Install anyway',
},
})
const props = withDefaults(
@@ -392,6 +419,9 @@ const props = withDefaults(
projectIconUrl?: string
projectName?: string
header?: string
mode?: 'version' | 'incompatibility-warning'
warning?: string
actionLoading?: boolean
/** Whether versions are currently being loaded */
loading?: boolean
/** Whether changelog is being loaded for the selected version */
@@ -404,6 +434,9 @@ const props = withDefaults(
projectIconUrl: undefined,
projectName: undefined,
header: undefined,
mode: 'version',
warning: undefined,
actionLoading: false,
loading: false,
loadingChangelog: false,
actionDisabled: false,
@@ -412,6 +445,20 @@ const props = withDefaults(
)
const isModpack = computed(() => props.projectType === 'modpack')
const incompatibilityWarningMode = computed(() => props.mode === 'incompatibility-warning')
const defaultHeader = computed(() => {
if (incompatibilityWarningMode.value) {
return formatMessage(messages.incompatibilityWarningHeader)
}
return formatMessage(
isModpack.value
? messages.switchModpackVersionHeader
: switchMode.value
? messages.switchVersionHeader
: messages.updateVersionHeader,
)
})
const emit = defineEmits<{
update: [version: Labrinth.Versions.v2.Version, event: MouseEvent]
@@ -431,6 +478,7 @@ const pendingIncompatibleUpdate = ref<{
version: Labrinth.Versions.v2.Version
event: MouseEvent
} | null>(null)
const suppressCancelOnHide = ref(false)
// Store the initial version ID to select when versions become available
const pendingInitialVersionId = ref<string | undefined>(undefined)
const pinnedInitialVersionId = ref<string | undefined>(undefined)
@@ -509,12 +557,16 @@ const filteredVersions = computed(() => {
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
versions = versions.filter(
(v) => v.name.toLowerCase().includes(query) || v.version_number.toLowerCase().includes(query),
(v) =>
v.name.toLowerCase().includes(query) ||
v.version_number.toLowerCase().includes(query) ||
(incompatibilityWarningMode.value &&
[...v.loaders, ...v.game_versions].some((value) => value.toLowerCase().includes(query))),
)
}
const beforeFilterCount = versions.length
if (!isModpack.value && hideIncompatibleState.value) {
if (!incompatibilityWarningMode.value && !isModpack.value && hideIncompatibleState.value) {
versions = versions.filter(
(version) =>
version.id === props.currentVersionId ||
@@ -537,6 +589,7 @@ const filteredVersions = computed(() => {
})
function shouldShowBadge(version: Labrinth.Versions.v2.Version): boolean {
if (incompatibilityWarningMode.value) return false
return version.id === props.currentVersionId || shouldShowIncompatibleBadge(version)
}
@@ -596,8 +649,20 @@ function formatLongDate(dateString: string): string {
function formatLoaderGameVersion(version: Labrinth.Versions.v2.Version): string {
const loader = capitalizeString(version.loaders[0] || '')
const gameVersion = version.game_versions[0] || ''
return `${loader} ${gameVersion}`
const gameVersions = formatGameVersions(version)
return [loader, gameVersions].filter(Boolean).join(' ')
}
function formatGameVersions(version: Labrinth.Versions.v2.Version): string {
if (!incompatibilityWarningMode.value) {
return version.game_versions[0] || ''
}
const gameVersions = tags?.gameVersions.value?.length
? formatVersionsForDisplay(version.game_versions, tags.gameVersions.value as GameVersionTag[])
: version.game_versions
return gameVersions.join(', ')
}
let prefetchTimeout: ReturnType<typeof useTimeoutFn> | null = null
@@ -623,8 +688,13 @@ function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
}
function handleUpdate(event: MouseEvent) {
if (props.actionDisabled) return
if (props.actionLoading || props.actionDisabled) return
if (selectedVersion.value) {
if (incompatibilityWarningMode.value) {
emitUpdate(selectedVersion.value, event, { hide: false })
return
}
const changesGameVersion = versionChangesGameVersion(
selectedVersion.value,
props.currentGameVersion,
@@ -689,9 +759,18 @@ function handleCancel() {
hide()
}
function handleModalHide() {
if (suppressCancelOnHide.value) {
suppressCancelOnHide.value = false
return
}
emit('cancel')
}
function show(initialVersionId?: string, options?: { switchMode?: boolean }) {
searchQuery.value = ''
hideIncompatibleState.value = !isModpack.value
hideIncompatibleState.value = incompatibilityWarningMode.value ? false : !isModpack.value
pendingIncompatibleUpdate.value = null
pinnedInitialVersionId.value = initialVersionId
switchMode.value = options?.switchMode ?? false
@@ -735,6 +814,7 @@ function show(initialVersionId?: string, options?: { switchMode?: boolean }) {
}
function hide() {
suppressCancelOnHide.value = true
modal.value?.hide()
}
+12
View File
@@ -1646,6 +1646,9 @@
"instances.content-install.header": {
"defaultMessage": "Install project"
},
"instances.content-install.incompatible-tooltip": {
"defaultMessage": "This instance uses a different loader or game version than this project supports."
},
"instances.content-install.install-button": {
"defaultMessage": "Install"
},
@@ -1718,6 +1721,12 @@
"instances.updater-modal.hide-incompatible": {
"defaultMessage": "Hide incompatible"
},
"instances.updater-modal.incompatibility-warning": {
"defaultMessage": "This version is not marked as compatible with this instance. Dependencies will not be installed automatically."
},
"instances.updater-modal.incompatibility-warning-header": {
"defaultMessage": "Choose version"
},
"instances.updater-modal.incompatible-update.description": {
"defaultMessage": "{version} is not marked as compatible with this installation. It may fail to launch or behave unexpectedly."
},
@@ -1727,6 +1736,9 @@
"instances.updater-modal.incompatible-update.proceed": {
"defaultMessage": "Update anyway"
},
"instances.updater-modal.install-anyway": {
"defaultMessage": "Install anyway"
},
"instances.updater-modal.loading-changelog": {
"defaultMessage": "Loading changelog..."
},