mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 18:14:49 +00:00
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:
co-authored by
tdgao
Artyom Ezri
parent
f0224dfff7
commit
7d92e4ec7f
@@ -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'
|
||||
+278
@@ -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">
|
||||
·
|
||||
</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'
|
||||
+84
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user