Files
modrinth/apps/app-frontend/src/pages/instance/content/index.vue
T
Calum H.andtdgao 3e07267e10 feat: preferences syncing frontend (#7192)
* feat: prefs frontend

* fix: DI issue

* fix: lint

* feat: appearance settings cleanup + fix settings, remove pinia

* fix: sync btn when logged out

* fix: prepr

* fix: sidebar issue

* feat: language coverage + cleanup

* feat: cleanup lang settings

* fix: fmt

* fix: CI

* fix: ci

* fix: storybook

* feat: bring back loader/game version sort

---------

Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-08-20 07:23:01 +00:00

1797 lines
54 KiB
Vue

<template>
<ReadyTransition :pending="loading">
<ContentPageLayout>
<template #modals>
<UnknownFileWarningModal
ref="unknownFileWarningModal"
mode="mod"
:file-name="unknownFileName"
@cancel="resolveUnknownFileWarning(false)"
@continue="handleUnknownFileContinue"
/>
<ShareModalWrapper
ref="shareModal"
:share-title="formatMessage(messages.shareTitle)"
:share-text="formatMessage(messages.shareText)"
:open-in-new-tab="false"
/>
<ManagedContentModal
ref="managedContentModal"
:header="managedContentModalHeader"
:source-name="managedContent?.card.manager.name"
:source-icon-url="managedContent?.card.manager.iconUrl"
:enable-toggle="!isServerInstance && !isSharedMember && !isQuarantined"
:action-disabled="isBulkOperating || isInstanceBusy"
:get-overflow-options="getOverflowOptions"
:switch-version="
isServerInstance || isSharedMember || isQuarantined ? undefined : handleSwitchVersion
"
@update:enabled="handleManagedContentToggle"
@bulk:enable="(items) => handleManagedContentBulkToggle(items, true)"
@bulk:disable="(items) => handleManagedContentBulkToggle(items, false)"
/>
<ConfirmDisableModal
ref="sharedDisableConfirmModal"
:count="pendingManagedContentDisableItems.length"
:item-type="formatMessage(messages.contentTypeProject)"
:warning="managedContentPolicy.disableWarning(pendingManagedContentDisableItems)"
:action-disabled="isInstanceBusy"
@disable="confirmPendingManagedContentDisable"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateConfirmModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[displayedModpackProject?.title, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ExportModal
v-if="projects.length > 0 && !instance.quarantined"
ref="exportModal"
:instance="instance"
/>
<ContentUpdaterModal
v-if="updatingProject || updatingModpack"
ref="contentUpdaterModal"
:versions="updatingProjectVersions"
:current-game-version="instance.game_version"
:current-loader="instance.loader"
:current-version-id="
updatingModpack
? (instance.link?.version_id ?? '')
: (updatingProject?.version?.id ?? '')
"
:is-app="true"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack ? displayedModpackProject?.icon_url : updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (displayedModpackProject?.title ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
:loading-changelog="loadingChangelog"
@update="handleModalUpdate"
@cancel="resetUpdateState"
@version-select="handleVersionSelect"
@version-hover="handleVersionHover"
/>
</template>
</ContentPageLayout>
</ReadyTransition>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ClipboardCopyIcon, FolderOpenIcon, LockIcon, LockOpenIcon } from '@modrinth/assets'
import {
type BulkOperationStatus,
commonMessages,
ConfirmDisableModal,
ConfirmModpackUpdateModal,
ContentCardLayout as ContentPageLayout,
type ContentItem,
type ContentOwner,
ContentUpdaterModal,
dedupeManagedContentItems,
defineMessages,
injectNotificationManager,
type ManagedContentData,
ManagedContentModal,
type ManagedContentModalState,
type ManagedContentProject,
type ManagedContentVersion,
type OverflowMenuOption,
provideContentManager,
ReadyTransition,
summarizeManagedContent,
UnknownFileWarningModal,
useDebugLogger,
useVIntl,
versionChangesGameVersion,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { getCurrentWebview } from '@tauri-apps/api/webview'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import ExportModal from '@/components/ui/ExportModal.vue'
import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue'
import { useManagedContentPolicy } from '@/composables/instances/use-managed-content-policy'
import { useAppEvent } from '@/composables/use-app-event'
import { type FeatureFlag, useAppSettings } from '@/composables/use-app-settings.ts'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version, get_version_many } from '@/helpers/cache.js'
import {
add_project_from_path,
edit,
get_linked_modpack_content,
get_shared_instance_publish_preview,
getInstanceIconUrl,
is_file_on_modrinth,
remove_project,
set_project_locked,
switch_project_version_with_dependencies,
toggle_disable_project,
update_all,
update_managed_modrinth_version,
} from '@/helpers/instance'
import { type InstanceContentData, loadInstanceContentData } from '@/helpers/instance-content'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import type { CacheBehaviour } from '@/helpers/types'
import { highlightModInInstance } from '@/helpers/utils.js'
import { type AppEventPayload, injectAppEvents } from '@/providers/app-events'
import { injectContentInstall } from '@/providers/content-install'
import { injectInstancePage } from '../instance-context'
import { instanceContentQueryOptions, instanceKeys } from '../query-options'
import { injectSharedInstance } from '../shared-instance-context'
type InstanceBulkUpdateProgress = AppEventPayload<'instance_bulk_update_progress'>
const messages = defineMessages({
modpackContentHeader: {
id: 'app.instance.content.managed-content.modpack-header',
defaultMessage: 'Modpack content',
},
sharedContentHeader: {
id: 'app.instance.content.managed-content.shared-header',
defaultMessage: 'Shared content',
},
shareTitle: {
id: 'app.instance.mods.share-title',
defaultMessage: 'Sharing modpack content',
},
shareText: {
id: 'app.instance.mods.share-text',
defaultMessage: "Check out the projects I'm using in my modpack!",
},
successfullyUploaded: {
id: 'app.instance.mods.successfully-uploaded',
defaultMessage: 'Successfully uploaded',
},
projectWasAdded: {
id: 'app.instance.mods.project-was-added',
defaultMessage: '"{name}" was added',
},
projectsWereAdded: {
id: 'app.instance.mods.projects-were-added',
defaultMessage: '{count} projects were added',
},
lockedContent: {
id: 'app.instance.mods.locked-content',
defaultMessage: 'Content in locked instances cannot be changed.',
},
freezeContent: {
id: 'app.instance.mods.freeze-content',
defaultMessage: 'Freeze version',
},
unfreezeContent: {
id: 'app.instance.mods.unfreeze-content',
defaultMessage: 'Unfreeze version',
},
contentTypeProject: {
id: 'app.instance.mods.content-type-project',
defaultMessage: 'project',
},
bulkUpdateResolvingVersions: {
id: 'app.instance.mods.bulk-update.resolving-versions',
defaultMessage: 'Resolving versions...',
},
bulkUpdateDownloadingProjects: {
id: 'app.instance.mods.bulk-update.downloading-projects',
defaultMessage: 'Downloading {current, number}/{total, number} projects...',
},
bulkUpdateFinishing: {
id: 'app.instance.mods.bulk-update.finishing',
defaultMessage: 'Finishing update...',
},
})
let savedModalState: ManagedContentModalState | null = null
function contentOwnerLink(owner: ContentOwner): NonNullable<ContentOwner['link']> {
if (owner.type === 'user') return `/user/${encodeURIComponent(owner.id)}`
return () => {
void openUrl(`https://modrinth.com/organization/${owner.id}`)
}
}
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const appEvents = injectAppEvents()
const { installingItems, installRevisionByInstance, installFailureRevisionByInstance } =
injectContentInstall()
const router = useRouter()
const queryClient = useQueryClient()
const debug = useDebugLogger('Mods:ContentUpdate')
const appSettings = useAppSettings()
const skipUnknownFileWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const skipNonEssentialWarnings = computed(() =>
appSettings.getFeatureFlag('skip_non_essential_warnings'),
)
const instancePage = injectInstancePage()
const sharedInstanceState = injectSharedInstance()
const instance = instancePage.instance
const isServerInstance = instancePage.isServerInstance
const openSettings = () => instancePage.openSettings(1)
const managedContentPolicy = useManagedContentPolicy(computed(() => instance.value))
const {
isManagedModpack: isSharedMember,
isQuarantined,
canMutateContent,
canUpdateContent: canUpdateProject,
} = managedContentPolicy
const contentQuery = useQuery(
computed(() => ({
...instanceContentQueryOptions(instancePage.instanceId.value),
enabled: !!instancePage.instanceId.value,
})),
)
const loading = ref(contentQuery.data.value === undefined)
const projects = ref<ContentItem[]>([])
const installingBuffer = ref<ContentItem[]>([])
const handledInstallRevision = ref(0)
watch(
() => installingItems.value.get(instance.value.id),
(items) => {
if (items && items.length > 0) {
installingBuffer.value = [...items]
}
},
{ immediate: true, deep: true },
)
watch(projects, (newProjects) => {
if (installingBuffer.value.length === 0) return
const realProjectIds = new Set(newProjects.map((p) => p.project?.id).filter(Boolean))
if (installingBuffer.value.every((item) => realProjectIds.has(item.project?.id))) {
installingBuffer.value = []
}
})
const mergedProjects = computed<ContentItem[]>(() => {
const active = installingItems.value.get(instance.value.id)
const pending = active ?? installingBuffer.value
if (pending.length === 0) return projects.value
const pendingProjectIds = new Set(pending.map((p) => p.project?.id).filter(Boolean))
const displayProjects = projects.value.map((project) =>
project.project?.id && pendingProjectIds.has(project.project.id)
? { ...project, installing: true }
: project,
)
const realProjectIds = new Set(displayProjects.map((p) => p.project?.id).filter(Boolean))
const placeholders = pending.filter((item) => !realProjectIds.has(item.project?.id))
return placeholders.length > 0 ? [...displayProjects, ...placeholders] : displayProjects
})
watch(
() => installFailureRevisionByInstance.value.get(instance.value.id) ?? 0,
(revision, previousRevision) => {
if (revision === previousRevision) return
installingBuffer.value = []
},
)
const linkedModpackProject = ref<ManagedContentProject | null>(null)
const linkedModpackVersion = ref<ManagedContentVersion | null>(null)
const linkedModpackUpdateVersionId = ref<string | null>(null)
const localImportedModpackUnlinked = ref(false)
const localImportedModpackProject = computed<ManagedContentProject | null>(() => {
const link = instance.value.link
if (localImportedModpackUnlinked.value || link?.type !== 'imported_modpack') return null
return {
id: link.filename ?? instance.value.id,
slug: link.filename ?? instance.value.id,
title: link.name ?? instance.value.name,
icon_url: getInstanceIconUrl(instance.value.icon_path) ?? undefined,
filename: link.filename ?? undefined,
}
})
const displayedModpackProject = computed(
() => linkedModpackProject.value ?? localImportedModpackProject.value,
)
watch(
() => instance.value.link,
() => {
localImportedModpackUnlinked.value = false
},
)
const isModpackUpdating = ref(false)
const isBulkOperating = ref(false)
const isInstanceBusy = computed(() => instance.value?.install_stage !== 'installed')
const showSharedContentFilter = computed(() => instance.value.shared_instance?.role === 'member')
const isPackLocked = computed(
() =>
instance.value.quarantined ||
instance.value?.link?.type === 'modrinth_modpack' ||
instance.value?.link?.type === 'server_project_modpack',
)
const shareModal = ref<InstanceType<typeof ShareModalWrapper> | null>()
const exportModal = ref(null)
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const managedContentModal = ref<InstanceType<typeof ManagedContentModal> | null>()
const modpackUpdateConfirmModal = ref<InstanceType<typeof ConfirmModpackUpdateModal> | null>()
const sharedDisableConfirmModal = ref<InstanceType<typeof ConfirmDisableModal> | null>()
const pendingManagedContentDisableItems = ref<ContentItem[]>([])
const unknownFileWarningModal = ref<InstanceType<typeof UnknownFileWarningModal> | null>()
const unknownFileName = ref('')
let resolveUnknownFileConfirmation: ((confirmed: boolean) => void) | null = null
const modpackContentQueryKey = computed(() => instanceKeys.linkedContent(instance.value.id))
const modpackContentQuery = useQuery({
queryKey: modpackContentQueryKey,
queryFn: () => get_linked_modpack_content(instance.value.id),
enabled: computed(
() =>
!!instance.value?.id &&
!!instance.value?.link &&
instance.value.install_stage === 'installed',
),
})
const hasSharedManagedContent = computed(() => {
if (instance.value.shared_instance?.role === 'owner') return false
const linkType = instance.value.link?.type
return (
!!instance.value.shared_instance ||
linkType === 'server_project' ||
linkType === 'server_project_modpack'
)
})
const managedContentItems = computed(() => {
const linkedContent = modpackContentQuery.data.value ?? []
const sourcedContent = hasSharedManagedContent.value
? projects.value.filter((item) =>
['server_project', 'shared_instance'].includes(item.source_kind ?? ''),
)
: []
return dedupeManagedContentItems([...linkedContent, ...sourcedContent])
})
const managedContentSummary = computed(() =>
modpackContentQuery.isLoading.value && modpackContentQuery.data.value === undefined
? undefined
: summarizeManagedContent(managedContentItems.value),
)
const managedContent = computed<ManagedContentData | null>(() => {
const attachment = instance.value.shared_instance
const sharedManager = sharedInstanceState.manager.value
const linkedProject = instancePage.linkedProject.value
const linkType = instance.value.link?.type
const isSharedOwner = attachment?.role === 'owner'
if (
!isSharedOwner &&
(attachment || linkType === 'server_project' || linkType === 'server_project_modpack')
) {
const serverManaged =
sharedManager?.type === 'server' ||
!!attachment?.server_manager_name ||
linkType === 'server_project' ||
linkType === 'server_project_modpack' ||
(!attachment && isServerInstance.value)
const managerName = serverManaged
? (sharedManager?.name ??
attachment?.server_manager_name ??
linkedProject?.name ??
instance.value.name)
: (sharedManager?.name ?? instance.value.name)
const managerIcon = serverManaged
? (sharedManager?.avatarUrl ??
attachment?.server_manager_icon_url ??
linkedProject?.icon_url ??
undefined)
: (sharedManager?.avatarUrl ??
(instance.value.icon_path ? convertFileSrc(instance.value.icon_path) : undefined))
const managerLink = serverManaged
? linkedProject
? {
path: `/project/${linkedProject.slug ?? linkedProject.id}`,
query: { i: instancePage.instanceId.value },
}
: undefined
: sharedManager?.type === 'user'
? `/user/${encodeURIComponent(sharedManager.name)}`
: undefined
return {
card: {
kind: serverManaged ? 'server' : 'shared-instance',
installing: isInstanceBusy.value,
manager: {
name: managerName,
iconUrl: managerIcon,
link: managerLink,
},
summary: managedContentSummary.value,
syncedAt: sharedInstanceState.lastUpdateCheckAt.value,
updateAvailable: instancePage.sharedInstanceUpdateAvailable.value,
},
disabled: attachment?.status === 'applying' || isInstanceBusy.value,
disabledText: formatMessage(commonMessages.updatingLabel),
}
}
const project = displayedModpackProject.value
if (!project) return null
return {
card: {
kind: 'modpack',
installing: isInstanceBusy.value,
manager: {
name: project.title,
iconUrl: project.icon_url ?? undefined,
link: linkedModpackProject.value
? {
path: `/project/${project.slug ?? project.id}`,
query: { i: instancePage.instanceId.value },
}
: undefined,
},
summary: managedContentSummary.value,
versionNumber: linkedModpackVersion.value?.version_number,
versionLink:
linkedModpackProject.value && linkedModpackVersion.value
? {
path: `/project/${linkedModpackProject.value.slug ?? linkedModpackProject.value.id}/version/${linkedModpackVersion.value.id}`,
query: { i: instancePage.instanceId.value },
}
: undefined,
updatedAt: linkedModpackVersion.value?.date_published,
},
disabled: isModpackUpdating.value || isInstanceBusy.value,
disabledText: formatMessage(commonMessages.updatingLabel),
}
})
const managedContentModalHeader = computed(() =>
formatMessage(
managedContent.value?.card.kind === 'modpack'
? messages.modpackContentHeader
: messages.sharedContentHeader,
),
)
// TODO: Extract content operation and updater modal state into composables; this page currently owns file mutations, dependency installs, busy flags, and version selection flow.
const updatingProject = ref<ContentItem | null>(null)
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
const loadingVersions = ref(false)
const loadingChangelog = ref(false)
const updatingModpack = ref(false)
const pendingModpackUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isModpackUpdateDowngrade = ref(false)
const activeContentOperationKeys = ref(new Set<string>())
let activeContentOperationCount = 0
let updateRequestId = 0
const activeUpdateRequestId = ref(0)
function fileNameFromPath(path: string) {
return path.split('/').pop() ?? path
}
function matchesContentItem(
item: ContentItem,
target: ContentItem,
originalFileName: string,
originalFilePath?: string,
) {
if (item.file_name === originalFileName || item.file_path === originalFilePath) return true
const projectId = target.project?.id
if (!projectId || item.project?.id !== projectId) return false
const versionId = target.version?.id
return !versionId || item.version?.id === versionId
}
function updateLinkedModpackContentCache(
target: ContentItem,
originalFileName: string,
originalFilePath: string | undefined,
updates: Partial<ContentItem>,
) {
queryClient.setQueryData<ContentItem[]>(modpackContentQueryKey.value, (items) => {
if (!items) return items
return items.map((item) =>
matchesContentItem(item, target, originalFileName, originalFilePath)
? { ...item, ...updates }
: item,
)
})
}
function getContentItemId(item: ContentItem | null | undefined) {
return item?.file_path ?? item?.file_name ?? item?.id ?? ''
}
function getContentOperationKeys(item: ContentItem) {
return [getContentItemId(item), item.file_path, item.file_name].filter(
(key): key is string => !!key,
)
}
function hasContentOperation(item: ContentItem) {
const keys = getContentOperationKeys(item)
return keys.some((key) => activeContentOperationKeys.value.has(key))
}
function canDeleteContent(item: ContentItem) {
return canMutateContent(item)
}
function canToggleContent(item: ContentItem) {
return canMutateContent(item)
}
function canChangeContentVersion(item: ContentItem) {
return canMutateContent(item) && !item.locked
}
async function reconcileSharedInstancePublishState() {
if (instance.value.shared_instance?.role !== 'owner') return
await get_shared_instance_publish_preview(instance.value.id).catch((error) => {
debug('Failed to reconcile shared instance publish state', { error })
})
}
function setContentItemBusy(item: ContentItem, busy: boolean, originalFileName = item.file_name) {
item.installing = busy
managedContentModal.value?.updateItem(originalFileName, {
installing: busy,
disabled: busy,
})
if (item.file_name !== originalFileName) {
managedContentModal.value?.updateItem(item.file_name, {
installing: busy,
disabled: busy,
})
}
}
function beginContentOperation(item: ContentItem) {
if (hasContentOperation(item)) return null
const keys = getContentOperationKeys(item)
activeContentOperationKeys.value = new Set([...activeContentOperationKeys.value, ...keys])
activeContentOperationCount++
isBulkOperating.value = true
setContentItemBusy(item, true)
return { keys, originalFileName: item.file_name }
}
function finishContentOperation(
item: ContentItem,
operation: { keys: string[]; originalFileName: string },
) {
const nextKeys = new Set(activeContentOperationKeys.value)
for (const key of operation.keys) {
nextKeys.delete(key)
}
activeContentOperationKeys.value = nextKeys
activeContentOperationCount = Math.max(0, activeContentOperationCount - 1)
setContentItemBusy(item, false, operation.originalFileName)
if (activeContentOperationCount === 0) {
isBulkOperating.value = false
}
}
function beginUpdateRequest() {
updateRequestId++
activeUpdateRequestId.value = updateRequestId
return updateRequestId
}
function isActiveUpdateRequest(requestId: number) {
return activeUpdateRequestId.value === requestId
}
function sortVersionsByPublishedDate(versions: Labrinth.Versions.v2.Version[]) {
return [...versions].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
}
function mergeVersionIntoList(
versions: Labrinth.Versions.v2.Version[],
version: Labrinth.Versions.v2.Version,
) {
const existingIndex = versions.findIndex((v) => v.id === version.id)
if (existingIndex === -1) {
return sortVersionsByPublishedDate([version, ...versions])
}
const mergedVersions = [...versions]
mergedVersions[existingIndex] = version
return sortVersionsByPublishedDate(mergedVersions)
}
async function getUpdaterProjectVersions(projectId: string, pinnedVersionId?: string) {
let fetchError: unknown = null
let versions = (await get_project_versions(projectId, 'bypass').catch((err) => {
fetchError = err
return null
})) as Labrinth.Versions.v2.Version[] | null
if (!versions) {
versions = (await get_project_versions(projectId).catch(() => null)) as
| Labrinth.Versions.v2.Version[]
| null
}
if (!versions && fetchError) {
handleError(fetchError as Error)
}
let mergedVersions = sortVersionsByPublishedDate(versions ?? [])
if (pinnedVersionId && !mergedVersions.some((version) => version.id === pinnedVersionId)) {
const pinnedVersion = (await get_version(pinnedVersionId, 'bypass').catch(
() => null,
)) as Labrinth.Versions.v2.Version | null
if (pinnedVersion) {
mergedVersions = mergeVersionIntoList(mergedVersions, pinnedVersion)
}
}
return mergedVersions
}
async function handleBrowseContent() {
if (!instance.value || instance.value.quarantined) return
await instancePage.browseContent(instance.value.loader === 'vanilla' ? 'resourcepack' : 'mod')
}
async function handleUploadFiles() {
if (!instance.value || instance.value.quarantined) return
const files = await open({ multiple: true })
if (!files) return
const selectedFiles: Array<{ path: string; filename: string }> = []
for (const file of files) {
const path = (file as { path?: string }).path ?? file
if (typeof path !== 'string') continue
selectedFiles.push({
path,
filename: path.split(/[\\/]/).pop() ?? path,
})
}
const fileRecognition = await Promise.all(
selectedFiles.map(async ({ path }) => {
try {
return await is_file_on_modrinth(path)
} catch {
return true
}
}),
)
const confirmedFiles: Array<{ path: string; filename: string }> = []
for (const [index, { path, filename }] of selectedFiles.entries()) {
if (!fileRecognition[index] && !(await confirmUnknownFileInstallation(filename))) {
continue
}
confirmedFiles.push({ path, filename })
}
const addedFiles = (
await Promise.all(
confirmedFiles.map(async ({ path, filename }) => {
try {
const installedPath = await add_project_from_path(instance.value.id, path)
return { filename, installedPath }
} catch (error) {
handleError(error as Error)
return null
}
}),
)
).filter((result): result is { filename: string; installedPath: string } => result !== null)
const uniqueAddedFiles = [
...new Map(addedFiles.map((file) => [file.installedPath, file])).values(),
]
await initProjects('must_revalidate')
if (uniqueAddedFiles.length > 0) {
const names = uniqueAddedFiles.map(({ filename, installedPath }) => {
const item = projects.value.find((project) => project.file_path === installedPath)
return item?.project?.title ?? filename
})
addNotification({
type: 'success',
title: formatMessage(messages.successfullyUploaded),
text:
names.length === 1
? formatMessage(messages.projectWasAdded, { name: names[0] })
: formatMessage(messages.projectsWereAdded, { count: names.length }),
})
}
}
function confirmUnknownFileInstallation(fileName: string) {
if (appSettings.getFeatureFlag(skipUnknownFileWarningFeatureFlag)) {
return Promise.resolve(true)
}
unknownFileName.value = fileName
return new Promise<boolean>((resolve) => {
resolveUnknownFileConfirmation = resolve
void nextTick(() => unknownFileWarningModal.value?.show())
})
}
function resolveUnknownFileWarning(confirmed: boolean) {
const resolve = resolveUnknownFileConfirmation
resolveUnknownFileConfirmation = null
unknownFileName.value = ''
resolve?.(confirmed)
}
async function handleUnknownFileContinue(dontShowAgain: boolean) {
if (dontShowAgain) {
appSettings.featureFlags[skipUnknownFileWarningFeatureFlag] = true
try {
const settings = await getSettings()
settings.feature_flags[skipUnknownFileWarningFeatureFlag] = true
await setSettings(settings)
} catch (error) {
handleError(error as Error)
}
}
resolveUnknownFileWarning(true)
}
async function toggleDisableMod(
mod: ContentItem,
desiredEnabled?: boolean,
reconcileSharedState = true,
) {
if (!mod.file_path || !canToggleContent(mod)) return
const operation = beginContentOperation(mod)
if (!operation) return
const originalFilePath = mod.file_path
try {
const newPath = await toggle_disable_project(instance.value.id, mod.file_path, desiredEnabled)
const newFileName = fileNameFromPath(newPath)
const enabled = !newPath.endsWith('.disabled')
mod.file_path = newPath
mod.file_name = newFileName
mod.enabled = enabled
managedContentModal.value?.updateItem(operation.originalFileName, {
file_path: newPath,
file_name: newFileName,
enabled,
})
updateLinkedModpackContentCache(mod, operation.originalFileName, originalFilePath, {
file_path: newPath,
file_name: newFileName,
enabled,
})
trackEvent('InstanceProjectDisable', {
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
disabled: !enabled,
})
if (reconcileSharedState) {
await reconcileSharedInstancePublishState()
}
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(mod, operation)
}
}
const toggleDisableDebounced = toggleDisableMod
async function removeMod(mod: ContentItem) {
if (!mod.file_path || !canDeleteContent(mod)) return
const operation = beginContentOperation(mod)
if (!operation) return
try {
const removedPath = mod.file_path
await remove_project(instance.value.id, removedPath)
projects.value = projects.value.filter((x) => removedPath !== x.file_path)
trackEvent('InstanceProjectRemove', {
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(mod, operation)
}
}
function isBreakingDependency(dependency: Labrinth.Versions.v2.Dependency) {
return dependency.dependency_type === 'required' || dependency.dependency_type === 'embedded'
}
function dependencyTargetsItem(dependency: Labrinth.Versions.v2.Dependency, item: ContentItem) {
return (
(!!dependency.project_id && dependency.project_id === item.project?.id) ||
('version_id' in dependency &&
!!dependency.version_id &&
dependency.version_id === item.version?.id)
)
}
async function getDeleteDependencyWarning(items: ContentItem[]) {
if (isServerInstance.value) return null
const deletingIds = new Set(items.map(getContentItemId))
const remainingItems = projects.value.filter((item) => !deletingIds.has(getContentItemId(item)))
const versionIds = [
...new Set(remainingItems.map((item) => item.version?.id).filter((id): id is string => !!id)),
]
if (versionIds.length === 0) return null
const versions = (await get_version_many(versionIds).catch((err) => {
handleError(err as Error)
return null
})) as Labrinth.Versions.v2.Version[] | null
if (!versions) return null
const versionsById = new Map(versions.map((version) => [version.id, version]))
const dependents = remainingItems
.map((candidate) => {
const version = candidate.version?.id ? versionsById.get(candidate.version.id) : null
if (!version) return null
const dependencies = items.filter((item) => {
if (!item.project?.id && !item.version?.id) return false
return version.dependencies?.some(
(dependency) =>
isBreakingDependency(dependency) && dependencyTargetsItem(dependency, item),
)
})
return dependencies.length > 0 ? { item: candidate, dependencies } : null
})
.filter(
(dependent): dependent is { item: ContentItem; dependencies: ContentItem[] } =>
dependent !== null,
)
return dependents.length > 0 ? { items, dependents } : null
}
function formatBulkUpdateProgress(progress: InstanceBulkUpdateProgress): BulkOperationStatus {
if (progress.stage === 'resolving_versions') {
return {
message: formatMessage(messages.bulkUpdateResolvingVersions),
waiting: true,
}
}
if (progress.stage === 'finishing') {
return {
message: formatMessage(messages.bulkUpdateFinishing),
progress: progress.current,
total: progress.total,
}
}
return {
message: formatMessage(messages.bulkUpdateDownloadingProjects, {
current: progress.current,
total: progress.total,
}),
progress: progress.current,
total: progress.total,
}
}
async function bulkUpdateAllProjects(onProgress?: (status: BulkOperationStatus) => void) {
let unlisten: (() => void) | null = null
try {
if (onProgress) {
onProgress({
message: formatMessage(messages.bulkUpdateResolvingVersions),
waiting: true,
})
unlisten = appEvents.on('instance_bulk_update_progress', (progress) => {
if (progress.instanceId !== instance.value.id) return
onProgress(formatBulkUpdateProgress(progress))
})
}
await update_all(instance.value.id)
await refreshContentState('must_revalidate')
} catch (err) {
handleError(err as Error)
throw err
} finally {
unlisten?.()
}
}
async function updateProject(mod: ContentItem) {
if (!canUpdateProject(mod) || mod.locked) return
const operation = beginContentOperation(mod)
if (!operation) return
try {
const updateVersionId = mod.update_version_id!
await switch_project_version_with_dependencies(
instance.value.id,
mod.file_path,
updateVersionId,
)
trackEvent('InstanceProjectUpdate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
throw err
} finally {
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
}
}
async function switchProjectVersion(mod: ContentItem, version: Labrinth.Versions.v2.Version) {
if (!canChangeContentVersion(mod)) return
if (!mod.file_path) return
const operation = beginContentOperation(mod)
if (!operation) return
const oldPath = mod.file_path
try {
await switch_project_version_with_dependencies(instance.value.id, oldPath, version.id)
trackEvent('InstanceProjectUpdate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
id: mod.project?.id,
name: mod.project?.title ?? mod.file_name,
project_type: mod.project_type,
})
} catch (err) {
handleError(err as Error)
} finally {
await refreshContentState('must_revalidate')
finishContentOperation(mod, operation)
}
}
async function handleUpdate(id: string) {
const item = projects.value.find((p) => getContentItemId(p) === id)
if (!item || item.locked || !canUpdateProject(item) || !item.project?.id || !item.version?.id)
return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
debug('handleUpdate triggered', {
fileName: item.file_name,
projectType: item.project_type,
projectId: item.project.id,
projectTitle: item.project.title,
currentVersionId: item.version.id,
currentVersionNumber: item.version.version_number,
updateVersionId: item.update_version_id,
instanceGameVersion: instance.value.game_version,
instanceLoader: instance.value.loader,
})
updatingModpack.value = false
updatingProject.value = item
updatingProjectVersions.value = []
loadingVersions.value = true
loadingChangelog.value = false
await nextTick()
const initialVersionId = item.update_version_id ?? undefined
debug('handleUpdate: opening content updater modal', {
type: 'content',
initialVersionId,
item: {
id: item.id,
fileName: item.file_name,
projectType: item.project_type,
projectId: item.project.id,
projectTitle: item.project.title,
currentVersionId: item.version.id,
currentVersionNumber: item.version.version_number,
updateVersionId: item.update_version_id,
},
instance: {
path: instance.value.id,
name: instance.value.name,
gameVersion: instance.value.game_version,
loader: instance.value.loader,
link: instance.value.link,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
updatingProjectId: updatingProject.value?.id,
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
id: version.id,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
datePublished: version.date_published,
})),
},
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = await getUpdaterProjectVersions(item.project.id, initialVersionId)
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
if (versions.length === 0) {
debug('handleUpdate: no versions returned', { projectId: item.project.id })
return
}
debug('handleUpdate: fetched versions', {
projectId: item.project.id,
projectType: item.project_type,
totalVersions: versions.length,
versionSample: versions.slice(0, 5).map((v) => ({
id: v.id,
number: v.version_number,
loaders: v.loaders,
gameVersions: v.game_versions,
})),
currentVersionInList: versions.some((v) => v.id === item.version?.id),
updateVersionInList: versions.some((v) => v.id === item.update_version_id),
})
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleUpdate: resolved content updater preselection', {
type: 'content',
initialVersionId,
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
preselectedVersion: preselectedVersion
? {
id: preselectedVersion.id,
versionNumber: preselectedVersion.version_number,
gameVersions: preselectedVersion.game_versions,
loaders: preselectedVersion.loaders,
datePublished: preselectedVersion.date_published,
}
: null,
versionCount: versions.length,
currentVersionId: item.version.id,
updateVersionId: item.update_version_id,
})
updatingProjectVersions.value = versions
}
async function handleSwitchVersion(item: ContentItem) {
if (!canChangeContentVersion(item)) return
if (!item.project?.id || !item.version?.id) return
const requestId = beginUpdateRequest()
const itemId = getContentItemId(item)
updatingModpack.value = false
updatingProject.value = item
updatingProjectVersions.value = []
loadingVersions.value = true
loadingChangelog.value = false
await nextTick()
const initialVersionId = item.version.id
contentUpdaterModal.value?.show(initialVersionId, { switchMode: true })
const versions = await getUpdaterProjectVersions(item.project.id, initialVersionId)
if (!isActiveUpdateRequest(requestId) || getContentItemId(updatingProject.value) !== itemId)
return
loadingVersions.value = false
updatingProjectVersions.value = versions
}
async function handleManagedContentToggle(item: ContentItem, enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning([item])) {
pendingManagedContentDisableItems.value = [item]
sharedDisableConfirmModal.value?.show()
return
}
await toggleDisableDebounced(item, enabled)
}
async function handleManagedContentBulkToggle(items: ContentItem[], enabled: boolean) {
if (!enabled && managedContentPolicy.disableWarning(items)) {
pendingManagedContentDisableItems.value = items
sharedDisableConfirmModal.value?.show()
return
}
await setManagedContentEnabled(items, enabled)
}
async function confirmPendingManagedContentDisable() {
const items = [...pendingManagedContentDisableItems.value]
pendingManagedContentDisableItems.value = []
await setManagedContentEnabled(items, false)
}
async function setManagedContentEnabled(items: ContentItem[], enabled: boolean) {
await Promise.all(items.map((item) => toggleDisableMod(item, enabled, false)))
await reconcileSharedInstancePublishState()
}
async function handleManagedContent() {
if (!instance.value?.id) return
if (modpackContentQuery.data.value !== undefined) {
managedContentModal.value?.show(managedContentItems.value)
return
}
managedContentModal.value?.showLoading()
const { data, error } = await modpackContentQuery.refetch()
if (data !== undefined) {
managedContentModal.value?.show(managedContentItems.value)
} else {
if (error) handleError(error)
managedContentModal.value?.hide()
}
}
async function refreshManagedContentItems(cacheBehaviour?: CacheBehaviour) {
if (!instance.value?.id) return
const contentItems = await queryClient
.fetchQuery({
queryKey: modpackContentQueryKey.value,
queryFn: () => get_linked_modpack_content(instance.value.id, cacheBehaviour),
})
.catch(handleError)
if (contentItems) {
managedContentModal.value?.setItems(managedContentItems.value)
}
}
async function refreshContentState(cacheBehaviour?: CacheBehaviour) {
await initProjects(cacheBehaviour)
await refreshManagedContentItems(cacheBehaviour)
}
watch(
() => installRevisionByInstance.value.get(instance.value.id) ?? 0,
async (revision) => {
if (revision <= handledInstallRevision.value) return
handledInstallRevision.value = revision
await refreshContentState('must_revalidate')
},
)
async function handleModpackUpdate() {
if (!instance.value?.link?.project_id) return
const requestId = beginUpdateRequest()
updatingModpack.value = true
updatingProject.value = null
updatingProjectVersions.value = []
loadingVersions.value = true
loadingChangelog.value = false
await nextTick()
const initialVersionId =
linkedModpackUpdateVersionId.value ?? instance.value?.link?.version_id ?? undefined
debug('handleModpackUpdate: opening modpack updater modal', {
type: 'modpack',
initialVersionId,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
linkedModpackProject: linkedModpackProject.value,
linkedModpackVersion: linkedModpackVersion.value,
instance: {
path: instance.value.id,
name: instance.value.name,
gameVersion: instance.value.game_version,
loader: instance.value.loader,
link: instance.value.link,
},
modalStateBeforeFetch: {
updatingModpack: updatingModpack.value,
updatingProjectId: updatingProject.value?.id,
updatingProjectVersions: updatingProjectVersions.value.map((version) => ({
id: version.id,
versionNumber: version.version_number,
gameVersions: version.game_versions,
loaders: version.loaders,
datePublished: version.date_published,
})),
},
})
contentUpdaterModal.value?.show(initialVersionId)
const versions = await getUpdaterProjectVersions(instance.value.link.project_id, initialVersionId)
if (!isActiveUpdateRequest(requestId) || !updatingModpack.value) return
loadingVersions.value = false
if (versions.length === 0) return
const preselectedVersion =
versions.find((version) => version.id === initialVersionId) ?? versions[0] ?? null
debug('handleModpackUpdate: resolved modpack updater preselection', {
type: 'modpack',
initialVersionId,
foundInitialVersion: versions.some((version) => version.id === initialVersionId),
preselectedVersion: preselectedVersion
? {
id: preselectedVersion.id,
versionNumber: preselectedVersion.version_number,
gameVersions: preselectedVersion.game_versions,
loaders: preselectedVersion.loaders,
datePublished: preselectedVersion.date_published,
}
: null,
versionCount: versions.length,
linkedModpackUpdateVersionId: linkedModpackUpdateVersionId.value,
currentLinkedVersionId: instance.value.link.version_id,
})
updatingProjectVersions.value = versions
}
async function fetchAndSpliceVersion(
versionId: string,
cacheBehaviour?: Parameters<typeof get_version>[1],
onError?: (err: unknown) => void,
requestId = activeUpdateRequestId.value,
) {
const fullVersion = (await get_version(versionId, cacheBehaviour).catch(
onError ?? (() => null),
)) as Labrinth.Versions.v2.Version | null
if (!isActiveUpdateRequest(requestId)) return
if (!fullVersion) return
updatingProjectVersions.value = mergeVersionIntoList(updatingProjectVersions.value, fullVersion)
}
async function handleVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return
const requestId = activeUpdateRequestId.value
loadingChangelog.value = true
await fetchAndSpliceVersion(
version.id,
'must_revalidate',
handleError as (err: unknown) => void,
requestId,
)
if (isActiveUpdateRequest(requestId)) {
loadingChangelog.value = false
}
}
async function handleVersionHover(version: Labrinth.Versions.v2.Version) {
if (version.changelog != null) return
await fetchAndSpliceVersion(version.id, undefined, undefined, activeUpdateRequestId.value)
}
function resetUpdateState() {
activeUpdateRequestId.value = 0
updatingModpack.value = false
updatingProject.value = null
updatingProjectVersions.value = []
loadingVersions.value = false
loadingChangelog.value = false
}
async function handleModpackUpdateRequest(selectedVersion: Labrinth.Versions.v2.Version) {
pendingModpackUpdateVersion.value = selectedVersion
const currentVersionId = instance.value?.link?.version_id
const currentVersion = updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isModpackUpdateDowngrade.value = currentVersion
? new Date(selectedVersion.date_published) < new Date(currentVersion.date_published)
: false
const shouldShowWarning =
isModpackUpdateDowngrade.value ||
versionChangesGameVersion(selectedVersion, instance.value.game_version)
if (skipNonEssentialWarnings.value || !shouldShowWarning) {
await handleModpackUpdateConfirm()
return
}
modpackUpdateConfirmModal.value?.show()
}
async function handleModpackUpdateConfirm() {
if (!pendingModpackUpdateVersion.value || !instance.value?.id) return
const version = pendingModpackUpdateVersion.value
pendingModpackUpdateVersion.value = null
contentUpdaterModal.value?.hide()
isModpackUpdating.value = true
try {
await update_managed_modrinth_version(instance.value.id, version.id)
await initProjects()
} finally {
isModpackUpdating.value = false
resetUpdateState()
}
}
function handleModpackUpdateCancel() {
pendingModpackUpdateVersion.value = null
}
async function handleModalUpdate(
selectedVersion: Labrinth.Versions.v2.Version,
event?: MouseEvent,
) {
if (updatingModpack.value) {
if (event?.shiftKey) {
pendingModpackUpdateVersion.value = selectedVersion
await handleModpackUpdateConfirm()
} else {
await handleModpackUpdateRequest(selectedVersion)
}
} else if (updatingProject.value) {
const mod = updatingProject.value
try {
if (mod.has_update && mod.update_version_id === selectedVersion.id) {
await updateProject(mod)
} else {
await switchProjectVersion(mod, selectedVersion)
}
} finally {
resetUpdateState()
}
}
}
async function unpairInstance() {
await edit(instance.value.id, {
link: null as unknown as undefined,
})
linkedModpackProject.value = null
linkedModpackVersion.value = null
linkedModpackUpdateVersionId.value = null
localImportedModpackUnlinked.value = true
await initProjects()
}
async function handleShareItems(
items: ContentItem[],
format: 'names' | 'file-names' | 'urls' | 'markdown',
) {
const source = items.length > 0 ? items : projects.value
let text: string
switch (format) {
case 'names':
text = source.map((x) => x.project?.title ?? x.file_name).join('\n')
break
case 'file-names':
text = source.map((x) => x.file_name).join('\n')
break
case 'urls':
text = source
.filter((x) => x.project?.slug)
.map((x) => `https://modrinth.com/${x.project_type}/${x.project?.slug}`)
.join('\n')
break
case 'markdown':
text = source
.map((x) => {
const name = x.project?.title ?? x.file_name
if (x.project?.slug) {
return `[${name}](https://modrinth.com/${x.project_type}/${x.project.slug})`
}
return name
})
.join('\n')
break
}
await shareModal.value?.show(text)
}
function getOverflowOptions(item: ContentItem): OverflowMenuOption[] {
const options: OverflowMenuOption[] = []
options.push({
id: 'show-file',
label: formatMessage(commonMessages.showFileButton),
icon: FolderOpenIcon,
action: () => highlightModInInstance(instance.value.id, item.file_path),
})
if (item.project?.slug) {
options.push({
id: 'copy-link',
label: formatMessage(commonMessages.copyLinkButton),
icon: ClipboardCopyIcon,
action: async () => {
await navigator.clipboard.writeText(
`https://modrinth.com/${item.project_type}/${item.project?.slug}`,
)
},
})
}
if (canMutateContent(item)) {
options.push(
{ type: 'divider' },
{
id: item.locked ? 'unfreeze-content' : 'freeze-content',
label: formatMessage(item.locked ? messages.unfreezeContent : messages.freezeContent),
icon: item.locked ? LockOpenIcon : LockIcon,
action: () => handleContentFreeze(item, !item.locked),
},
)
}
return options
}
async function handleContentFreeze(item: ContentItem, frozen: boolean) {
if (!item.file_path || !canMutateContent(item)) return
const operation = beginContentOperation(item)
if (!operation) return
const originalFilePath = item.file_path
try {
await set_project_locked(instance.value.id, item.file_path, frozen)
item.locked = frozen
managedContentModal.value?.updateItem(operation.originalFileName, { locked: frozen })
updateLinkedModpackContentCache(item, operation.originalFileName, originalFilePath, {
locked: frozen,
})
} catch (err) {
handleError(err as Error)
} finally {
finishContentOperation(item, operation)
}
}
async function initProjects(cacheBehaviour?: CacheBehaviour, staleTime = 0) {
if (!instance.value) return
const contentData = await queryClient.fetchQuery({
...instanceContentQueryOptions(instance.value.id),
queryFn: () => loadInstanceContentData(instance.value.id, cacheBehaviour, handleError),
staleTime,
})
applyContentData(contentData)
}
function applyContentData(contentData: InstanceContentData) {
if (contentData.path !== instance.value.id) {
return false
}
if (!contentData.contentItems) {
loading.value = false
return true
}
projects.value = contentData.contentItems.map((item) => ({
...item,
has_update: canUpdateProject(item),
}))
if (contentData.modpack) {
linkedModpackProject.value = contentData.modpack.project
linkedModpackVersion.value = contentData.modpack.version
linkedModpackUpdateVersionId.value = contentData.modpack.updateVersionId
} else {
linkedModpackProject.value = null
linkedModpackVersion.value = null
linkedModpackUpdateVersionId.value = null
}
loading.value = false
return true
}
function contentVersionLabel(item: ContentItem): string {
if (item.embedded_metadata?.version) return item.embedded_metadata.version
return formatMessage(commonMessages.unknownLabel)
}
provideContentManager({
items: mergedProjects,
loading,
error: ref(null),
managedContent,
isPackLocked,
isBusy: isInstanceBusy,
disableAddContent: isQuarantined,
disableAddContentTooltip: formatMessage(messages.lockedContent),
isBulkOperating,
skipNonEssentialWarnings,
contentTypeLabel: ref(formatMessage(messages.contentTypeProject)),
toggleEnabled: toggleDisableDebounced,
bulkEnableItems: async (items: ContentItem[]) => {
await Promise.all(
items
.filter((item) => canToggleContent(item) && !item.enabled)
.map((item) => toggleDisableMod(item, true, false)),
)
await reconcileSharedInstancePublishState()
},
bulkDisableItems: async (items: ContentItem[]) => {
await Promise.all(
items
.filter((item) => canToggleContent(item) && item.enabled)
.map((item) => toggleDisableMod(item, false, false)),
)
await reconcileSharedInstancePublishState()
},
deleteItem: removeMod,
bulkDeleteItems: (items: ContentItem[]) =>
Promise.all(items.filter(canDeleteContent).map((item) => removeMod(item))).then(() => {}),
canDeleteItem: canDeleteContent,
canToggleItem: canToggleContent,
getDeleteWarning: managedContentPolicy.deleteWarning,
getDisableWarning: managedContentPolicy.disableWarning,
getDeleteDependencyWarning,
refresh: () => initProjects('must_revalidate'),
browse: handleBrowseContent,
uploadFiles: handleUploadFiles,
hasUpdateSupport: true,
updateItem: handleUpdate,
bulkUpdateAll: bulkUpdateAllProjects,
bulkUpdateItem: updateProject,
runManagedContentPrimaryAction:
instance.value.shared_instance?.role === 'member'
? instancePage.reviewSharedInstanceUpdate
: instance.value.link?.type === 'modrinth_modpack' && !isQuarantined.value
? handleModpackUpdate
: undefined,
viewManagedContent: handleManagedContent,
unlinkModpack: unpairInstance,
openManagedContentSettings: openSettings,
switchVersion: handleSwitchVersion,
getOverflowOptions,
shareItems: handleShareItems,
getItemId: getContentItemId,
mapToTableItem: (item: ContentItem) => ({
id: getContentItemId(item),
project: item.project ?? {
id: item.file_name,
slug: null,
title: item.embedded_metadata?.name ?? item.file_name.replace('.disabled', ''),
icon_url: item.embedded_metadata?.icon_url ?? null,
},
projectLink: item.project?.id
? { path: `/project/${item.project.id}`, query: { i: instancePage.instanceId.value } }
: undefined,
version: item.version ?? {
id: item.file_name,
version_number: contentVersionLabel(item),
file_name: item.file_name,
},
versionLink:
item.project?.id && item.version?.id
? {
path: `/project/${item.project.id}/version/${item.version.id}`,
query: { i: instancePage.instanceId.value },
}
: undefined,
owner: item.owner
? {
...item.owner,
link: contentOwnerLink(item.owner),
}
: undefined,
external: item.external ?? !item.project,
enabled: canMutateContent(item) ? item.enabled : undefined,
locked: item.locked,
installing: item.installing,
hideDelete: !canDeleteContent(item),
hideSwitchVersion: !canChangeContentVersion(item) || !item.project?.id || !item.version?.id,
hasUpdate: canUpdateProject(item) && !item.locked,
}),
showSharedContentFilter,
filterPersistKey: instance.value.id,
})
type UnlistenFn = () => void
const initialContentReady = loadInitialContent()
void initialContentReady.then(restoreManagedContentModalState).catch(handleError)
function getInstallRevision() {
return installRevisionByInstance.value.get(instance.value.id) ?? 0
}
function loadInitialContent() {
const installRevision = getInstallRevision()
if (installRevision > handledInstallRevision.value) {
handledInstallRevision.value = installRevision
return initProjects('must_revalidate')
}
return initProjects(undefined, 30_000)
}
watch(
contentQuery.data,
(data) => {
if (data) applyContentData(data)
},
{ immediate: true },
)
watch(contentQuery.error, (error) => {
if (error) {
loading.value = false
handleError(error)
}
})
async function restoreManagedContentModalState() {
if (!savedModalState) return
const stateToRestore = savedModalState
savedModalState = null
await nextTick()
managedContentModal.value?.restore(stateToRestore)
}
// Save modal state when navigating away so it can be restored on back
const removeBeforeEach = router.beforeEach(() => {
const state = managedContentModal.value?.getState()
savedModalState = state ?? null
})
let isUnmounted = false
let unlistenDragDrop: UnlistenFn | null = null
useAppEvent('instance', async (event) => {
if (
instance.value &&
event.instance_id === instance.value.id &&
event.event === 'synced' &&
instance.value.install_stage === 'installed' &&
!isBulkOperating.value
) {
await initProjects()
}
})
onMounted(() => {
void getCurrentWebview()
.onDragDropEvent(async (event) => {
if (event.payload.type !== 'drop' || !instance.value) return
for (const file of event.payload.paths) {
if (file.endsWith('.mrpack')) continue
await add_project_from_path(instance.value.id, file).catch(handleError)
}
await initProjects()
})
.then((unlisten) => {
if (isUnmounted) {
unlisten()
return
}
unlistenDragDrop = unlisten
})
.catch(handleError)
})
watch(
() => instance.value?.install_stage,
async (newStage, oldStage) => {
if (oldStage !== 'installed' && newStage === 'installed') {
await refreshContentState('must_revalidate')
} else if (oldStage === 'not_installed' && newStage === 'pack_installing') {
await initProjects()
}
},
)
watch(
() => instance.value?.link,
async (newInstanceLink, oldInstanceLink) => {
if (oldInstanceLink && !newInstanceLink) {
await initProjects('must_revalidate')
}
},
)
watch(
() => instance.value?.update_channel,
async (newValue, oldValue) => {
if (newValue !== oldValue) {
await initProjects('must_revalidate')
}
},
)
onUnmounted(() => {
isUnmounted = true
removeBeforeEach()
unlistenDragDrop?.()
})
</script>