Files
modrinth/apps/app-frontend/src/components/ui/shared-instances/SharedInstancePublishModal.vue
T
Calum H. 4c3abc62a8 fix: export modal path handling (#6949)
* fix: export modal path handling

* fix: prepr

* fix: NEVER_EXPORTABLE_PATH_SUFFIXES list
2026-08-04 15:34:20 +00:00

226 lines
6.2 KiB
Vue

<template>
<ContentDiffModal
ref="publishReviewModal"
:header="formatMessage(messages.header)"
:admonition-header="formatMessage(messages.admonitionHeader)"
:description="formatMessage(messages.description)"
:diffs="publishDiffs"
:confirm-label="formatMessage(messages.publishButton)"
:confirm-icon="UploadIcon"
:confirm-disabled="publishDiffs.length === 0 && selectedConfigPaths.length === 0"
:added-label="formatMessage(messages.addedLabel)"
:removed-label="formatMessage(messages.removedLabel)"
@confirm="publishChanges"
@cancel="finishReview"
>
<template #additional-content>
<Accordion
class="min-w-0"
overflow-visible
button-class="w-full bg-transparent m-0 p-0 border-none"
>
<template #title>
<FileCogIcon class="size-4 shrink-0 text-primary" />
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.configTitle) }}
</span>
</template>
<div class="flex min-w-0 flex-col gap-3 pt-4">
<div ref="configFileTreeContainer" class="max-h-[292px] overflow-y-auto rounded-[20px]">
<FileTreeSelect
v-model="includedConfigPaths"
v-model:excluded-paths="excludedConfigPaths"
:items="configFileItems"
:show-size="false"
:show-modified="false"
@navigate="scrollConfigFileTreeToTop"
/>
</div>
</div>
</Accordion>
</template>
</ContentDiffModal>
</template>
<script setup lang="ts">
import { FileCogIcon, UploadIcon } from '@modrinth/assets'
import {
Accordion,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
FileTreeSelect,
type FileTreeSelectItem,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
getSharedInstanceUnavailableReason,
isSharedInstanceUnavailableError,
} from '@/helpers/install'
import { get_shared_instance_publish_preview, publish_shared_instance } from '@/helpers/instance'
import { useSharedInstanceErrors } from '@/helpers/shared-instance-errors'
import type { GameInstance } from '@/helpers/types'
type SharedInstancePublishState = 'idle' | 'reviewing' | 'publishing'
const props = defineProps<{
instance: GameInstance
}>()
const emit = defineEmits<{
published: []
'state-change': [state: SharedInstancePublishState]
}>()
const { formatMessage } = useVIntl()
const { notifySharedInstanceError, notifySharedInstanceUnavailable } = useSharedInstanceErrors()
const publishReviewModal = ref<InstanceType<typeof ContentDiffModal>>()
const configFileTreeContainer = ref<HTMLElement>()
const publishDiffs = ref<ContentDiffItem[]>([])
const configFilePaths = ref<string[]>([])
const includedConfigPaths = ref<string[]>([])
const excludedConfigPaths = ref<string[]>([])
const state = ref<SharedInstancePublishState>('idle')
const configFileItems = computed<FileTreeSelectItem[]>(() =>
configFilePaths.value.map((path) => ({ path, type: 'file' })),
)
const selectedConfigPaths = computed(() => {
const includedPaths = new Set(includedConfigPaths.value)
const excludedPaths = new Set(excludedConfigPaths.value)
return configFilePaths.value.filter((path) =>
isConfigPathSelected(path, includedPaths, excludedPaths),
)
})
async function show(e?: MouseEvent) {
if (state.value !== 'idle') return
setState('reviewing')
let reviewOpened = false
try {
const preview = await get_shared_instance_publish_preview(props.instance.id)
if (!preview) {
notifySharedInstanceUnavailable()
emit('published')
return
}
publishDiffs.value = preview.diffs.map((diff) => ({
type: diff.type,
projectName: diff.projectName ?? undefined,
fileName: diff.fileName ?? undefined,
currentVersionName: diff.currentVersionName ?? undefined,
newVersionName: diff.newVersionName ?? undefined,
fileCount: diff.configFileCount ?? undefined,
disabled: diff.disabled,
}))
configFilePaths.value = preview.configFiles
includedConfigPaths.value = []
excludedConfigPaths.value = []
if (!publishReviewModal.value) return
publishReviewModal.value.show(e)
reviewOpened = true
} catch (error) {
handlePublishError(error)
} finally {
if (!reviewOpened) finishReview()
}
}
async function publishChanges() {
if (state.value !== 'reviewing') return
setState('publishing')
try {
await publish_shared_instance(props.instance.id, selectedConfigPaths.value)
emit('published')
} catch (error) {
handlePublishError(error)
} finally {
setState('idle')
}
}
function isConfigPathSelected(
path: string,
includedPaths: Set<string>,
excludedPaths: Set<string>,
) {
let selected = false
let prefix = ''
for (const segment of path.split('/').filter(Boolean)) {
prefix = prefix ? `${prefix}/${segment}` : segment
if (includedPaths.has(prefix)) selected = true
if (excludedPaths.has(prefix)) selected = false
}
return selected
}
function scrollConfigFileTreeToTop() {
if (configFileTreeContainer.value) {
configFileTreeContainer.value.scrollTop = 0
}
}
function finishReview() {
if (state.value === 'reviewing') {
setState('idle')
}
}
function handlePublishError(error: unknown) {
if (isSharedInstanceUnavailableError(error)) {
notifySharedInstanceUnavailable(getSharedInstanceUnavailableReason(error))
emit('published')
return
}
notifySharedInstanceError(error)
}
function setState(nextState: SharedInstancePublishState) {
state.value = nextState
emit('state-change', nextState)
}
const messages = defineMessages({
header: {
id: 'instance.shared-instance.publish-review.header',
defaultMessage: 'Review changes',
},
admonitionHeader: {
id: 'instance.shared-instance.publish-review.admonition-header',
defaultMessage: 'Push update to players',
},
description: {
id: 'instance.shared-instance.publish-review.description',
defaultMessage:
'Review the content changes and choose any config files to include in this update.',
},
publishButton: {
id: 'instance.shared-instance.publish-review.publish-button',
defaultMessage: 'Push update',
},
addedLabel: {
id: 'instance.shared-instance.publish-review.added-label',
defaultMessage: 'Added',
},
removedLabel: {
id: 'instance.shared-instance.publish-review.removed-label',
defaultMessage: 'Removed',
},
configTitle: {
id: 'instance.shared-instance.publish-review.config-title-v2',
defaultMessage: 'Select config files',
},
})
defineExpose({ show })
</script>