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:
Calum H.
2026-07-24 13:06:38 +00:00
committed by GitHub
co-authored by sychic
parent 2e0d797bb0
commit e58af98f21
269 changed files with 18202 additions and 2579 deletions
@@ -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
@@ -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>
@@ -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
@@ -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', {
@@ -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<
@@ -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"
@@ -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 {