feat: content tab rewrite for worlds (#5136)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: base content tab internals rewrite

* feat: fix invalidmodal

* feat: add ContentModpackCard

* fix: extract types

* draft: layout

* feat: unlink modal

* feat: impl content tab

* fix: lint

* fix: toggling

* temp: disable updating stuff

* feat: selection v-model

* feat: bulk selection

* feat: mods tab rough draft

* feat: use fuse.js

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: use v-on

* feat: bulk actions + fix floating action bar width

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: use ContentCardTable + ContentCardItems

* feat: fix gap + border issues on last elm

* feat: cleanup + use proper searching

* fix: use TeleportOverflowMenu

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* feat: impl modal

* feat(app): backend changes for content tab refactor (#5237)

* feat: include_changelog=false for updater modal

* fix: hash overrides

* feat: update checking for modpack

* feat: qa

* feat: modpack content modal

* fix: padding in table to match modals + tightness

* fix: lint

* feat: delete modal

* feat: fix toggle bugs

* fix: prepr

* fix: duplicate messages

* qa: full width search

* qa: use bg-surface-1.5

* qa: animation for filter pills

* qa: standardize hover colors

* fix: border-[1px] is border

* qa: mass de-select actually mass selecting

* qa: match figma designs for floating action bar

* qa: modal fixes

* q: modal fixes x2

* fix: table border

* qa: confirm modals

* qa: modal alignment

* qa: re-add stuck heading + dedupe logic

* qa: dedupe virtual scrolling + remove dead components

* qa: responsiveness for content table + link fixes

* qa: version column link, tooltips + lint fixes

* qa: instance busy protections

* fix: installation freeze bug

* chore: remove old mods page

* refactor: deduplicate layout

* chore: delete old content page(s)

* qa

* qa

* qa

* feat: sort btn - to iterate

* fix: ml

* feat: date added

* fix: lint

* fix: formatting.ts removal

* feat: get_dependencies_as_content_items

* qa: final QA changes

* refactor: deduplicate + polish content.rs

* feat: hook up content.vue with v1

* feat: hide v1 content api behind frontend feature flag

* fix: query keys + copy on empty state

* chore: i18n pass

* feat: reimpl unlink + upload endpoint

* feat: use bulk endpoints v1

* fix: lint

* fix: flags

* fix: responsiveness via container queries

* fix: lint

* qa: 1

* qa: fixes

* qa: fix ssr issues with browse content

* qa: header page divider

* qa: modals

* fix: prepr

* fix: issues

* fix: lint

* fix: toggle v1 ff

* qa: 5

* qa: delete modal copy

* feat: creation flow modals (#5383)

* refactor: delete content v0 usages + impl

* feat: qa + fixes

* feat: installing banner using state event

* feat: fix modpack card bugs + filtering issues

* refactor: delete backups v0 api module

* feat: v1 servers GET endpoint

* fix: backups

* feat: swap to kyros upload v1 addon

* fix: use tanstack for loader.vue

* feat: finish install from discovery modal

* qa: bug fixes

* feat: set up installation settings

* fix: lint

* fix: typos

* fix: bugs

* fix: disable inline content

* feat: content tab improvements — upload UX, installation settings, and client-only indicators

   Upload cancellation and navigation guard:
   - Add ConfirmLeaveModal that prompts when navigating away during upload
   - Cancel in-flight XHR uploads when user confirms leaving the page
   - Add beforeunload handler to warn on browser/tab close during upload
   - Track uploadedBytes/totalBytes in UploadState for progress display
   - Replace Collapsible with Transition for upload progress admonition
   - Show byte progress and percentage in upload banner
   - Clamp upload progress to prevent exceeding 100%

   Installation settings (server.properties):
   - Add KnownPropertiesFields and PropertiesFields types to Archon types
   - Add buildProperties() to creation flow context to collect gamemode,
     difficulty, seed, world type, structures, and generator settings
   - Pass properties through installContent on onboarding, discovery, and
     ServerSetupModal flows

   Server setup and discovery flow improvements:
   - Migrate ServerSetupModal from servers_v0.reinstall to content_v1.installContent
   - Replace loaderApiNames lookup with toApiLoader() helper
   - Remove eraseDataOnInstall toggle — always use soft_override: false
   - Simplify modpack install on discovery page to use first available version
     and route through creation flow modal for both onboarding and non-onboarding
   - Differentiate post-install navigation: content page for onboarding,
     loader options for existing servers

   Modpack update flow:
   - Replace updateModpack() call with installContent() using soft_override: true
     to support version selection in the content updater modal

   Client-only mod indicators:
   - Add environment field to AddonVersion (reuses Labrinth.Projects.v3.Environment)
   - Add environment to ContentItem and isClientOnly to ContentCardTableItem
   - Show orange TriangleAlertIcon with tooltip on client-only mods in content table
   - Add "Client-only" filter pill to content filtering (controlled via
     showClientOnlyFilter on ContentManagerContext)
   - Apply client-only indicators in both ContentPageLayout and ModpackContentModal

   Misc:
   - Add CLAUDE.md note about using prepr commands for lint checks
   - Export ConfirmLeaveModal from instances barrel

* fix: piping

* fix: switch content disable for linked server instances

* feat: client only filter

* fix: prepr

* feat: hasUpdate shape update

* feat: bulk update endpoint impl for content in panel

* feat: websocket state impl again with new phases

* fix: ws

* fix: use timeout fn for sync admon + fix content card layout scroll for browsers with overflow anchor bug

* fix: qa bugs

* fix: lint, a11y and i18n

* refactor: set up layouts folder properly

* fix: linked data cache stuff + lint

* feat: move installationsettings to shared layout

* fix: lint

* fix: issues

* feat: temp fuck staging up

* fix: lockfile

* fix: data sync issues on loader.vue

* fix: lint

* Hide shader configuration files from content list (#5499)

* feat: workaround search problem + split out reset

* fix: qa

* fix: changelog not showing on first open

* fix: qa + optimistic updating improvements

* fix: prepr+lint

* fix: qa

* feat: qa

* fix: lint

* fix: lint

* fix: build

* fix: build

* fix: type errors

* fix: fade and JAVA_HOME passthrough

* feat: qa

* feat: impl diff shit

* fix: qa

* fix: app qa

* feat: update diff modal

* fix: endpoint

* fix: qa

* fix: qa

* fix: use bulk in modpack modal

* feat: abort signal impl + fix issues

* fix: diff modal trunc

* feat: qa

* fix: qa

* feat: tooltip content tab

* fix: prepr

* fix: dismiss on settings btn

* feat: qa

* feat: dont clear handlers on disconnect

* fix: lint

* fix: wrangler + introduce staging-archon env file

---------

Signed-off-by: Calum H. <calum@modrinth.com>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
Co-authored-by: Artyom Ezri <61311568+Artezon@users.noreply.github.com>
This commit is contained in:
Calum H.
2026-03-12 13:24:32 -07:00
committed by GitHub
co-authored by tdgao Artyom Ezri
parent f0224dfff7
commit 7d92e4ec7f
302 changed files with 20016 additions and 12142 deletions
@@ -0,0 +1,222 @@
<template>
<NewModal ref="modal" :header="header" :closable="true" no-padding>
<div class="max-w-[500px]">
<div class="flex flex-col gap-4 p-4">
<Admonition :type="hasUnknownContent ? 'warning' : 'info'" :header="admonitionHeader">
{{ description }}
</Admonition>
<Admonition
v-if="hasUnknownContent"
type="warning"
:header="formatMessage(messages.unknownContentHeader)"
>
{{ formatMessage(messages.unknownContentBody) }}
</Admonition>
<div v-if="diffs.length" class="flex gap-2">
<div v-if="removedCount" class="flex gap-1 items-center">
<MinusIcon />
{{ formatMessage(messages.removedCount, { count: removedCount }) }}
</div>
<div v-if="addedCount" class="flex gap-1 items-center">
<PlusIcon />
{{ formatMessage(messages.addedCount, { count: addedCount }) }}
</div>
<div v-if="updatedCount" class="flex gap-1 items-center">
<RefreshCwIcon />
{{ formatMessage(messages.updatedCount, { count: updatedCount }) }}
</div>
</div>
</div>
<div
v-if="diffs.length"
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
>
<div
v-for="(diff, index) in sortedDiffs"
:key="diff.projectName || diff.fileName || index"
class="grid items-center min-h-10 h-10 gap-2"
:class="diff.projectName ? 'grid-cols-[auto_auto_1fr]' : 'grid-cols-[auto_auto_1fr]'"
>
<div class="flex flex-col justify-between items-center">
<div class="w-[1px] h-2"></div>
<PlusIcon v-if="diff.type === 'added'" />
<MinusIcon v-else-if="diff.type === 'removed'" class="text-red" />
<RefreshCwIcon v-else />
<div
:class="index === sortedDiffs.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
class="w-[1px] h-2 relative top-1"
></div>
</div>
<span class="text-sm shrink-0 whitespace-nowrap">{{
diff.type === 'removed' && props.removedLabel
? props.removedLabel
: formatMessage(diffTypeMessages[diff.type])
}}</span>
<span
v-if="diff.projectName"
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
>
{{ diff.projectName }}
</span>
<span
v-else-if="diff.fileName"
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
>
{{ decodeURIComponent(diff.fileName) }}
</span>
</div>
</div>
<div
v-if="showBackupCreator"
class="p-4 border-t border-solid border-surface-5 border-b-0 border-l-0 border-r-0"
>
<InlineBackupCreator
ref="backupCreator"
backup-name="Before version change"
@update:buttons-disabled="buttonsDisabled = $event"
/>
</div>
</div>
<template #actions>
<div class="flex justify-between gap-2">
<div>
<ButtonStyled v-if="showReportButton" color="red" type="transparent">
<button @click="emit('report')">
<ReportIcon />
{{ formatMessage(commonMessages.reportButton) }}
</button>
</ButtonStyled>
</div>
<div class="flex gap-2">
<ButtonStyled>
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="buttonsDisabled" @click="handleConfirm">
<component :is="confirmIcon" v-if="confirmIcon" />
{{ confirmLabel || formatMessage(commonMessages.confirmButton) }}
</button>
</ButtonStyled>
</div>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { MinusIcon, PlusIcon, RefreshCwIcon, ReportIcon, XIcon } from '@modrinth/assets'
import { type Component, computed, 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 } from '#ui/utils/common-messages'
import InlineBackupCreator from '../../content-tab/components/modals/InlineBackupCreator.vue'
import type { ContentDiffItem } from '../types'
const props = defineProps<{
header: string
description?: string
admonitionHeader?: string
diffs: ContentDiffItem[]
hasUnknownContent?: boolean
confirmLabel?: string
confirmIcon?: Component
showReportButton?: boolean
showBackupCreator?: boolean
removedLabel?: string
}>()
const emit = defineEmits<{
confirm: []
cancel: []
report: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const backupCreator = ref<InstanceType<typeof InlineBackupCreator>>()
const buttonsDisabled = 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 sortedDiffs = computed(() =>
[...props.diffs].sort((a, b) => {
const typeOrder = { added: 0, updated: 1, removed: 2 }
return typeOrder[a.type] - typeOrder[b.type]
}),
)
function show(e?: MouseEvent) {
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
function handleConfirm() {
hide()
emit('confirm')
}
function handleCancel() {
hide()
emit('cancel')
}
const messages = defineMessages({
removedCount: {
id: 'content.diff-modal.removed-count',
defaultMessage: '{count} removed',
},
addedCount: {
id: 'content.diff-modal.added-count',
defaultMessage: '{count} added',
},
updatedCount: {
id: 'content.diff-modal.updated-count',
defaultMessage: '{count} updated',
},
unknownContentHeader: {
id: 'content.diff-modal.unknown-content-header',
defaultMessage: 'Unknown content',
},
unknownContentBody: {
id: 'content.diff-modal.unknown-content-body',
defaultMessage:
'Some content on your server could not be analyzed and may be affected by this change.',
},
})
const diffTypeMessages = defineMessages({
added: {
id: 'content.diff-modal.diff-type.added',
defaultMessage: 'Added (dependency)',
},
removed: {
id: 'content.diff-modal.diff-type.removed',
defaultMessage: 'Removed',
},
updated: {
id: 'content.diff-modal.diff-type.updated',
defaultMessage: 'Updated',
},
})
defineExpose({ show, hide })
</script>
@@ -0,0 +1 @@
export { useInstallationForm } from './use-installation-form'
@@ -0,0 +1,278 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Ref } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { formatLoaderLabel } from '#ui/utils/loaders'
import type { ContentUpdaterModal } from '../../content-tab'
import type ContentDiffModal from '../components/ContentDiffModal.vue'
import type { InstallationSettingsContext } from '../providers/installation-settings'
import type { ContentDiffPreview } from '../types'
export function useInstallationForm(
ctx: InstallationSettingsContext,
updaterModalRef: Ref<InstanceType<typeof ContentUpdaterModal> | null | undefined>,
contentDiffModalRef?: Ref<InstanceType<typeof ContentDiffModal> | null | undefined>,
) {
const isEditing = ref(false)
const selectedPlatform = ctx.editingPlatformRef ?? ref(ctx.currentPlatform.value)
const selectedGameVersion = ctx.editingGameVersionRef ?? ref(ctx.currentGameVersion.value)
const selectedLoaderVersion = ref(0)
const showSnapshots = ref(false)
const isSaving = ref(false)
const isVerifying = ref(false)
const pendingPreview = ref<ContentDiffPreview | null>(null)
let abortController: AbortController | null = null
const gameVersionOptions = computed(() =>
ctx.resolveGameVersions(selectedPlatform.value, showSnapshots.value),
)
const loaderVersionEntries = computed(() =>
ctx.resolveLoaderVersions(selectedPlatform.value, selectedGameVersion.value),
)
const loaderVersionOptions = computed(() =>
loaderVersionEntries.value.map((v, index) => ({ value: index, label: v.id })),
)
const loaderVersionDisplayValue = computed(() => {
const idx = selectedLoaderVersion.value
return idx >= 0 && loaderVersionEntries.value[idx] ? loaderVersionEntries.value[idx].id : ''
})
const hasSnapshots = computed(() => ctx.resolveHasSnapshots(selectedPlatform.value))
const formattedLoaderName = computed(() => formatLoaderLabel(selectedPlatform.value))
const isValid = computed(() => {
if (!selectedGameVersion.value) return false
if (selectedPlatform.value !== 'vanilla') {
return selectedLoaderVersion.value >= 0 && loaderVersionEntries.value.length > 0
}
return true
})
const hasChanges = computed(() => {
if (selectedPlatform.value !== ctx.currentPlatform.value) return true
if (selectedGameVersion.value !== ctx.currentGameVersion.value) return true
if (
selectedPlatform.value !== 'vanilla' &&
loaderVersionEntries.value[selectedLoaderVersion.value]?.id !== ctx.currentLoaderVersion.value
) {
return true
}
return false
})
watch(selectedPlatform, () => {
selectedLoaderVersion.value = 0
})
watch(selectedGameVersion, () => {
selectedLoaderVersion.value = 0
})
async function save() {
isSaving.value = true
try {
const isModded = ctx.currentPlatform.value !== 'vanilla'
const gameVersionChanged = selectedGameVersion.value !== ctx.currentGameVersion.value
if (ctx.previewSave && isModded && gameVersionChanged) {
isVerifying.value = true
abortController = new AbortController()
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
: null
let preview: ContentDiffPreview | null
try {
preview = await ctx.previewSave(
selectedPlatform.value,
selectedGameVersion.value,
loaderVersionId,
abortController.signal,
)
} finally {
isVerifying.value = false
abortController = null
}
if (preview && (preview.diffs.length > 0 || preview.hasUnknownContent)) {
pendingPreview.value = preview
await nextTick()
contentDiffModalRef?.value?.show()
return
}
}
await performSave()
} catch {
isSaving.value = false
}
}
async function performSave() {
try {
const loaderVersionId =
selectedPlatform.value !== 'vanilla'
? (loaderVersionEntries.value[selectedLoaderVersion.value]?.id ?? null)
: null
await ctx.save(selectedPlatform.value, selectedGameVersion.value, loaderVersionId)
if (ctx.afterSave) await ctx.afterSave()
isEditing.value = false
} finally {
isSaving.value = false
}
}
async function confirmSave() {
pendingPreview.value = null
try {
await performSave()
} catch {
// Error handled in performSave
}
}
function cancelPreview() {
pendingPreview.value = null
isSaving.value = false
}
function cancelEditing() {
abortController?.abort()
abortController = null
isVerifying.value = false
isSaving.value = false
pendingPreview.value = null
selectedPlatform.value = ctx.currentPlatform.value
selectedGameVersion.value = ctx.currentGameVersion.value
const currentId = ctx.currentLoaderVersion.value
const entries = ctx.resolveLoaderVersions(
ctx.currentPlatform.value,
ctx.currentGameVersion.value,
)
selectedLoaderVersion.value = Math.max(
entries.findIndex((e) => e.id === currentId),
0,
)
isEditing.value = false
}
// Modpack updater state
const updatingModpack = ref(false)
const updatingProjectVersions = ref<Labrinth.Versions.v2.Version[]>([])
const loadingVersions = ref(false)
const loadingChangelog = ref(false)
async function handleChangeModpackVersion() {
updatingModpack.value = true
loadingChangelog.value = false
const cached = ctx.getCachedModpackVersions()
if (cached) {
updatingProjectVersions.value = [...cached].sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
loadingVersions.value = false
} else {
updatingProjectVersions.value = []
loadingVersions.value = true
}
await nextTick()
updaterModalRef.value?.show(ctx.updaterModalProps.value.currentVersionId || undefined)
if (!cached) {
try {
const versions = await ctx.fetchModpackVersions()
versions.sort(
(a, b) => new Date(b.date_published).getTime() - new Date(a.date_published).getTime(),
)
updatingProjectVersions.value = versions
} catch {
// Error handled by context
} finally {
loadingVersions.value = false
}
}
}
function spliceVersion(full: Labrinth.Versions.v2.Version) {
const i = updatingProjectVersions.value.findIndex((v) => v.id === full.id)
if (i !== -1) {
const arr = [...updatingProjectVersions.value]
arr[i] = full
updatingProjectVersions.value = arr
}
}
async function handleUpdaterVersionSelect(version: Labrinth.Versions.v2.Version) {
if (version.changelog) return
loadingChangelog.value = true
try {
const full = await ctx.getVersionChangelog(version.id)
if (full) spliceVersion(full)
} finally {
loadingChangelog.value = false
}
}
async function handleUpdaterVersionHover(version: Labrinth.Versions.v2.Version) {
if (version.changelog) return
try {
const full = await ctx.getVersionChangelog(version.id)
if (full) spliceVersion(full)
} catch {
// silent
}
}
function resetUpdateState() {
updatingModpack.value = false
updatingProjectVersions.value = []
loadingVersions.value = false
loadingChangelog.value = false
}
async function handleUpdaterConfirm(version: Labrinth.Versions.v2.Version) {
try {
await ctx.onModpackVersionConfirm(version)
} finally {
resetUpdateState()
}
}
return {
isEditing,
selectedPlatform,
selectedGameVersion,
selectedLoaderVersion,
showSnapshots,
isSaving,
isVerifying,
gameVersionOptions,
loaderVersionOptions,
loaderVersionDisplayValue,
hasSnapshots,
formattedLoaderName,
isValid,
hasChanges,
save,
pendingPreview,
confirmSave,
cancelPreview,
cancelEditing,
updatingModpack,
updatingProjectVersions,
loadingVersions,
loadingChangelog,
handleChangeModpackVersion,
handleUpdaterVersionSelect,
handleUpdaterVersionHover,
handleUpdaterConfirm,
resetUpdateState,
}
}
@@ -0,0 +1,5 @@
export { default as ContentDiffModal } from './components/ContentDiffModal.vue'
export { useInstallationForm } from './composables'
export { default as InstallationSettingsLayout } from './layout.vue'
export * from './providers'
export * from './types'
@@ -0,0 +1,710 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ArrowLeftRightIcon,
CircleAlertIcon,
DownloadIcon,
EyeIcon,
EyeOffIcon,
HammerIcon,
PencilIcon,
SaveIcon,
SpinnerIcon,
UnlinkIcon,
XIcon,
} from '@modrinth/assets'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { onBeforeRouteLeave } from 'vue-router'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Chips from '#ui/components/base/Chips.vue'
import Combobox from '#ui/components/base/Combobox.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import ConfirmLeaveModal from '../content-tab/components/modals/ConfirmLeaveModal.vue'
import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue'
import ConfirmRepairModal from '../content-tab/components/modals/ConfirmRepairModal.vue'
import ConfirmUnlinkModal from '../content-tab/components/modals/ConfirmUnlinkModal.vue'
import ContentUpdaterModal from '../content-tab/components/modals/ContentUpdaterModal.vue'
import ContentDiffModal from './components/ContentDiffModal.vue'
import { useInstallationForm } from './composables'
import { injectInstallationSettings } from './providers/installation-settings'
const { formatMessage } = useVIntl()
const ctx = injectInstallationSettings()
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
const repairModal = ref<InstanceType<typeof ConfirmRepairModal>>()
const reinstallModal = ref<InstanceType<typeof ConfirmReinstallModal>>()
const unlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal> | null>()
const contentDiffModal = ref<InstanceType<typeof ContentDiffModal>>()
const modpackUpdateModal = ref<InstanceType<typeof ConfirmModpackUpdateModal>>()
const pendingUpdateVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const isUpdateDowngrade = ref(false)
const form = useInstallationForm(ctx, contentUpdaterModal, contentDiffModal)
function handleBeforeUnload(e: BeforeUnloadEvent) {
if (form.isSaving.value) {
e.preventDefault()
return ''
}
}
if (typeof window !== 'undefined') {
watch(
() => form.isSaving.value,
(saving) => {
if (saving) {
window.addEventListener('beforeunload', handleBeforeUnload)
} else {
window.removeEventListener('beforeunload', handleBeforeUnload)
}
},
)
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
onBeforeRouteLeave(async () => {
if (form.isSaving.value) {
return (await confirmLeaveModal.value?.prompt()) ?? false
}
return true
})
}
const disabledPlatforms = computed(() => {
if (!ctx.lockPlatform || ctx.currentPlatform.value === 'vanilla') return []
return ctx.availablePlatforms.filter((p) => p !== ctx.currentPlatform.value)
})
const showModpackVersionActions = ctx.showModpackVersionActions ?? true
function handleModpackUpdateRequest(version: Labrinth.Versions.v2.Version) {
pendingUpdateVersion.value = version
const currentVersionId = ctx.updaterModalProps.value.currentVersionId
const currentVersion = form.updatingProjectVersions.value.find((v) => v.id === currentVersionId)
isUpdateDowngrade.value = currentVersion
? new Date(version.date_published) < new Date(currentVersion.date_published)
: false
modpackUpdateModal.value?.show()
}
function handleModpackUpdateConfirm() {
if (pendingUpdateVersion.value) {
form.cancelEditing()
form.handleUpdaterConfirm(pendingUpdateVersion.value)
pendingUpdateVersion.value = null
}
}
function handleModpackUpdateCancel() {
pendingUpdateVersion.value = null
}
function handleRepair() {
form.cancelEditing()
ctx.repair()
}
function handleReinstall() {
form.cancelEditing()
ctx.reinstallModpack()
}
function handleUnlink() {
form.cancelEditing()
ctx.unlinkModpack()
}
defineExpose({
cancelEditing: () => form.cancelEditing(),
})
const messages = defineMessages({
linkedInstanceTitle: {
id: 'installation-settings.linked-instance.title',
defaultMessage: 'Linked {projectType, select, server {server project} other {modpack}}',
},
reinstallModpackTitle: {
id: 'installation-settings.reinstall-modpack.title',
defaultMessage: 'Re-install modpack',
},
reinstallModpackDescription: {
id: 'installation-settings.reinstall-modpack.description',
defaultMessage:
"Re-installing the modpack resets the {type, select, server {server's} other {instance's}} content to its original state, removing any mods or content you have added.",
},
editInstallationTitle: {
id: 'installation-settings.edit-installation.title',
defaultMessage: 'Edit installation',
},
unlinkDescription: {
id: 'installation-settings.unlink.description',
defaultMessage:
"Unlinking permanently disconnects this {type, select, server {server} other {instance}} from the {projectType, select, server {server} other {modpack}} project, allowing you to change the loader and Minecraft version, but you won't receive future updates.",
},
repairInstanceTitle: {
id: 'installation-settings.repair.instance-title',
defaultMessage: 'Repair instance',
},
repairInstanceDescription: {
id: 'installation-settings.repair.instance-description',
defaultMessage:
'Reinstalls Minecraft dependencies and checks for corruption. This may resolve issues if your game is not launching due to launcher-related errors.',
},
repairServerTitle: {
id: 'installation-settings.repair.server-title',
defaultMessage: 'Repair server',
},
repairServerDescription: {
id: 'installation-settings.repair.server-description',
defaultMessage:
'Reinstalls the loader and Minecraft dependencies without deleting your content. This may resolve issues if your server is not starting correctly.',
},
editWarningInstance: {
id: 'installation-settings.edit.warning-instance',
defaultMessage:
"We don't recommend editing your installation settings after installing content. If you want to edit them, be cautious as it may cause issues.",
},
editWarningServer: {
id: 'installation-settings.edit.warning-server',
defaultMessage:
"We don't recommend editing your installation settings after installing content. If you want to edit them reset your server.",
},
loaderVersionLabel: {
id: 'installation-settings.loader-version',
defaultMessage: '{loader} version',
},
searchGameVersionPlaceholder: {
id: 'installation-settings.search-game-version',
defaultMessage: 'Search game version...',
},
savingLabel: {
id: 'installation-settings.saving',
defaultMessage: 'Saving...',
},
verifyingLabel: {
id: 'installation-settings.verifying',
defaultMessage: 'Verifying...',
},
selectPlatformAriaLabel: {
id: 'installation-settings.aria.select-platform',
defaultMessage: 'Select platform',
},
selectGameVersionAriaLabel: {
id: 'installation-settings.aria.select-game-version',
defaultMessage: 'Select game version',
},
selectLoaderVersionAriaLabel: {
id: 'installation-settings.aria.select-loader-version',
defaultMessage: 'Select {loader} version',
},
reinstallingModpackButton: {
id: 'installation-settings.reinstalling-modpack',
defaultMessage: 'Reinstalling modpack',
},
unlinkButton: {
id: 'installation-settings.unlink',
defaultMessage: 'Unlink',
},
platformLockTooltip: {
id: 'installation-settings.platform-lock-tooltip',
defaultMessage: 'You will need to reset your server to switch loader.',
},
confirmVersionChangeHeader: {
id: 'installation-settings.confirm-version-change-header',
defaultMessage: 'Review content changes',
},
confirmVersionChange: {
id: 'installation-settings.confirm-version-change',
defaultMessage: 'Confirm',
},
confirmVersionChangeDescription: {
id: 'installation-settings.confirm-version-change-description',
defaultMessage: 'Changing to {gameVersion} will modify the following content on your server.',
},
removedIncompatible: {
id: 'installation-settings.removed-incompatible',
defaultMessage: 'Removed (incompatible)',
},
})
</script>
<template>
<div class="flex flex-col gap-6">
<!-- Loading state -->
<div v-if="ctx.loading.value" class="flex items-center justify-center py-12">
<SpinnerIcon class="size-8 animate-spin text-secondary" />
</div>
<template v-else>
<!-- Installation Info (linked state) -->
<div v-if="ctx.isLinked.value" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.installationInfoTitle) }}
</span>
<div class="flex flex-col gap-2.5 rounded-[20px] bg-surface-2 p-4">
<div
v-for="row in ctx.installationInfo.value"
:key="row.label"
class="flex items-center justify-between"
>
<span class="text-primary">{{ row.label }}</span>
<span v-if="row.value" class="font-semibold text-contrast">{{ row.value }}</span>
<span
v-else
class="inline-block h-3 w-16 animate-pulse rounded bg-button-border"
></span>
</div>
</div>
</div>
<!-- LINKED -->
<template v-if="ctx.isLinked.value">
<!-- Installed Modpack -->
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.installedModpackTitle) }}
</span>
<div
v-if="ctx.modpack.value"
class="flex items-center gap-2.5 rounded-[20px] bg-surface-2 p-3"
>
<AutoLink :to="ctx.modpack.value.link" class="shrink-0">
<div
class="size-14 shrink-0 overflow-hidden rounded-2xl border border-solid border-surface-5"
>
<Avatar
v-if="ctx.modpack.value.iconUrl"
:src="ctx.modpack.value.iconUrl"
:alt="ctx.modpack.value.title"
size="100%"
no-shadow
/>
</div>
</AutoLink>
<div class="flex flex-col gap-1">
<AutoLink
:to="ctx.modpack.value.link"
class="font-semibold text-contrast hover:underline"
>
{{ ctx.modpack.value.title }}
</AutoLink>
<div class="flex items-center gap-2 text-sm text-secondary">
<AutoLink
v-if="ctx.modpack.value.owner"
:to="
ctx.modpack.value.owner.type === 'organization'
? `/organization/${ctx.modpack.value.owner.id}`
: `/user/${ctx.modpack.value.owner.id}`
"
class="flex items-center gap-1.5 hover:underline"
>
<Avatar
:src="ctx.modpack.value.owner.iconUrl"
:alt="ctx.modpack.value.owner.name"
size="1.25rem"
:circle="ctx.modpack.value.owner.type === 'user'"
no-shadow
/>
<span class="font-medium">{{ ctx.modpack.value.owner.name }}</span>
</AutoLink>
<template v-if="ctx.modpack.value.owner && ctx.modpack.value.versionNumber">
&middot;
</template>
<span v-if="ctx.modpack.value.versionNumber" class="font-medium">
{{ ctx.modpack.value.versionNumber }}
</span>
</div>
</div>
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled v-if="showModpackVersionActions">
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="form.handleChangeModpackVersion()"
>
<ArrowLeftRightIcon class="size-5" />
{{ formatMessage(commonMessages.changeVersionButton) }}
</button>
</ButtonStyled>
</div>
</div>
<!-- Unlink -->
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(messages.linkedInstanceTitle, {
projectType: showModpackVersionActions ? 'modpack' : 'server',
})
}}
</span>
<span class="text-primary">
{{
formatMessage(messages.unlinkDescription, {
type: ctx.isServer ? 'server' : 'instance',
projectType: showModpackVersionActions ? 'modpack' : 'server',
})
}}
</span>
<div>
<ButtonStyled color="orange">
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="unlinkModal?.show()"
>
<UnlinkIcon class="size-5" />
{{
formatMessage(
showModpackVersionActions
? commonMessages.unlinkModpackButton
: messages.unlinkButton,
)
}}
</button>
</ButtonStyled>
</div>
</div>
<!-- Reinstall -->
<div v-if="showModpackVersionActions" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.reinstallModpackTitle) }}
</span>
<span class="text-primary">
{{
formatMessage(messages.reinstallModpackDescription, {
type: ctx.isServer ? 'server' : 'instance',
})
}}
</span>
<div>
<ButtonStyled color="red">
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="reinstallModal?.show()"
>
<SpinnerIcon v-if="ctx.reinstalling?.value" class="animate-spin" />
<DownloadIcon v-else class="size-5" />
{{
ctx.reinstalling?.value
? formatMessage(messages.reinstallingModpackButton)
: formatMessage(commonMessages.reinstallModpackButton)
}}
</button>
</ButtonStyled>
</div>
</div>
<!-- Repair -->
<div class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(
ctx.isServer ? messages.repairServerTitle : messages.repairInstanceTitle,
)
}}
</span>
<span class="text-primary">
{{
formatMessage(
ctx.isServer
? messages.repairServerDescription
: messages.repairInstanceDescription,
)
}}
</span>
<div>
<ButtonStyled>
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="repairModal?.show()"
>
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
<HammerIcon v-else class="size-5" />
{{
ctx.repairing?.value
? formatMessage(commonMessages.repairingButton)
: formatMessage(commonMessages.repairButton)
}}
</button>
</ButtonStyled>
</div>
</div>
</template>
<!-- NOT LINKED -->
<template v-else>
<!-- Edit form -->
<div v-if="form.isEditing.value" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.editInstallationTitle) }}
</span>
<div class="flex flex-col gap-3 rounded-[20px] border border-solid border-surface-5 p-4">
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(commonMessages.platformLabel) }}
</span>
<Chips
v-model="form.selectedPlatform.value"
:items="ctx.availablePlatforms"
:disabled-items="disabledPlatforms"
:disabled-tooltip="formatMessage(messages.platformLockTooltip)"
:aria-label="formatMessage(messages.selectPlatformAriaLabel)"
/>
</div>
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(commonMessages.gameVersionLabel) }}
</span>
<Combobox
v-model="form.selectedGameVersion.value"
:options="form.gameVersionOptions.value"
searchable
sync-with-selection
:placeholder="formatMessage(commonMessages.selectVersionPlaceholder)"
:search-placeholder="formatMessage(messages.searchGameVersionPlaceholder)"
:display-value="
form.selectedGameVersion.value ||
formatMessage(commonMessages.selectVersionPlaceholder)
"
:aria-label="formatMessage(messages.selectGameVersionAriaLabel)"
>
<template v-if="form.hasSnapshots.value" #dropdown-footer>
<button
class="flex w-full cursor-pointer items-center justify-center gap-1.5 border-0 border-t border-solid border-surface-5 bg-transparent py-3 text-center text-sm font-semibold text-secondary transition-colors hover:text-contrast"
@mousedown.prevent
@click="form.showSnapshots.value = !form.showSnapshots.value"
>
<EyeOffIcon v-if="form.showSnapshots.value" class="size-4" />
<EyeIcon v-else class="size-4" />
{{
form.showSnapshots.value
? formatMessage(commonMessages.hideSnapshotsButton)
: formatMessage(commonMessages.showAllVersionsButton)
}}
</button>
</template>
</Combobox>
</div>
<div
v-if="form.selectedPlatform.value !== 'vanilla' && !ctx.hideLoaderVersion"
class="flex flex-col gap-2.5"
>
<span class="font-semibold text-contrast">
{{
formatMessage(messages.loaderVersionLabel, {
loader: form.formattedLoaderName.value,
})
}}
</span>
<Combobox
v-model="form.selectedLoaderVersion.value"
searchable
sync-with-selection
:placeholder="
form.loaderVersionDisplayValue.value ||
formatMessage(commonMessages.selectVersionPlaceholder)
"
:search-placeholder="formatMessage(commonMessages.searchVersionPlaceholder)"
:options="form.loaderVersionOptions.value"
:display-value="
form.loaderVersionDisplayValue.value ||
formatMessage(commonMessages.selectVersionPlaceholder)
"
:aria-label="
formatMessage(messages.selectLoaderVersionAriaLabel, {
loader: form.formattedLoaderName.value,
})
"
/>
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button
class="!shadow-none"
:disabled="!form.isValid.value || !form.hasChanges.value || form.isSaving.value"
@click="form.save()"
>
<SpinnerIcon v-if="form.isSaving.value" class="animate-spin" />
<SaveIcon v-else />
{{
form.isVerifying.value
? formatMessage(messages.verifyingLabel)
: form.isSaving.value
? formatMessage(messages.savingLabel)
: formatMessage(commonMessages.saveButton)
}}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button
class="!border !border-surface-5 !shadow-none"
@click="form.cancelEditing()"
>
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
<!-- Non-editing: installation info + warning + edit button -->
<div v-if="!form.isEditing.value" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(commonMessages.installationInfoTitle) }}
</span>
<div class="flex flex-col gap-2.5 rounded-[20px] bg-surface-2 p-4">
<div
v-for="row in ctx.installationInfo.value"
:key="row.label"
class="flex items-center justify-between"
>
<span class="text-primary">{{ row.label }}</span>
<span class="font-semibold text-contrast">{{ row.value }}</span>
</div>
</div>
<div class="flex items-start gap-2">
<CircleAlertIcon class="mt-0.5 size-5 shrink-0 text-orange" />
<span class="text-primary">
{{
formatMessage(
ctx.isServer ? messages.editWarningServer : messages.editWarningInstance,
)
}}
</span>
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="orange">
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="form.isEditing.value = true"
>
<PencilIcon class="size-5" />
{{ formatMessage(commonMessages.editButton) }}
</button>
</ButtonStyled>
<slot name="unlinked-extra-buttons" />
</div>
</div>
<!-- Repair section -->
<div v-if="ctx.currentPlatform.value !== 'vanilla'" class="flex flex-col gap-2.5">
<span class="text-lg font-semibold text-contrast">
{{
formatMessage(
ctx.isServer ? messages.repairServerTitle : messages.repairInstanceTitle,
)
}}
</span>
<span class="text-primary">
{{
formatMessage(
ctx.isServer
? messages.repairServerDescription
: messages.repairInstanceDescription,
)
}}
</span>
<div>
<ButtonStyled>
<button
class="!shadow-none"
:disabled="ctx.isBusy.value"
@click="repairModal?.show()"
>
<SpinnerIcon v-if="ctx.repairing?.value" class="animate-spin" />
<HammerIcon v-else class="size-5" />
{{
ctx.repairing?.value
? formatMessage(commonMessages.repairingButton)
: formatMessage(commonMessages.repairButton)
}}
</button>
</ButtonStyled>
</div>
</div>
</template>
<slot name="extra" />
</template>
</div>
<!-- Modals -->
<Teleport to="body">
<ContentUpdaterModal
v-if="form.updatingModpack.value"
ref="contentUpdaterModal"
:versions="form.updatingProjectVersions.value"
:current-game-version="ctx.updaterModalProps.value.currentGameVersion"
:current-loader="ctx.updaterModalProps.value.currentLoader"
:current-version-id="ctx.updaterModalProps.value.currentVersionId"
:is-app="ctx.isApp"
:is-modpack="true"
:project-icon-url="ctx.updaterModalProps.value.projectIconUrl"
:project-name="ctx.updaterModalProps.value.projectName"
:loading="form.loadingVersions.value"
:loading-changelog="form.loadingChangelog.value"
@update="handleModpackUpdateRequest"
@cancel="form.resetUpdateState()"
@version-select="form.handleUpdaterVersionSelect"
@version-hover="form.handleUpdaterVersionHover"
/>
<ConfirmModpackUpdateModal
ref="modpackUpdateModal"
:downgrade="isUpdateDowngrade"
:server="ctx.isServer"
@confirm="handleModpackUpdateConfirm"
@cancel="handleModpackUpdateCancel"
/>
<ConfirmRepairModal ref="repairModal" :server="ctx.isServer" @repair="handleRepair" />
<ConfirmReinstallModal
ref="reinstallModal"
:server="ctx.isServer"
@reinstall="handleReinstall"
/>
<ConfirmUnlinkModal ref="unlinkModal" :server="ctx.isServer" @unlink="handleUnlink" />
<ContentDiffModal
v-if="form.pendingPreview.value"
ref="contentDiffModal"
:header="formatMessage(messages.confirmVersionChangeHeader)"
:description="
formatMessage(messages.confirmVersionChangeDescription, {
gameVersion: form.pendingPreview.value.newGameVersion,
})
"
:admonition-header="formatMessage(messages.confirmVersionChangeHeader)"
:diffs="form.pendingPreview.value.diffs"
:has-unknown-content="form.pendingPreview.value.hasUnknownContent"
:confirm-label="formatMessage(messages.confirmVersionChange)"
:confirm-icon="SaveIcon"
:removed-label="formatMessage(messages.removedIncompatible)"
:show-backup-creator="ctx.isServer"
@confirm="form.confirmSave()"
@cancel="form.cancelPreview()"
/>
<ConfirmLeaveModal ref="confirmLeaveModal" />
<slot name="extra-modals" />
</Teleport>
</template>
@@ -0,0 +1 @@
export * from './installation-settings'
@@ -0,0 +1,84 @@
import type { Labrinth } from '@modrinth/api-client'
import type { ComputedRef, Ref } from 'vue'
import { createContext } from '#ui/providers/create-context'
import type {
ContentDiffPreview,
GameVersionOption,
InstallationInfoRow,
InstallationModpackData,
LoaderVersionEntry,
} from '../types'
export interface InstallationSettingsContext {
loading: Ref<boolean> | ComputedRef<boolean>
installationInfo: ComputedRef<InstallationInfoRow[]>
isLinked: ComputedRef<boolean>
isBusy: Ref<boolean> | ComputedRef<boolean>
modpack: Ref<InstallationModpackData | null> | ComputedRef<InstallationModpackData | null>
currentPlatform: ComputedRef<string>
currentGameVersion: ComputedRef<string>
currentLoaderVersion: ComputedRef<string>
availablePlatforms: string[]
resolveGameVersions: (loader: string, showSnapshots: boolean) => GameVersionOption[]
resolveLoaderVersions: (loader: string, gameVersion: string) => LoaderVersionEntry[]
resolveHasSnapshots: (loader: string) => boolean
save: (platform: string, gameVersion: string, loaderVersionId: string | null) => Promise<void>
repair: () => Promise<void>
reinstallModpack: () => Promise<void>
unlinkModpack: () => Promise<void>
getCachedModpackVersions: () => Labrinth.Versions.v2.Version[] | null
fetchModpackVersions: () => Promise<Labrinth.Versions.v2.Version[]>
getVersionChangelog: (versionId: string) => Promise<Labrinth.Versions.v2.Version | null>
onModpackVersionConfirm: (version: Labrinth.Versions.v2.Version) => Promise<void>
updaterModalProps: ComputedRef<{
isApp: boolean
currentVersionId: string
projectIconUrl?: string
projectName: string
currentGameVersion: string
currentLoader: string
}>
isServer: boolean
isApp: boolean
/** When false, hides change-version and reinstall buttons in linked state (default: true) */
showModpackVersionActions?: boolean
repairing?: Ref<boolean>
reinstalling?: Ref<boolean>
afterSave?: () => Promise<void>
lockPlatform?: boolean
hideLoaderVersion?: boolean
previewSave?: (
platform: string,
gameVersion: string,
loaderVersionId: string | null,
signal?: AbortSignal,
) => Promise<ContentDiffPreview | null>
/**
* Optional refs for the editing form state. When provided, the composable
* uses these instead of creating its own. This lets the wrapper observe
* editing state for reactive query dependencies (e.g. paper/purpur builds).
*/
editingPlatformRef?: Ref<string>
editingGameVersionRef?: Ref<string>
}
export const [injectInstallationSettings, provideInstallationSettings] =
createContext<InstallationSettingsContext>(
'InstallationSettingsLayout',
'installationSettingsContext',
)
@@ -0,0 +1,46 @@
import type { RouteLocationRaw } from 'vue-router'
export interface InstallationInfoRow {
label: string
value: string | null
}
export interface InstallationModpackOwner {
id: string
name: string
iconUrl?: string
type: 'user' | 'organization'
}
export interface InstallationModpackData {
iconUrl?: string
title: string
link: string | RouteLocationRaw
versionNumber?: string
owner?: InstallationModpackOwner
}
export interface GameVersionOption {
value: string
label: string
}
export interface LoaderVersionEntry {
id: string
stable?: boolean
}
export interface ContentDiffItem {
type: 'added' | 'removed' | 'updated'
projectName?: string
fileName?: string
currentVersionName?: string
newVersionName?: string
}
export interface ContentDiffPreview {
diffs: ContentDiffItem[]
newGameVersion: string
newLoaderVersion: string
hasUnknownContent: boolean
}