mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 10:04:52 +00:00
feat: instance sharing thru shared-instances service (#6569)
* feat: implement instance share page + search_users backend call * feat: invite players modal * feat: use tanstack queries for friends sync across app pages * feat: base shared instances implementation * fix: admon style * feat: impl instance admonitions like server panel * fix: impl get + del usage * feat: support modpack links * feat: invite notif accepting * fix: lint + fmt * feat: impl install to play * feat: impl usage of UpdateToPlayModal * feat: warnings on deleting/disabling shared-instance version content * fix: send instance name * feat: align with backend * feat: shared instances qa * feat: wrong account protection * feat: qa * fix: smartly apply updates * fix: install bug * fix: 401/404 differentiation * fix: fmt+prepr * feat: qa * feat: qa * fix: signing out messes up revoke/deleted checks * feat: qa * fix: fmt + lint * feat: lock content if part of shared instance * fix: lint * [do not merge] feat: rough invite links impl temp (#6666) * fix: wrong cmd * feat: invite page * fix: server-manager DTO mismatch * fix: drop anonymous invite link acceptance * refactor: structured shared-instance unavailable errors * refactor: centralise error presentations * refactor: dedupe shared instance diff detection * fix: logging in reqwests * refactor: move app.vue shared instances into handler * refactor: break up Share.vue * refactor: split up shared instances state outside of instance index * refactor: dedicated shared instances install/update modals + split up page * refactor: centralized managed content * refactor: split up install shared to own runner + shared.rs split up * refactor: dedupe sql for instance metadata enrichmnt * refactor: friends composable + dedupe friends logic across usages * chore: reduced unused code * fix: align with backend * fix: lint * fix: file sha changes * fix: invite links not working due to icon signed * feat: qa * feat: reporting frontend dummy * fix: try use header * remove: file hash field * fix: pin box * feat: malware warning for shared instances * fix: cache rule * feat: config files syncing * feat: disable config sharing * fix: header * fix: use mark ready * fix: dont cause push update for configs * fix: lint * feat: sharing page in settings * feat: move config + change flow * fix: qa * fix: lint prepr * feat: proxy file upload thru shared instances backend * fix: use collapisible * fix: push config * fix: config * feat: swap out sign in modal for new one * fix: report flow * fix: exclude configs.zip from external warnings * fix: nuxi init * fix: config bundle downloading * fix: error notif * fix: polling * fix: qa * fix: lint + prepr * feat: shared instances moderation frontend + hook up report flow * fix: report copy * fix: lint * fix: lint * fix: modrinth ids being undefined * feat: instance quarantining * fix: prepr + fmt * fix: quarantined -> locked terminology * fix: missing endpoint impls + fmt * fix: missing api in build.rs * fix: share tab jittery * fix: fmt *PT bug * fix: invites count as users even if pending * fix: prepr * fix: invite page owner in users list * fix: lint * fix: qa * fix: lint * fix: members stale not clearing * fix: invite use joined_at field * fix: lint * fix: qa --------- Co-authored-by: sychic <47618543+Sychic@users.noreply.github.com>
This commit is contained in:
@@ -99,7 +99,7 @@ function getProjectCardTags(result: Labrinth.Search.v3.ResultSearchProject, disp
|
||||
<template v-if="ctx.installContext?.value && ctx.variant !== 'web'">
|
||||
<div
|
||||
ref="stickyInstallHeaderRef"
|
||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 p-3 border-surface-5"
|
||||
class="sticky top-0 z-20 -mx-6 -mt-6 rounded-tl-[--radius-xl] border-0 border-b border-solid bg-surface-1 px-3 py-4 border-surface-5"
|
||||
:class="[isInstallHeaderStuck ? 'border-t' : '']"
|
||||
>
|
||||
<BrowseInstallHeader />
|
||||
|
||||
@@ -27,6 +27,7 @@ interface Props {
|
||||
hideDelete?: boolean
|
||||
hideHeader?: boolean
|
||||
flat?: boolean
|
||||
showItemActions?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -38,6 +39,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
hideDelete: false,
|
||||
hideHeader: false,
|
||||
flat: false,
|
||||
showItemActions: false,
|
||||
})
|
||||
|
||||
const stickyHeaderRef = ref<HTMLElement | null>(null)
|
||||
@@ -67,7 +69,8 @@ const hasEnabledListener = computed(
|
||||
const hasAnyActions = computed(() => {
|
||||
// Check if there are listeners for actions
|
||||
const hasListeners =
|
||||
(hasDeleteListener.value && !props.hideDelete) ||
|
||||
(hasDeleteListener.value &&
|
||||
props.items.some((item) => !props.hideDelete && !item.hideDelete)) ||
|
||||
hasUpdateListener.value ||
|
||||
hasSwitchVersionListener.value ||
|
||||
hasEnabledListener.value
|
||||
@@ -80,7 +83,7 @@ const hasAnyActions = computed(() => {
|
||||
item.enabled !== undefined,
|
||||
)
|
||||
|
||||
return hasListeners || hasItemActions
|
||||
return hasListeners || hasItemActions || props.showItemActions
|
||||
})
|
||||
|
||||
// Virtualization
|
||||
@@ -273,7 +276,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:toggle-disabled="item.toggleDisabled"
|
||||
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
|
||||
:show-checkbox="showSelection"
|
||||
:hide-delete="hideDelete"
|
||||
:hide-delete="hideDelete || item.hideDelete"
|
||||
:hide-actions="!hasAnyActions"
|
||||
:selected="isItemSelected(item.id)"
|
||||
:class="[
|
||||
@@ -296,6 +299,9 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
hasSwitchVersionListener ? { switchVersion: () => emit('switchVersion', item.id) } : {}
|
||||
"
|
||||
>
|
||||
<template #title-badges>
|
||||
<slot name="itemTitleBadges" :item="item" :index="visibleRange.start + idx" />
|
||||
</template>
|
||||
<template #additionalButtonsLeft>
|
||||
<slot name="itemButtonsLeft" :item="item" :index="visibleRange.start + idx" />
|
||||
</template>
|
||||
@@ -326,13 +332,14 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
:has-update="item.hasUpdate"
|
||||
:is-client-only="item.isClientOnly"
|
||||
:client-warning="item.clientWarning"
|
||||
:hide-switch-version="item.hideSwitchVersion"
|
||||
:overflow-options="item.overflowOptions"
|
||||
:disabled="item.disabled"
|
||||
:disabled-tooltip="item.disabledTooltip"
|
||||
:toggle-disabled="item.toggleDisabled"
|
||||
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
|
||||
:show-checkbox="showSelection"
|
||||
:hide-delete="hideDelete"
|
||||
:hide-delete="hideDelete || item.hideDelete"
|
||||
:hide-actions="!hasAnyActions"
|
||||
:selected="isItemSelected(item.id)"
|
||||
:class="[
|
||||
@@ -350,6 +357,9 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
@update="emit('update', item.id)"
|
||||
@switch-version="emit('switchVersion', item.id)"
|
||||
>
|
||||
<template #title-badges>
|
||||
<slot name="itemTitleBadges" :item="item" :index="index" />
|
||||
</template>
|
||||
<template #additionalButtonsLeft>
|
||||
<slot name="itemButtonsLeft" :item="item" :index="index" />
|
||||
</template>
|
||||
|
||||
@@ -7,9 +7,7 @@ import {
|
||||
MoreVerticalIcon,
|
||||
Settings2Icon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Tooltip } from 'floating-vue'
|
||||
import { computed, getCurrentInstance, onMounted, onUnmounted, ref } from 'vue'
|
||||
import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
@@ -23,7 +21,7 @@ import OverflowMenu, {
|
||||
import TagTagItem from '#ui/components/base/TagTagItem.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import { useRelativeTime } from '#ui/composables/how-ago'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type {
|
||||
@@ -35,21 +33,6 @@ import type {
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
contentHintTitle: {
|
||||
id: 'content.modpack-card.content-hint-title',
|
||||
defaultMessage: 'Modpack content moved',
|
||||
},
|
||||
contentHintDescription: {
|
||||
id: 'content.modpack-card.content-hint-description',
|
||||
defaultMessage: "Your modpack's content can now be found here!",
|
||||
},
|
||||
dismissHint: {
|
||||
id: 'content.modpack-card.dismiss-hint',
|
||||
defaultMessage: "Don't show again",
|
||||
},
|
||||
})
|
||||
|
||||
interface Props {
|
||||
project: ContentModpackCardProject
|
||||
projectLink?: string | RouteLocationRaw
|
||||
@@ -61,7 +44,6 @@ interface Props {
|
||||
overflowOptions?: OverflowMenuOption[]
|
||||
hasUpdate?: boolean
|
||||
disabledText?: string
|
||||
showContentHint?: boolean
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
@@ -74,14 +56,12 @@ withDefaults(defineProps<Props>(), {
|
||||
overflowOptions: undefined,
|
||||
hasUpdate: false,
|
||||
disabledText: undefined,
|
||||
showContentHint: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: []
|
||||
content: []
|
||||
settings: []
|
||||
'dismiss-content-hint': []
|
||||
}>()
|
||||
|
||||
const instance = getCurrentInstance()
|
||||
@@ -143,7 +123,7 @@ onUnmounted(() => {
|
||||
<div class="flex flex-wrap items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-4">
|
||||
<AutoLink :to="projectLink" class="shrink-0">
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow raised />
|
||||
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow />
|
||||
</AutoLink>
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<div class="flex min-w-0 flex-col">
|
||||
@@ -230,60 +210,15 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<Tooltip
|
||||
v-if="hasContentListener"
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex"
|
||||
:triggers="[]"
|
||||
:shown="showContentHint && isExpanded"
|
||||
:auto-hide="false"
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ButtonStyled>
|
||||
<button
|
||||
class="!shadow-none"
|
||||
@click="
|
||||
() => {
|
||||
emit('content')
|
||||
emit('dismiss-content-hint')
|
||||
}
|
||||
"
|
||||
>
|
||||
<BoxesIcon />
|
||||
{{ formatMessage(commonMessages.contentLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<template #popper>
|
||||
<div class="grid grid-cols-[min-content] gap-1">
|
||||
<div class="flex min-w-48 items-center justify-between gap-8">
|
||||
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
||||
{{ formatMessage(messages.contentHintTitle) }}
|
||||
</h3>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismissHint)"
|
||||
@click="emit('dismiss-content-hint')"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
{{ formatMessage(messages.contentHintDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<ButtonStyled v-if="hasContentListener">
|
||||
<button class="!shadow-none" @click="emit('content')">
|
||||
<BoxesIcon />
|
||||
{{ formatMessage(commonMessages.contentLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled v-if="hasSettingsListener" type="outlined" circular>
|
||||
<button
|
||||
@click="
|
||||
() => {
|
||||
emit('settings')
|
||||
emit('dismiss-content-hint')
|
||||
}
|
||||
"
|
||||
>
|
||||
<button @click="emit('settings')">
|
||||
<Settings2Icon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
@@ -300,53 +235,19 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Tooltip
|
||||
v-if="collapsedOptions.length"
|
||||
theme="dismissable-prompt"
|
||||
class="inline-flex"
|
||||
:triggers="[]"
|
||||
:shown="showContentHint && !isExpanded"
|
||||
:auto-hide="false"
|
||||
placement="bottom-end"
|
||||
>
|
||||
<ButtonStyled circular type="outlined"
|
||||
><TeleportOverflowMenu
|
||||
:options="collapsedOptions"
|
||||
class="flex @[700px]:hidden"
|
||||
@open="emit('dismiss-content-hint')"
|
||||
>
|
||||
<MoreVerticalIcon class="size-5" />
|
||||
<template #content>
|
||||
<BoxesIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.contentLabel) }}
|
||||
</template>
|
||||
<template #settings>
|
||||
<Settings2Icon class="size-5" />
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu></ButtonStyled
|
||||
>
|
||||
<template #popper>
|
||||
<div class="grid grid-cols-[min-content] gap-1">
|
||||
<div class="flex min-w-48 items-center justify-between gap-8">
|
||||
<h3 class="m-0 whitespace-nowrap text-base font-bold text-contrast">
|
||||
{{ formatMessage(messages.contentHintTitle) }}
|
||||
</h3>
|
||||
<ButtonStyled size="small" circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.dismissHint)"
|
||||
@click="emit('dismiss-content-hint')"
|
||||
>
|
||||
<XIcon aria-hidden="true" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
{{ formatMessage(messages.contentHintDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Tooltip>
|
||||
<ButtonStyled v-if="collapsedOptions.length" circular type="outlined">
|
||||
<TeleportOverflowMenu :options="collapsedOptions" class="flex @[700px]:hidden">
|
||||
<MoreVerticalIcon class="size-5" />
|
||||
<template #content>
|
||||
<BoxesIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.contentLabel) }}
|
||||
</template>
|
||||
<template #settings>
|
||||
<Settings2Icon class="size-5" />
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled
|
||||
v-if="overflowOptions?.length"
|
||||
|
||||
@@ -94,6 +94,7 @@ interface Props {
|
||||
bulkItemCount?: number
|
||||
ariaLabel?: string
|
||||
getItemId?: (item: ContentItem) => string
|
||||
toggleItems?: ContentItem[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -109,6 +110,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
bulkItemCount: 0,
|
||||
ariaLabel: undefined,
|
||||
getItemId: undefined,
|
||||
toggleItems: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -130,8 +132,10 @@ function resolveItemId(item: ContentItem) {
|
||||
return props.getItemId?.(item) ?? item.file_path ?? item.file_name ?? item.id
|
||||
}
|
||||
|
||||
const allDisabled = computed(() => props.selectedItems.every((m) => !m.enabled))
|
||||
const allEnabled = computed(() => props.selectedItems.every((m) => m.enabled))
|
||||
const toggleActionItems = computed(() => props.toggleItems ?? props.selectedItems)
|
||||
const hasToggleActions = computed(() => toggleActionItems.value.length > 0)
|
||||
const allDisabled = computed(() => toggleActionItems.value.every((m) => !m.enabled))
|
||||
const allEnabled = computed(() => toggleActionItems.value.every((m) => m.enabled))
|
||||
|
||||
const selectedCountText = computed(() => {
|
||||
const count = props.isBulkOperating
|
||||
@@ -231,7 +235,7 @@ const bulkProgressMessage = computed(() => {
|
||||
<div v-if="!isBulkOperating" class="ml-auto flex items-center gap-0.5">
|
||||
<slot name="actions" />
|
||||
|
||||
<ButtonStyled type="transparent">
|
||||
<ButtonStyled v-if="hasToggleActions" type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
isBusy && busyTooltip
|
||||
@@ -247,7 +251,7 @@ const bulkProgressMessage = computed(() => {
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.enableButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<ButtonStyled v-if="hasToggleActions" type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
isBusy && busyTooltip
|
||||
|
||||
+31
-9
@@ -11,8 +11,8 @@
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
<Admonition type="warning" :header="admonitionHeader">
|
||||
{{ admonitionBody }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
@@ -36,12 +36,7 @@
|
||||
@click="confirm"
|
||||
>
|
||||
<TrashIcon />
|
||||
{{
|
||||
formatMessage(messages.deleteButton, {
|
||||
count: visibleCount,
|
||||
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
|
||||
})
|
||||
}}
|
||||
{{ deleteButtonLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -51,7 +46,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
@@ -59,6 +54,7 @@ import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
|
||||
|
||||
import type { ContentActionWarning } from '../../types'
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -87,12 +83,14 @@ const props = withDefaults(
|
||||
defineProps<{
|
||||
count: number
|
||||
itemType: string
|
||||
warning?: ContentActionWarning | null
|
||||
variant?: 'instance' | 'server'
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>(),
|
||||
{
|
||||
warning: null,
|
||||
variant: 'instance',
|
||||
backupTip: undefined,
|
||||
actionDisabled: false,
|
||||
@@ -109,11 +107,35 @@ const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
const visibleCount = ref(props.count)
|
||||
const visibleItemType = ref(props.itemType)
|
||||
const visibleWarning = ref(props.warning)
|
||||
|
||||
const formattedItemType = computed(() =>
|
||||
formatContentTypeSentence(formatMessage, visibleItemType.value, visibleCount.value),
|
||||
)
|
||||
|
||||
const admonitionHeader = computed(
|
||||
() => visibleWarning.value?.admonitionHeader ?? formatMessage(messages.admonitionHeader),
|
||||
)
|
||||
|
||||
const admonitionBody = computed(() => {
|
||||
return visibleWarning.value?.admonitionBody ?? formatMessage(messages.admonitionBody)
|
||||
})
|
||||
|
||||
const deleteButtonLabel = computed(() => {
|
||||
return (
|
||||
visibleWarning.value?.actionLabel ??
|
||||
formatMessage(messages.deleteButton, {
|
||||
count: visibleCount.value,
|
||||
itemType: formattedItemType.value,
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
async function show() {
|
||||
await nextTick()
|
||||
visibleCount.value = props.count
|
||||
visibleItemType.value = props.itemType
|
||||
visibleWarning.value = props.warning
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="
|
||||
formatMessage(messages.header, {
|
||||
itemType: formatContentTypeSentence(formatMessage, visibleItemType, visibleCount),
|
||||
})
|
||||
"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="visibleWarning?.admonitionHeader ?? ''">
|
||||
{{ visibleWarning?.admonitionBody }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="orange">
|
||||
<button
|
||||
v-tooltip="props.actionDisabled ? props.actionDisabledTooltip : undefined"
|
||||
:disabled="props.actionDisabled"
|
||||
@click="confirm"
|
||||
>
|
||||
<SlashIcon />
|
||||
{{ visibleWarning?.actionLabel }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SlashIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
|
||||
|
||||
import type { ContentActionWarning } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'content.confirm-disable.header',
|
||||
defaultMessage: 'Disable {itemType}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
count: number
|
||||
itemType: string
|
||||
warning?: ContentActionWarning | null
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
}>(),
|
||||
{
|
||||
actionDisabled: false,
|
||||
actionDisabledTooltip: undefined,
|
||||
warning: null,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'disable'): void
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const visibleCount = ref(props.count)
|
||||
const visibleItemType = ref(props.itemType)
|
||||
const visibleWarning = ref(props.warning)
|
||||
|
||||
async function show() {
|
||||
await nextTick()
|
||||
visibleCount.value = props.count
|
||||
visibleItemType.value = props.itemType
|
||||
visibleWarning.value = props.warning
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
if (props.actionDisabled) return
|
||||
modal.value?.hide()
|
||||
emit('disable')
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
})
|
||||
</script>
|
||||
+4
@@ -19,6 +19,9 @@
|
||||
})
|
||||
}}
|
||||
</Admonition>
|
||||
<Admonition v-if="managedWarning" type="warning" :header="managedWarning.header">
|
||||
{{ managedWarning.body }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
:backup-name="backupName"
|
||||
@@ -65,6 +68,7 @@ import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
downgrade?: boolean
|
||||
managedWarning?: { header: string; body: string } | null
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
actionDisabledTooltip?: string
|
||||
|
||||
+16
-5
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header)"
|
||||
:header="props.header ?? formatMessage(messages.header)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
:on-hide="() => backupCreator?.cancelBackup()"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="warning" :header="formatMessage(messages.admonitionHeader)">
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
<Admonition type="warning" :header="admonitionHeader">
|
||||
{{ admonitionBody }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
@@ -32,7 +32,7 @@
|
||||
@click="confirm"
|
||||
>
|
||||
<UnlinkIcon />
|
||||
{{ formatMessage(props.server ? messages.header : messages.unlinkButton) }}
|
||||
{{ formatMessage(actionMessage) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UnlinkIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
@@ -54,6 +54,8 @@ import { commonMessages } from '#ui/utils/common-messages'
|
||||
import InlineBackupCreator from './InlineBackupCreator.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
header?: string
|
||||
warning?: { header: string; body: string } | null
|
||||
server?: boolean
|
||||
backupTip?: string
|
||||
actionDisabled?: boolean
|
||||
@@ -90,6 +92,15 @@ const emit = defineEmits<{
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
const admonitionHeader = computed(() => {
|
||||
if (props.warning) return props.warning.header
|
||||
return formatMessage(messages.admonitionHeader)
|
||||
})
|
||||
const admonitionBody = computed(() => {
|
||||
if (props.warning) return props.warning.body
|
||||
return formatMessage(messages.admonitionBody)
|
||||
})
|
||||
const actionMessage = computed(() => (props.server ? messages.header : messages.unlinkButton))
|
||||
|
||||
function show() {
|
||||
debug('show: called', {
|
||||
|
||||
+90
-26
@@ -2,6 +2,7 @@
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
BoxIcon,
|
||||
ExternalIcon,
|
||||
FilterIcon,
|
||||
GlassesIcon,
|
||||
PaintbrushIcon,
|
||||
@@ -13,6 +14,7 @@ import { computed, nextTick, ref, watchSyncEffect } from 'vue'
|
||||
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
@@ -33,6 +35,7 @@ import ContentSelectionBar from '../ContentSelectionBar.vue'
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
interface Props {
|
||||
header?: string
|
||||
modpackName?: string
|
||||
modpackIconUrl?: string
|
||||
enableToggle?: boolean
|
||||
@@ -43,6 +46,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
header: undefined,
|
||||
modpackName: undefined,
|
||||
modpackIconUrl: undefined,
|
||||
enableToggle: false,
|
||||
@@ -84,6 +88,18 @@ const messages = defineMessages({
|
||||
id: 'instances.modpack-content-modal.no-results',
|
||||
defaultMessage: 'No projects match your search.',
|
||||
},
|
||||
externalContent: {
|
||||
id: 'instances.modpack-content-modal.external-content',
|
||||
defaultMessage: 'External',
|
||||
},
|
||||
externalContentDescription: {
|
||||
id: 'instances.modpack-content-modal.external-content-description',
|
||||
defaultMessage: 'This file is not published on Modrinth.',
|
||||
},
|
||||
openInSlicer: {
|
||||
id: 'instances.modpack-content-modal.open-in-slicer',
|
||||
defaultMessage: 'Open in Slicer',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ModpackContentModalState {
|
||||
@@ -104,18 +120,17 @@ const selectedFilters = ref<string[]>([])
|
||||
const selectedIds = ref<string[]>([])
|
||||
|
||||
const selectedItems = computed(() =>
|
||||
items.value.filter((item) => selectedIds.value.includes(item.file_name)),
|
||||
items.value.filter((item) => selectedIds.value.includes(item.id)),
|
||||
)
|
||||
|
||||
const allSelected = computed(() => {
|
||||
if (filteredItems.value.length === 0) return false
|
||||
return filteredItems.value.every((item) => selectedIds.value.includes(item.file_name))
|
||||
return filteredItems.value.every((item) => selectedIds.value.includes(item.id))
|
||||
})
|
||||
|
||||
const someSelected = computed(() => {
|
||||
return (
|
||||
filteredItems.value.some((item) => selectedIds.value.includes(item.file_name)) &&
|
||||
!allSelected.value
|
||||
filteredItems.value.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
|
||||
)
|
||||
})
|
||||
|
||||
@@ -123,7 +138,7 @@ function toggleSelectAll() {
|
||||
if (allSelected.value || someSelected.value) {
|
||||
selectedIds.value = []
|
||||
} else {
|
||||
selectedIds.value = filteredItems.value.map((item) => item.file_name)
|
||||
selectedIds.value = filteredItems.value.map((item) => item.id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +175,7 @@ const filterOptions = computed(() => {
|
||||
options.push({ id: 'warnings', label: 'Warnings' })
|
||||
}
|
||||
|
||||
if (items.value.some((item) => !item.enabled)) {
|
||||
if (props.enableToggle && items.value.some((item) => item.enabled === false)) {
|
||||
options.push({ id: 'disabled', label: 'Disabled' })
|
||||
}
|
||||
|
||||
@@ -190,7 +205,7 @@ const attributeFilterIds = new Set(['disabled', 'warnings'])
|
||||
const typeFilteredCount = computed(() => {
|
||||
if (selectedFilters.value.length === 0) return items.value.length
|
||||
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
|
||||
const hasDisabledFilter = selectedFilters.value.includes('disabled')
|
||||
const hasDisabledFilter = props.enableToggle && selectedFilters.value.includes('disabled')
|
||||
const hasWarningsFilter = selectedFilters.value.includes('warnings')
|
||||
return items.value.filter((item) => {
|
||||
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
|
||||
@@ -208,16 +223,12 @@ const filteredItems = computed(() => {
|
||||
if (query) {
|
||||
result = fuse.search(query).map(({ item }) => item)
|
||||
} else {
|
||||
result = [...items.value].sort((a, b) => {
|
||||
const nameA = a.project?.title ?? a.file_name
|
||||
const nameB = b.project?.title ?? b.file_name
|
||||
return nameA.toLowerCase().localeCompare(nameB.toLowerCase())
|
||||
})
|
||||
result = sortContentItems(items.value)
|
||||
}
|
||||
|
||||
if (selectedFilters.value.length > 0) {
|
||||
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
|
||||
const hasDisabledFilter = selectedFilters.value.includes('disabled')
|
||||
const hasDisabledFilter = props.enableToggle && selectedFilters.value.includes('disabled')
|
||||
const hasWarningsFilter = selectedFilters.value.includes('warnings')
|
||||
result = result.filter((item) => {
|
||||
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
|
||||
@@ -228,21 +239,21 @@ const filteredItems = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
return sortContentItems(result, !query)
|
||||
})
|
||||
|
||||
const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
filteredItems.value.map((item) => ({
|
||||
id: item.file_name,
|
||||
id: item.id,
|
||||
project: item.project ?? {
|
||||
id: item.file_name,
|
||||
id: item.id,
|
||||
slug: null,
|
||||
title: item.file_name,
|
||||
icon_url: null,
|
||||
},
|
||||
projectLink: item.project?.id ? `/project/${item.project.id}` : undefined,
|
||||
projectLink: !item.external && item.project?.id ? `/project/${item.project.id}` : undefined,
|
||||
version: item.version ?? {
|
||||
id: item.file_name,
|
||||
id: item.id,
|
||||
version_number: 'Unknown',
|
||||
file_name: item.file_name,
|
||||
},
|
||||
@@ -278,6 +289,20 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
],
|
||||
})),
|
||||
)
|
||||
const externalItemIds = computed(
|
||||
() => new Set(items.value.filter((item) => item.external).map((item) => item.id)),
|
||||
)
|
||||
const externalSlicerUrls = computed(() => {
|
||||
const urls: Record<string, string> = {}
|
||||
for (const item of items.value) {
|
||||
if (item.external && item.external_url) {
|
||||
urls[item.id] = `https://slicer.run/?url=${encodeURIComponent(item.external_url)}`
|
||||
}
|
||||
}
|
||||
return urls
|
||||
})
|
||||
const hasExternalSlicerUrls = computed(() => Object.keys(externalSlicerUrls.value).length > 0)
|
||||
const showTableActions = computed(() => props.enableToggle || hasExternalSlicerUrls.value)
|
||||
|
||||
function getTypeIcon(type: string) {
|
||||
switch (type) {
|
||||
@@ -293,9 +318,23 @@ function getTypeIcon(type: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEnabledChange(fileName: string, value: boolean) {
|
||||
function sortContentItems(contentItems: ContentItem[], sortByName = true) {
|
||||
return [...contentItems].sort((a, b) => {
|
||||
const externalDiff = Number(b.external === true) - Number(a.external === true)
|
||||
if (externalDiff !== 0) return externalDiff
|
||||
if (!sortByName) return 0
|
||||
|
||||
return itemDisplayName(a).toLowerCase().localeCompare(itemDisplayName(b).toLowerCase())
|
||||
})
|
||||
}
|
||||
|
||||
function itemDisplayName(item: ContentItem) {
|
||||
return item.project?.title ?? item.file_name
|
||||
}
|
||||
|
||||
function handleEnabledChange(id: string, value: boolean) {
|
||||
if (props.actionDisabled) return
|
||||
const item = items.value.find((i) => i.file_name === fileName)
|
||||
const item = items.value.find((item) => item.id === id)
|
||||
if (!item) return
|
||||
emit('update:enabled', item, value)
|
||||
}
|
||||
@@ -388,9 +427,10 @@ function updateItem(fileName: string, updates: Partial<ContentItem> & { disabled
|
||||
}
|
||||
|
||||
function setItems(contentItems: ContentItem[]) {
|
||||
const contentIds = new Set(contentItems.map((item) => item.id))
|
||||
const contentFileNames = new Set(contentItems.map((item) => item.file_name))
|
||||
items.value = contentItems.map((item) => ({ ...item }))
|
||||
selectedIds.value = selectedIds.value.filter((id) => contentFileNames.has(id))
|
||||
selectedIds.value = selectedIds.value.filter((id) => contentIds.has(id))
|
||||
disabledIds.value = new Set([...disabledIds.value].filter((id) => contentFileNames.has(id)))
|
||||
loading.value = false
|
||||
}
|
||||
@@ -414,7 +454,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
:tint-by="props.modpackName"
|
||||
/>
|
||||
<span class="text-lg font-extrabold text-contrast">
|
||||
{{ formatMessage(messages.header) }}
|
||||
{{ props.header ?? formatMessage(messages.header) }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="flex flex-col h-[min(600px,calc(95vh-10rem))]">
|
||||
@@ -498,7 +538,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
props.enableToggle
|
||||
showTableActions
|
||||
? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1'
|
||||
"
|
||||
@@ -517,13 +557,13 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
</div>
|
||||
<div
|
||||
class="hidden @[800px]:flex"
|
||||
:class="props.enableToggle ? 'flex-1 min-w-0' : 'flex-1'"
|
||||
:class="showTableActions ? 'flex-1 min-w-0' : 'flex-1'"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.versionLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
<div v-if="props.enableToggle" class="min-w-[160px] shrink-0 text-right">
|
||||
<div v-if="showTableActions" class="min-w-[160px] shrink-0 text-right">
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.actionsLabel)
|
||||
}}</span>
|
||||
@@ -534,6 +574,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
v-model:selected-ids="selectedIds"
|
||||
:items="tableItems"
|
||||
:show-selection="props.enableToggle"
|
||||
:show-item-actions="hasExternalSlicerUrls"
|
||||
hide-delete
|
||||
hide-header
|
||||
flat
|
||||
@@ -542,7 +583,30 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
|
||||
? { 'update:enabled': (id: string, val: boolean) => handleEnabledChange(id, val) }
|
||||
: {}
|
||||
"
|
||||
/>
|
||||
>
|
||||
<template #itemTitleBadges="{ item }">
|
||||
<span
|
||||
v-if="externalItemIds.has(item.id)"
|
||||
v-tooltip="formatMessage(messages.externalContentDescription)"
|
||||
class="inline-flex shrink-0 items-center rounded-full border border-solid border-orange bg-orange-highlight px-2 py-0.5 text-xs font-semibold leading-4 text-orange"
|
||||
>
|
||||
{{ formatMessage(messages.externalContent) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #itemButtonsRight="{ item }">
|
||||
<ButtonStyled v-if="externalSlicerUrls[item.id]" circular type="transparent">
|
||||
<a
|
||||
v-tooltip="formatMessage(messages.openInSlicer)"
|
||||
:aria-label="formatMessage(messages.openInSlicer)"
|
||||
:href="externalSlicerUrls[item.id]"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalIcon class="size-4" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</ContentCardTable>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ export { default as ContentCardTable } from './components/ContentCardTable.vue'
|
||||
export { default as ContentModpackCard } from './components/ContentModpackCard.vue'
|
||||
export { default as ConfirmBulkUpdateModal } from './components/modals/ConfirmBulkUpdateModal.vue'
|
||||
export { default as ConfirmDeletionModal } from './components/modals/ConfirmDeletionModal.vue'
|
||||
export { default as ConfirmDisableModal } from './components/modals/ConfirmDisableModal.vue'
|
||||
export { default as ConfirmModpackUpdateModal } from './components/modals/ConfirmModpackUpdateModal.vue'
|
||||
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
|
||||
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
|
||||
@@ -16,6 +17,7 @@ export type {
|
||||
ContentInstallProjectOwner,
|
||||
} from './components/modals/ContentInstallModal.vue'
|
||||
export { default as ContentInstallModal } from './components/modals/ContentInstallModal.vue'
|
||||
export { default as InlineBackupCreator } from './components/modals/InlineBackupCreator.vue'
|
||||
export type { ModpackContentModalState } from './components/modals/ModpackContentModal.vue'
|
||||
export { default as ModpackContentModal } from './components/modals/ModpackContentModal.vue'
|
||||
export { default as ContentCardLayout } from './layout.vue'
|
||||
|
||||
@@ -33,6 +33,7 @@ import ContentModpackCard from './components/ContentModpackCard.vue'
|
||||
import ContentSelectionBar from './components/ContentSelectionBar.vue'
|
||||
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
|
||||
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
|
||||
import ConfirmDisableModal from './components/modals/ConfirmDisableModal.vue'
|
||||
import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
|
||||
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
|
||||
import {
|
||||
@@ -45,7 +46,12 @@ import {
|
||||
useContentSelection,
|
||||
} from './composables'
|
||||
import { injectContentManager } from './providers/content-manager'
|
||||
import type { BulkOperationStatus, ContentCardTableItem, ContentItem } from './types'
|
||||
import type {
|
||||
BulkOperationStatus,
|
||||
ContentActionWarning,
|
||||
ContentCardTableItem,
|
||||
ContentItem,
|
||||
} from './types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ContentPageLayout')
|
||||
@@ -280,13 +286,14 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
|
||||
toggleDisabled: ctx.isBusy.value,
|
||||
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
|
||||
installing: item.installing === true,
|
||||
hasUpdate: item.has_update,
|
||||
hasUpdate: base.hasUpdate ?? item.has_update,
|
||||
isClientOnly:
|
||||
isClientOnlyEnvironment(item.environment) ||
|
||||
!!item.pack_client_retained ||
|
||||
!!item.pack_client_depends,
|
||||
clientWarning: getClientWarningType(item),
|
||||
hideSwitchVersion: !base.versionLink,
|
||||
hideDelete: base.hideDelete,
|
||||
hideSwitchVersion: base.hideSwitchVersion ?? !base.versionLink,
|
||||
overflowOptions: ctx.getOverflowOptions?.(item),
|
||||
}
|
||||
})
|
||||
@@ -322,8 +329,12 @@ const hasOutdatedProjects = computed(() => {
|
||||
|
||||
// Deletion
|
||||
const pendingDeletionItems = ref<ContentItem[]>([])
|
||||
const pendingDeletionWarning = ref<ContentActionWarning | null>(null)
|
||||
const confirmDeletionModal = ref<InstanceType<typeof ConfirmDeletionModal>>()
|
||||
const confirmDisableModal = ref<InstanceType<typeof ConfirmDisableModal>>()
|
||||
const contentDependencyWarningModal = ref<InstanceType<typeof ContentDependencyWarningModal>>()
|
||||
const pendingDisableItems = ref<ContentItem[]>([])
|
||||
const pendingDisableWarning = ref<ContentActionWarning | null>(null)
|
||||
const pendingDependencyWarningItems = ref<ContentCardTableItem[]>([])
|
||||
const pendingDependencyWarningDependents = ref<
|
||||
Array<{
|
||||
@@ -340,17 +351,30 @@ function mapToDisplayItem(item: ContentItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function canDeleteItem(item: ContentItem) {
|
||||
return ctx.canDeleteItem?.(item) ?? true
|
||||
}
|
||||
|
||||
function canToggleItem(item: ContentItem) {
|
||||
return ctx.canToggleItem?.(item) ?? true
|
||||
}
|
||||
|
||||
const deletableSelectedItems = computed(() => selectedItems.value.filter(canDeleteItem))
|
||||
const toggleableSelectedItems = computed(() => selectedItems.value.filter(canToggleItem))
|
||||
|
||||
async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
|
||||
if (items.length === 0) return
|
||||
pendingDeletionItems.value = items
|
||||
const deletableItems = items.filter(canDeleteItem)
|
||||
if (deletableItems.length === 0) return
|
||||
pendingDeletionItems.value = deletableItems
|
||||
pendingDeletionWarning.value = ctx.getDeleteWarning?.(deletableItems) ?? null
|
||||
pendingDependencyWarningItems.value = []
|
||||
pendingDependencyWarningDependents.value = []
|
||||
pendingDependencyWarningDisableTargets.value = []
|
||||
const deletingIds = new Set(items.map(getItemId))
|
||||
const deletingIds = new Set(deletableItems.map(getItemId))
|
||||
|
||||
const warning = ctx.getDeleteDependencyWarning
|
||||
? await Promise.resolve()
|
||||
.then(() => ctx.getDeleteDependencyWarning!(items))
|
||||
.then(() => ctx.getDeleteDependencyWarning!(deletableItems))
|
||||
.catch(() => null)
|
||||
: null
|
||||
if (warning) {
|
||||
@@ -366,7 +390,7 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
|
||||
const relevantDependencyIds = new Set(
|
||||
remainingDependents.flatMap((dependent) => dependent.dependencies.map(getItemId)),
|
||||
)
|
||||
const warningItems = items.filter((item) => relevantDependencyIds.has(getItemId(item)))
|
||||
const warningItems = deletableItems.filter((item) => relevantDependencyIds.has(getItemId(item)))
|
||||
if (warningItems.length === 0) {
|
||||
showDeletionConfirmation(event)
|
||||
return
|
||||
@@ -390,7 +414,11 @@ async function promptDeleteItems(items: ContentItem[], event?: MouseEvent) {
|
||||
}
|
||||
|
||||
async function showDeletionConfirmation(event?: MouseEvent) {
|
||||
if ((event?.shiftKey || skipNonEssentialWarnings.value) && !ctx.isBusy.value) {
|
||||
if (
|
||||
!pendingDeletionWarning.value &&
|
||||
(event?.shiftKey || skipNonEssentialWarnings.value) &&
|
||||
!ctx.isBusy.value
|
||||
) {
|
||||
confirmDelete()
|
||||
} else {
|
||||
await nextTick()
|
||||
@@ -419,6 +447,11 @@ async function confirmDependencyWarningDelete(disableDependentsAfterDeleting: bo
|
||||
|
||||
pendingDependencyWarningItems.value = []
|
||||
pendingDependencyWarningDependents.value = []
|
||||
if (pendingDeletionWarning.value) {
|
||||
confirmDeletionModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
await confirmDelete()
|
||||
}
|
||||
|
||||
@@ -427,6 +460,10 @@ async function disablePendingDependencyWarningDependents() {
|
||||
pendingDependencyWarningDisableTargets.value = []
|
||||
if (items.length === 0) return
|
||||
|
||||
await promptDisableItems(items)
|
||||
}
|
||||
|
||||
async function disableItemsWithoutWarning(items: ContentItem[]) {
|
||||
if (ctx.bulkDisableItems) {
|
||||
await ctx.bulkDisableItems(items)
|
||||
return
|
||||
@@ -447,6 +484,7 @@ async function confirmDelete() {
|
||||
if (ctx.isBusy.value) return
|
||||
const itemsToDelete = [...pendingDeletionItems.value]
|
||||
pendingDeletionItems.value = []
|
||||
pendingDeletionWarning.value = null
|
||||
if (itemsToDelete.length === 0) return
|
||||
|
||||
if (ctx.bulkDeleteItems && itemsToDelete.length > 1) {
|
||||
@@ -495,10 +533,75 @@ async function confirmDelete() {
|
||||
await disablePendingDependencyWarningDependents()
|
||||
}
|
||||
|
||||
async function promptDisableItems(items: ContentItem[]) {
|
||||
if (items.length === 0) return
|
||||
pendingDisableItems.value = items
|
||||
const warning = ctx.getDisableWarning?.(items) ?? null
|
||||
if (warning) {
|
||||
pendingDisableWarning.value = warning
|
||||
confirmDisableModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
await confirmDisable()
|
||||
}
|
||||
|
||||
async function confirmDisable() {
|
||||
if (ctx.isBusy.value) return
|
||||
const itemsToDisable = [...pendingDisableItems.value]
|
||||
pendingDisableItems.value = []
|
||||
pendingDisableWarning.value = null
|
||||
if (itemsToDisable.length === 0) return
|
||||
|
||||
if (ctx.bulkDisableItems && itemsToDisable.length > 1) {
|
||||
isBulkOperating.value = true
|
||||
bulkOperation.value = 'disable'
|
||||
bulkProgress.value = 0
|
||||
bulkTotal.value = itemsToDisable.length
|
||||
bulkWaiting.value = true
|
||||
try {
|
||||
await disableItemsWithoutWarning(itemsToDisable)
|
||||
} finally {
|
||||
clearSelection()
|
||||
isBulkOperating.value = false
|
||||
bulkOperation.value = null
|
||||
bulkProgress.value = 0
|
||||
bulkTotal.value = 0
|
||||
bulkWaiting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (itemsToDisable.length === 1) {
|
||||
const item = itemsToDisable[0]
|
||||
const id = getItemId(item)
|
||||
markChanging(id)
|
||||
try {
|
||||
if (ctx.bulkDisableItems) {
|
||||
await ctx.bulkDisableItems(itemsToDisable)
|
||||
} else {
|
||||
await ctx.toggleEnabled(item)
|
||||
}
|
||||
} finally {
|
||||
unmarkChanging(id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
await runBulk('disable', itemsToDisable, (item) => disableItemsWithoutWarning([item]), {
|
||||
onComplete: clearSelection,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleToggleEnabledById(id: string, _value: boolean) {
|
||||
if (ctx.isBusy.value) return
|
||||
const item = ctx.items.value.find((i) => getItemId(i) === id)
|
||||
if (!item) return
|
||||
if (!canToggleItem(item)) return
|
||||
if (!_value) {
|
||||
await promptDisableItems([item])
|
||||
return
|
||||
}
|
||||
markChanging(id)
|
||||
try {
|
||||
await ctx.toggleEnabled(item)
|
||||
@@ -509,7 +612,7 @@ async function handleToggleEnabledById(id: string, _value: boolean) {
|
||||
|
||||
async function bulkEnable() {
|
||||
if (ctx.isBusy.value) return
|
||||
const items = selectedItems.value.filter((item) => !item.enabled)
|
||||
const items = toggleableSelectedItems.value.filter((item) => !item.enabled)
|
||||
if (items.length === 0) return
|
||||
if (ctx.bulkEnableItems) {
|
||||
isBulkOperating.value = true
|
||||
@@ -534,27 +637,9 @@ async function bulkEnable() {
|
||||
|
||||
async function bulkDisable() {
|
||||
if (ctx.isBusy.value) return
|
||||
const items = selectedItems.value.filter((item) => item.enabled)
|
||||
const items = toggleableSelectedItems.value.filter((item) => item.enabled)
|
||||
if (items.length === 0) return
|
||||
if (ctx.bulkDisableItems) {
|
||||
isBulkOperating.value = true
|
||||
bulkOperation.value = 'disable'
|
||||
bulkProgress.value = 0
|
||||
bulkTotal.value = items.length
|
||||
bulkWaiting.value = true
|
||||
try {
|
||||
await ctx.bulkDisableItems(items)
|
||||
} finally {
|
||||
clearSelection()
|
||||
isBulkOperating.value = false
|
||||
bulkOperation.value = null
|
||||
bulkProgress.value = 0
|
||||
bulkTotal.value = 0
|
||||
bulkWaiting.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
await runBulk('disable', items, (item) => ctx.toggleEnabled(item), { onComplete: clearSelection })
|
||||
await promptDisableItems(items)
|
||||
}
|
||||
|
||||
function handleUpdateById(id: string) {
|
||||
@@ -696,16 +781,12 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:has-update="ctx.modpack.value.hasUpdate"
|
||||
:disabled="ctx.modpack.value.disabled"
|
||||
:disabled-text="ctx.modpack.value.disabledText"
|
||||
:show-content-hint="
|
||||
!!(ctx.showContentHint?.value && ctx.modpack.value && ctx.items.value.length === 0)
|
||||
"
|
||||
v-on="{
|
||||
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
|
||||
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
|
||||
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
|
||||
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
|
||||
}"
|
||||
@dismiss-content-hint="ctx.dismissContentHint?.()"
|
||||
/>
|
||||
|
||||
<template v-if="ctx.items.value.length > 0">
|
||||
@@ -737,20 +818,6 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
/>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="ctx.browse"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="outlined">
|
||||
<button
|
||||
v-tooltip="
|
||||
@@ -765,6 +832,20 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
{{ formatMessage(messages.uploadFiles) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="
|
||||
ctx.busyMessage?.value ??
|
||||
(ctx.disableAddContent?.value ? ctx.disableAddContentTooltip : undefined)
|
||||
"
|
||||
:disabled="ctx.isBusy.value || ctx.disableAddContent?.value"
|
||||
class="!h-10 flex items-center gap-2"
|
||||
@click="ctx.browse"
|
||||
>
|
||||
<CompassIcon class="size-5" />
|
||||
<span>{{ formatMessage(messages.browseContent) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -947,6 +1028,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:bulk-item-count="bulkItemCount"
|
||||
:aria-label="formatMessage(commonMessages.selectionActionsLabel)"
|
||||
:get-item-id="getItemId"
|
||||
:toggle-items="toggleableSelectedItems"
|
||||
@clear="clearSelection"
|
||||
@enable="bulkEnable"
|
||||
@disable="bulkDisable"
|
||||
@@ -1013,9 +1095,10 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
</template>
|
||||
|
||||
<template #actions-end>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<div v-if="deletableSelectedItems.length > 0" class="mx-1 h-6 w-px bg-surface-5" />
|
||||
|
||||
<ButtonStyled
|
||||
v-if="deletableSelectedItems.length > 0"
|
||||
type="transparent"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
@@ -1036,12 +1119,22 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
ref="confirmDeletionModal"
|
||||
:count="pendingDeletionItems.length"
|
||||
:item-type="ctx.contentTypeLabel.value"
|
||||
:warning="pendingDeletionWarning"
|
||||
:variant="ctx.deletionContext ?? 'instance'"
|
||||
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
|
||||
:action-disabled="ctx.isBusy.value"
|
||||
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
<ConfirmDisableModal
|
||||
ref="confirmDisableModal"
|
||||
:count="pendingDisableItems.length"
|
||||
:item-type="ctx.contentTypeLabel.value"
|
||||
:warning="pendingDisableWarning"
|
||||
:action-disabled="ctx.isBusy.value"
|
||||
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
|
||||
@disable="confirmDisable"
|
||||
/>
|
||||
<ContentDependencyWarningModal
|
||||
ref="contentDependencyWarningModal"
|
||||
:items="pendingDependencyWarningItems"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type {
|
||||
BulkOperationStatus,
|
||||
ContentActionWarning,
|
||||
ContentCardTableItem,
|
||||
ContentItem,
|
||||
ContentModpackCardCategory,
|
||||
@@ -65,6 +66,10 @@ export interface ContentManagerContext {
|
||||
bulkDeleteItems?: (items: ContentItem[]) => Promise<void>
|
||||
bulkEnableItems?: (items: ContentItem[]) => Promise<void>
|
||||
bulkDisableItems?: (items: ContentItem[]) => Promise<void>
|
||||
canDeleteItem?: (item: ContentItem) => boolean
|
||||
canToggleItem?: (item: ContentItem) => boolean
|
||||
getDeleteWarning?: (items: ContentItem[]) => ContentActionWarning | null
|
||||
getDisableWarning?: (items: ContentItem[]) => ContentActionWarning | null
|
||||
getDeleteDependencyWarning?: (
|
||||
items: ContentItem[],
|
||||
) => ContentDependencyWarning | null | Promise<ContentDependencyWarning | null>
|
||||
@@ -100,10 +105,6 @@ export interface ContentManagerContext {
|
||||
// Deletion context (controls modal variant)
|
||||
deletionContext?: 'instance' | 'server'
|
||||
|
||||
// One-time content hint (optional — shows tooltip on modpack content button)
|
||||
showContentHint?: Ref<boolean>
|
||||
dismissContentHint?: () => void
|
||||
|
||||
// Table item mapping (link generation differs per platform)
|
||||
mapToTableItem: (item: ContentItem) => ContentCardTableItem
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@ export interface ContentOwner {
|
||||
|
||||
export type ClientWarningType = 'retained' | 'depends' | 'environment'
|
||||
|
||||
export type ContentSourceKind =
|
||||
| 'local'
|
||||
| 'modrinth_modpack'
|
||||
| 'server_project'
|
||||
| 'modrinth_hosting'
|
||||
| 'imported_modpack'
|
||||
| 'shared_instance'
|
||||
|
||||
export interface ContentActionWarning {
|
||||
admonitionHeader: string
|
||||
admonitionBody: string
|
||||
actionLabel: string
|
||||
}
|
||||
|
||||
export interface ContentCardTableItem {
|
||||
id: string
|
||||
project: ContentCardProject
|
||||
@@ -39,6 +53,7 @@ export interface ContentCardTableItem {
|
||||
hasUpdate?: boolean
|
||||
isClientOnly?: boolean
|
||||
clientWarning?: ClientWarningType | null
|
||||
hideDelete?: boolean
|
||||
hideSwitchVersion?: boolean
|
||||
overflowOptions?: OverflowMenuOption[]
|
||||
}
|
||||
@@ -70,6 +85,9 @@ export interface ContentItem extends Omit<
|
||||
pack_client_retained?: boolean
|
||||
pack_client_depends?: boolean
|
||||
installing?: boolean
|
||||
source_kind?: ContentSourceKind | null
|
||||
external?: boolean
|
||||
external_url?: string
|
||||
}
|
||||
|
||||
export type ContentModpackCardProject = Pick<
|
||||
|
||||
+200
-39
@@ -4,19 +4,17 @@
|
||||
:header="header"
|
||||
:closable="true"
|
||||
:disable-close="disableClose"
|
||||
:on-hide="handleHide"
|
||||
max-width="544px"
|
||||
width="544px"
|
||||
no-padding
|
||||
scrollable
|
||||
>
|
||||
<div class="flex flex-col gap-4" :class="hasExternalDiffs ? 'px-6 py-4' : 'p-4'">
|
||||
<template v-if="hasExternalDiffs">
|
||||
<p v-if="description" class="m-0 text-primary">{{ description }}</p>
|
||||
<Admonition
|
||||
v-if="hasExternalDiffs"
|
||||
type="warning"
|
||||
:header="formatMessage(messages.unknownFilesWarning)"
|
||||
>
|
||||
{{ formatMessage(messages.unknownFilesDescription) }}
|
||||
<Admonition type="warning" :header="formatMessage(messages.unknownFilesWarning)">
|
||||
{{ externalWarningDescription || formatMessage(messages.unknownFilesDescription) }}
|
||||
</Admonition>
|
||||
</template>
|
||||
<Admonition v-else :type="hasUnknownContent ? 'warning' : 'info'" :header="admonitionHeader">
|
||||
@@ -41,17 +39,28 @@
|
||||
<MinusIcon class="size-4" />
|
||||
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
|
||||
</div>
|
||||
<div v-if="removedDisabledCount" class="flex items-center gap-1">
|
||||
<MinusIcon class="size-4" />
|
||||
{{ formatMessage(messages.removedDisabledCount, { count: removedDisabledCount }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="diffs.length"
|
||||
class="flex max-h-[272px] flex-col overflow-y-auto border-0 border-y border-solid border-surface-5 bg-surface-2 px-3 py-4"
|
||||
class="flex max-h-[272px] flex-col overflow-y-auto border-0 border-y border-solid border-surface-5 bg-surface-2 px-3 py-2"
|
||||
>
|
||||
<div v-if="!diffs.length" class="flex h-10 min-h-10 items-center gap-2 px-2">
|
||||
<div class="flex w-4 shrink-0 items-center justify-center">
|
||||
<MinusIcon class="size-4" />
|
||||
</div>
|
||||
<span class="text-sm font-medium text-contrast">
|
||||
{{ formatMessage(messages.noContentChanges) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(diff, index) in sortedDiffs"
|
||||
:key="diff.projectName || diff.fileName || index"
|
||||
:key="diff.projectName || diff.fileName || (isConfigurationDiff(diff) ? diff.type : index)"
|
||||
class="flex h-10 min-h-10 items-center gap-2"
|
||||
:class="showExternalWarning(diff) ? '-mx-3 px-5' : 'px-2'"
|
||||
:style="
|
||||
@@ -67,8 +76,14 @@
|
||||
v-if="index > 0"
|
||||
class="absolute left-1/2 top-0 h-3 w-px -translate-x-1/2 bg-surface-5"
|
||||
/>
|
||||
<PlusIcon v-if="diff.type === 'added'" class="relative z-[1] size-4" />
|
||||
<MinusIcon v-else-if="diff.type === 'removed'" class="relative z-[1] size-4 text-red" />
|
||||
<PlusIcon
|
||||
v-if="diff.type === 'added' || diff.type === 'modpack_linked'"
|
||||
class="relative z-[1] size-4"
|
||||
/>
|
||||
<MinusIcon
|
||||
v-else-if="diff.type === 'removed' || diff.type === 'modpack_unlinked'"
|
||||
class="relative z-[1] size-4 text-red"
|
||||
/>
|
||||
<RefreshCwIcon v-else class="relative z-[1] size-4" />
|
||||
<div
|
||||
v-if="index < sortedDiffs.length - 1"
|
||||
@@ -84,7 +99,10 @@
|
||||
{{ formatMessage(messages.unknownProject) }}
|
||||
</span>
|
||||
</template>
|
||||
<span v-else class="truncate font-medium text-contrast">
|
||||
<span
|
||||
v-else-if="!isConfigurationDiff(diff) || diff.type === 'modpack_updated'"
|
||||
class="truncate font-medium text-contrast"
|
||||
>
|
||||
{{ diff.projectName || (diff.fileName ? decodeURIComponent(diff.fileName) : '') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -99,6 +117,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="$slots['additional-content']" class="px-4 pt-4">
|
||||
<slot name="additional-content" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showBackupCreator"
|
||||
class="p-4 border-t border-solid border-surface-5 border-b-0 border-l-0 border-r-0"
|
||||
@@ -114,38 +136,52 @@
|
||||
<template #actions>
|
||||
<div v-if="hasExternalDiffs" class="flex flex-col gap-6 p-2">
|
||||
<p class="m-0 text-primary">{{ formatMessage(messages.reviewedFiles) }}</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="transparent" color="orange">
|
||||
<button :disabled="buttonsDisabled" @click="handleConfirm">
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleCancel">
|
||||
<BanIcon />
|
||||
{{ formatMessage(messages.dontInstall) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="flex justify-between gap-2">
|
||||
<div>
|
||||
<ButtonStyled v-if="showReportButton" color="red" type="transparent">
|
||||
<button @click="emit('report', $event)">
|
||||
<ReportIcon />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled type="transparent" color="orange">
|
||||
<button :disabled="buttonsDisabled || confirmDisabled" @click="handleConfirm">
|
||||
{{ formatMessage(messages.installAnyway) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleCancel">
|
||||
<BanIcon />
|
||||
{{ formatMessage(messages.dontInstall) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex justify-between gap-2 pt-4">
|
||||
<div
|
||||
v-else
|
||||
class="flex justify-between gap-2"
|
||||
:class="{ 'pt-4': !$slots['additional-content'] }"
|
||||
>
|
||||
<div>
|
||||
<ButtonStyled v-if="showReportButton" color="red" type="transparent">
|
||||
<button @click="emit('report')">
|
||||
<button @click="emit('report', $event)">
|
||||
<ReportIcon />
|
||||
{{ formatMessage(commonMessages.reportButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button @click="handleCancel">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="buttonsDisabled" @click="handleConfirm">
|
||||
<button :disabled="buttonsDisabled || confirmDisabled" @click="handleConfirm">
|
||||
<component :is="confirmIcon" v-if="confirmIcon" />
|
||||
{{ confirmLabel || formatMessage(commonMessages.confirmButton) }}
|
||||
</button>
|
||||
@@ -187,16 +223,19 @@ const props = defineProps<{
|
||||
confirmIcon?: Component
|
||||
showReportButton?: boolean
|
||||
showBackupCreator?: boolean
|
||||
addedLabel?: string
|
||||
removedLabel?: string
|
||||
confirmDisabled?: boolean
|
||||
disableClose?: boolean
|
||||
showExternalWarnings?: boolean
|
||||
externalWarningDescription?: string
|
||||
versionDate?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
confirm: []
|
||||
cancel: []
|
||||
report: []
|
||||
report: [event?: MouseEvent]
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -204,38 +243,105 @@ const { formatMessage } = useVIntl()
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
|
||||
const buttonsDisabled = ref(false)
|
||||
const closingFromAction = ref(false)
|
||||
|
||||
const removedCount = computed(() => props.diffs.filter((d) => d.type === 'removed').length)
|
||||
const addedCount = computed(() => props.diffs.filter((d) => d.type === 'added').length)
|
||||
const updatedCount = computed(() => props.diffs.filter((d) => d.type === 'updated').length)
|
||||
const removedCount = computed(
|
||||
() => props.diffs.filter((diff) => diff.type === 'removed' && !diff.disabled).length,
|
||||
)
|
||||
const removedDisabledCount = computed(
|
||||
() => props.diffs.filter((diff) => diff.type === 'removed' && diff.disabled).length,
|
||||
)
|
||||
const addedCount = computed(() => props.diffs.filter((diff) => diff.type === 'added').length)
|
||||
const updatedCount = computed(
|
||||
() =>
|
||||
props.diffs.filter((diff) => diff.type === 'updated' || diff.type === 'config_files_updated')
|
||||
.length,
|
||||
)
|
||||
const hasExternalDiffs = computed(() => props.diffs.some(showExternalWarning))
|
||||
|
||||
type DependencyDiffType = Extract<ContentDiffItem['type'], 'added' | 'removed' | 'updated'>
|
||||
type ConfigurationDiffType = Exclude<ContentDiffItem['type'], DependencyDiffType>
|
||||
|
||||
const configurationDiffTypes = new Set<ConfigurationDiffType>([
|
||||
'modpack_linked',
|
||||
'modpack_updated',
|
||||
'modpack_unlinked',
|
||||
'game_version_updated',
|
||||
'loader_updated',
|
||||
'config_files_updated',
|
||||
])
|
||||
|
||||
function isDependencyDiff(
|
||||
diff: ContentDiffItem,
|
||||
): diff is ContentDiffItem & { type: DependencyDiffType } {
|
||||
return diff.type === 'added' || diff.type === 'removed' || diff.type === 'updated'
|
||||
}
|
||||
|
||||
function isConfigurationDiff(
|
||||
diff: ContentDiffItem,
|
||||
): diff is ContentDiffItem & { type: ConfigurationDiffType } {
|
||||
return configurationDiffTypes.has(diff.type as ConfigurationDiffType)
|
||||
}
|
||||
|
||||
function showExternalWarning(diff: ContentDiffItem) {
|
||||
return Boolean(
|
||||
props.showExternalWarnings &&
|
||||
diff.external &&
|
||||
isDependencyDiff(diff) &&
|
||||
diff.type !== 'removed',
|
||||
)
|
||||
}
|
||||
|
||||
const sortedDiffs = computed(() =>
|
||||
[...props.diffs].sort((a, b) => {
|
||||
const aExternal = showExternalWarning(a)
|
||||
const bExternal = showExternalWarning(b)
|
||||
if (aExternal !== bExternal) return aExternal ? -1 : 1
|
||||
|
||||
const typeOrder = { added: 0, updated: 1, removed: 2 }
|
||||
const typeOrder: Record<ContentDiffItem['type'], number> = {
|
||||
modpack_linked: 0,
|
||||
modpack_updated: 0,
|
||||
modpack_unlinked: 0,
|
||||
game_version_updated: 1,
|
||||
loader_updated: 2,
|
||||
config_files_updated: 3,
|
||||
added: 4,
|
||||
updated: 5,
|
||||
removed: 6,
|
||||
}
|
||||
return typeOrder[a.type] - typeOrder[b.type]
|
||||
}),
|
||||
)
|
||||
|
||||
function getDiffTypeLabel(diff: ContentDiffItem) {
|
||||
if (showExternalWarning(diff)) return formatMessage(externalDiffTypeMessages[diff.type])
|
||||
if (showExternalWarning(diff) && isDependencyDiff(diff)) {
|
||||
return formatMessage(externalDiffTypeMessages[diff.type])
|
||||
}
|
||||
if (diff.type === 'modpack_updated') return formatMessage(diffTypeMessages.updated)
|
||||
if (isConfigurationDiff(diff)) return formatMessage(configurationDiffMessages[diff.type])
|
||||
if (diff.type === 'removed' && diff.disabled) {
|
||||
return formatMessage(diffTypeMessages.removedDisabled)
|
||||
}
|
||||
if (diff.type === 'added' && props.addedLabel) return props.addedLabel
|
||||
if (diff.type === 'removed' && props.removedLabel) return props.removedLabel
|
||||
return formatMessage(diffTypeMessages[diff.type])
|
||||
}
|
||||
|
||||
function getVersionLabel(diff: ContentDiffItem) {
|
||||
if (showExternalWarning(diff) && diff.fileName) return decodeURIComponent(diff.fileName)
|
||||
if (diff.type === 'config_files_updated' && diff.fileCount !== undefined) {
|
||||
return formatMessage(messages.fileCount, { count: diff.fileCount })
|
||||
}
|
||||
if (diff.type === 'modpack_updated') return diff.newVersionName
|
||||
if (isConfigurationDiff(diff)) {
|
||||
if (diff.currentVersionName && diff.newVersionName) {
|
||||
return `${diff.currentVersionName} → ${diff.newVersionName}`
|
||||
}
|
||||
return diff.newVersionName ?? diff.currentVersionName
|
||||
}
|
||||
return diff.type === 'removed' ? diff.currentVersionName : diff.newVersionName
|
||||
}
|
||||
|
||||
function showExternalWarning(diff: ContentDiffItem) {
|
||||
return Boolean(props.showExternalWarnings && diff.external && diff.type !== 'removed')
|
||||
}
|
||||
|
||||
function show(e?: MouseEvent) {
|
||||
modal.value?.show(e)
|
||||
}
|
||||
@@ -245,13 +351,25 @@ function hide() {
|
||||
}
|
||||
|
||||
function handleConfirm() {
|
||||
closingFromAction.value = true
|
||||
hide()
|
||||
emit('confirm')
|
||||
closingFromAction.value = false
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
closingFromAction.value = true
|
||||
hide()
|
||||
emit('cancel')
|
||||
closingFromAction.value = false
|
||||
}
|
||||
|
||||
function handleHide() {
|
||||
if (closingFromAction.value) {
|
||||
closingFromAction.value = false
|
||||
return
|
||||
}
|
||||
emit('cancel')
|
||||
}
|
||||
|
||||
const messages = defineMessages({
|
||||
@@ -259,6 +377,10 @@ const messages = defineMessages({
|
||||
id: 'content.diff-modal.removed-count',
|
||||
defaultMessage: '{count} removed',
|
||||
},
|
||||
removedDisabledCount: {
|
||||
id: 'content.diff-modal.removed-disabled-count',
|
||||
defaultMessage: '{count} removed (disabled)',
|
||||
},
|
||||
addedCount: {
|
||||
id: 'content.diff-modal.added-count',
|
||||
defaultMessage: '{count} added',
|
||||
@@ -267,6 +389,14 @@ const messages = defineMessages({
|
||||
id: 'content.diff-modal.updated-count',
|
||||
defaultMessage: '{count} updated',
|
||||
},
|
||||
noContentChanges: {
|
||||
id: 'content.diff-modal.no-content-changes',
|
||||
defaultMessage: 'No content changes',
|
||||
},
|
||||
fileCount: {
|
||||
id: 'content.diff-modal.file-count',
|
||||
defaultMessage: '{count, plural, one {# file} other {# files}}',
|
||||
},
|
||||
unknownContentBody: {
|
||||
id: 'content.diff-modal.unknown-content-body',
|
||||
defaultMessage:
|
||||
@@ -300,6 +430,33 @@ const messages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const configurationDiffMessages = defineMessages({
|
||||
modpack_linked: {
|
||||
id: 'content.diff-modal.modpack-linked',
|
||||
defaultMessage: 'Linked modpack',
|
||||
},
|
||||
modpack_updated: {
|
||||
id: 'content.diff-modal.modpack-updated',
|
||||
defaultMessage: 'Updated modpack',
|
||||
},
|
||||
modpack_unlinked: {
|
||||
id: 'content.diff-modal.modpack-unlinked',
|
||||
defaultMessage: 'Unlinked modpack',
|
||||
},
|
||||
game_version_updated: {
|
||||
id: 'content.diff-modal.game-version-updated',
|
||||
defaultMessage: 'Game version',
|
||||
},
|
||||
loader_updated: {
|
||||
id: 'content.diff-modal.loader-updated',
|
||||
defaultMessage: 'Loader',
|
||||
},
|
||||
config_files_updated: {
|
||||
id: 'content.diff-modal.config-files-updated',
|
||||
defaultMessage: 'Changed config files',
|
||||
},
|
||||
})
|
||||
|
||||
const diffTypeMessages = defineMessages({
|
||||
added: {
|
||||
id: 'content.diff-modal.diff-type.added',
|
||||
@@ -309,6 +466,10 @@ const diffTypeMessages = defineMessages({
|
||||
id: 'content.diff-modal.diff-type.removed',
|
||||
defaultMessage: 'Disabled',
|
||||
},
|
||||
removedDisabled: {
|
||||
id: 'content.diff-modal.diff-type.removed-disabled',
|
||||
defaultMessage: 'Removed (disabled)',
|
||||
},
|
||||
updated: {
|
||||
id: 'content.diff-modal.diff-type.updated',
|
||||
defaultMessage: 'Updated',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import AutoLink from '#ui/components/base/AutoLink.vue'
|
||||
import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
@@ -204,6 +205,12 @@ const isLocalFile = computed(() => {
|
||||
return typeof val === 'boolean' ? val : val.value
|
||||
})
|
||||
|
||||
const isManagedModpack = computed(() => {
|
||||
const val = ctx.isManagedModpack
|
||||
if (val == null) return false
|
||||
return typeof val === 'boolean' ? val : val.value
|
||||
})
|
||||
|
||||
const isLinkedModpack = computed(() => showModpackVersionActions.value || isLocalFile.value)
|
||||
|
||||
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event?: MouseEvent) {
|
||||
@@ -226,10 +233,15 @@ function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version, event
|
||||
? new Date(version.date_published) < new Date(currentVersion.date_published)
|
||||
: false
|
||||
const shouldShowWarning =
|
||||
isManagedModpack.value ||
|
||||
isUpdateDowngrade.value ||
|
||||
versionChangesGameVersion(version, ctx.updaterModalProps.value.currentGameVersion)
|
||||
|
||||
if (event?.shiftKey || skipNonEssentialWarnings.value || !shouldShowWarning) {
|
||||
if (
|
||||
event?.shiftKey ||
|
||||
(skipNonEssentialWarnings.value && !isManagedModpack.value) ||
|
||||
!shouldShowWarning
|
||||
) {
|
||||
debug('handleModpackUpdateRequest: confirming without warning', {
|
||||
isUpdateDowngrade: isUpdateDowngrade.value,
|
||||
shouldShowWarning,
|
||||
@@ -391,7 +403,7 @@ function handleShowUnlinkModal(event: MouseEvent) {
|
||||
snapshot: stateSnapshot(),
|
||||
refs: modalRefsSnapshot(),
|
||||
})
|
||||
if (event.shiftKey || skipNonEssentialWarnings.value) {
|
||||
if (event.shiftKey || (skipNonEssentialWarnings.value && !isManagedModpack.value)) {
|
||||
handleUnlink()
|
||||
return
|
||||
}
|
||||
@@ -593,7 +605,7 @@ const messages = defineMessages({
|
||||
<!-- LINKED -->
|
||||
<template v-if="ctx.isLinked.value">
|
||||
<!-- Installed Modpack -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-if="ctx.modpack.value" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{ formatMessage(commonMessages.installedModpackTitle) }}
|
||||
</span>
|
||||
@@ -667,10 +679,17 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<Admonition
|
||||
v-if="isManagedModpack && (showModpackVersionActions || isLocalFile)"
|
||||
type="warning"
|
||||
:header="ctx.managedModpackWarning?.value.admonitionHeader"
|
||||
>
|
||||
{{ ctx.managedModpackWarning?.value.changeVersionBody }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<!-- Unlink -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<div v-if="!isManagedModpack" class="flex flex-col gap-2.5">
|
||||
<span class="text-lg font-semibold text-contrast">
|
||||
{{
|
||||
formatMessage(messages.linkedInstanceTitle, {
|
||||
@@ -1048,6 +1067,14 @@ const messages = defineMessages({
|
||||
<ConfirmModpackUpdateModal
|
||||
ref="modpackUpdateModal"
|
||||
:downgrade="isUpdateDowngrade"
|
||||
:managed-warning="
|
||||
isManagedModpack && ctx.managedModpackWarning
|
||||
? {
|
||||
header: ctx.managedModpackWarning.value.admonitionHeader,
|
||||
body: ctx.managedModpackWarning.value.changeVersionBody,
|
||||
}
|
||||
: null
|
||||
"
|
||||
:backup-tip="
|
||||
[ctx.modpack.value?.title, pendingUpdateVersion?.version_number].filter(Boolean).join(' ')
|
||||
"
|
||||
@@ -1067,7 +1094,6 @@ const messages = defineMessages({
|
||||
:backup-tip="ctx.modpack.value?.title"
|
||||
@unlink="handleUnlink"
|
||||
/>
|
||||
|
||||
<IncompatibleContentModal
|
||||
v-if="form.incompatibleContentVariant.value"
|
||||
ref="incompatibleContentModal"
|
||||
|
||||
+8
@@ -63,6 +63,14 @@ export interface InstallationSettingsContext {
|
||||
/** True when the linked modpack was uploaded as a local file rather than from Modrinth */
|
||||
isLocalFile?: boolean | ComputedRef<boolean>
|
||||
|
||||
/** True when an external source controls the linked modpack. */
|
||||
isManagedModpack?: boolean | ComputedRef<boolean>
|
||||
managedModpackWarning?: ComputedRef<{
|
||||
admonitionHeader: string
|
||||
changeVersionBody: string
|
||||
unlinkBody: string
|
||||
}>
|
||||
|
||||
repairing?: Ref<boolean>
|
||||
reinstalling?: Ref<boolean>
|
||||
|
||||
|
||||
@@ -36,12 +36,23 @@ export interface LoaderVersionEntry {
|
||||
}
|
||||
|
||||
export interface ContentDiffItem {
|
||||
type: 'added' | 'removed' | 'updated'
|
||||
external?: boolean
|
||||
type:
|
||||
| 'added'
|
||||
| 'removed'
|
||||
| 'updated'
|
||||
| 'modpack_linked'
|
||||
| 'modpack_updated'
|
||||
| 'modpack_unlinked'
|
||||
| 'game_version_updated'
|
||||
| 'loader_updated'
|
||||
| 'config_files_updated'
|
||||
projectName?: string
|
||||
fileName?: string
|
||||
currentVersionName?: string
|
||||
newVersionName?: string
|
||||
fileCount?: number
|
||||
disabled?: boolean
|
||||
external?: boolean
|
||||
}
|
||||
|
||||
export interface ContentDiffPreview {
|
||||
|
||||
Reference in New Issue
Block a user