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 15:56:05 +00:00
committed by GitHub
parent 707e219ff8
commit 7366c32df3
12 changed files with 336 additions and 258 deletions
@@ -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..."
},