import type { Labrinth, UploadProgress } from '@modrinth/api-client' import { ArrowLeftRightIcon, LeftArrowIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets' import { createContext, injectModrinthClient, injectNotificationManager, injectProjectPageContext, type MultiStageModal, type StageConfigInput, } from '@modrinth/ui' import JSZip from 'jszip' import type { Ref, ShallowRef } from 'vue' import { markRaw, toRaw } from 'vue' import type { ComponentExposed } from 'vue-component-type-helpers' import SelectCompatibilityType from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectCompatibilityType.vue' import SelectPublishedModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectPublishedModpack.vue' import SelectVanillaVersions from '~/components/ui/project-settings/ServerCompatibilityModal/stages/SelectVanillaVersions.vue' import UploadCustomModpack from '~/components/ui/project-settings/ServerCompatibilityModal/stages/UploadCustomModpack.vue' export type CompatibilityType = 'vanilla' | 'published-modpack' | 'custom-modpack' export interface ServerCompatibilityContextValue { // Stage management stageConfigs: StageConfigInput[] modal: ShallowRef | null> isSubmitting: Ref isUploading: Ref uploadProgress: Ref // State compatibilityType: Ref selectedProjectId: Ref selectedVersionId: Ref supportedGameVersions: Ref recommendedGameVersion: Ref customModpackFile: Ref hasLicensePermission: Ref isEditingExistingCompatibility: Ref isSwitchingCompatibilityType: Ref // Actions resetContext: () => void handleSave: () => Promise } export const [injectServerCompatibilityContext, provideServerCompatibilityContext] = createContext('ServerCompatibilityModal') export function createServerCompatibilityContext( modal: ShallowRef | null>, ): ServerCompatibilityContextValue { const { projectV3, patchProjectV3 } = injectProjectPageContext() const { labrinth } = injectModrinthClient() const { addNotification } = injectNotificationManager() const isSubmitting = ref(false) const isUploading = ref(false) const uploadProgress = ref({ loaded: 0, total: 0, progress: 0 }) const compatibilityType = ref(null) const selectedProjectId = ref('') const selectedVersionId = ref('') const supportedGameVersions = ref([]) const recommendedGameVersion = ref(null) const customModpackFile = ref(null) const hasLicensePermission = ref(false) const isEditingExistingCompatibility = ref(false) const isSwitchingCompatibilityType = ref(false) async function uploadCustomModpackFile(file: File): Promise { const rawFile = toRaw(file) // Default to filename if we can't parse the mrpack let versionName = rawFile.name.replace(/\.(zip|mrpack)$/i, '') let versionNumber = versionName const loaders: string[] = [] let gameVersions: string[] = [] try { const zip = await JSZip.loadAsync(rawFile) const indexFile = zip.file('modrinth.index.json') if (indexFile) { const indexContent = await indexFile.async('text') const metadata = JSON.parse(indexContent) as { name?: string versionId?: string dependencies?: Record } if (metadata.name) { versionName = metadata.name } if (metadata.versionId) { versionNumber = metadata.versionId } if (metadata.dependencies) { if ('forge' in metadata.dependencies) loaders.push('forge') if ('neoforge' in metadata.dependencies) loaders.push('neoforge') if ('fabric-loader' in metadata.dependencies) loaders.push('fabric') if ('quilt-loader' in metadata.dependencies) loaders.push('quilt') if (metadata.dependencies.minecraft) { gameVersions = [metadata.dependencies.minecraft] } } } } catch { console.warn('Could not parse modrinth.index.json from mrpack') } const draftVersion: Labrinth.Versions.v3.DraftVersion = { project_id: projectV3.value.id, name: versionName, version_number: versionNumber, version_type: 'release', loaders, game_versions: gameVersions, featured: false, status: 'listed', changelog: '', dependencies: [], environment: 'client_and_server', } const files: Labrinth.Versions.v3.DraftVersionFile[] = [{ file: rawFile, fileType: undefined }] const uploadHandle = labrinth.versions_v3.createVersion(draftVersion, files, 'modpack') uploadHandle.onProgress((progress) => { uploadProgress.value = progress }) return await uploadHandle.promise } async function handleSave() { isSubmitting.value = true setTimeout(() => { if (compatibilityType.value === 'custom-modpack' && isSubmitting.value === true) { isUploading.value = true uploadProgress.value = { loaded: 0, total: 0, progress: 0 } } }, 1500) try { switch (compatibilityType.value) { case 'vanilla': await patchProjectV3({ minecraft_java_server: { content: { kind: 'vanilla', supported_game_versions: supportedGameVersions.value, recommended_game_version: recommendedGameVersion.value, }, }, }) break case 'published-modpack': await patchProjectV3({ minecraft_java_server: { content: { kind: 'modpack', version_id: selectedVersionId.value, }, }, }) break case 'custom-modpack': { if (!customModpackFile.value) break // Upload the modpack file as a new version const uploadedVersion: Labrinth.Versions.v3.Version = await uploadCustomModpackFile( customModpackFile.value, ) // Patch the project to point to the newly uploaded version try { await patchProjectV3({ minecraft_java_server: { content: { kind: 'modpack', version_id: uploadedVersion.id, }, }, }) } catch (err) { // If patch fails, clean up the uploaded version try { await labrinth.versions_v3.deleteVersion(uploadedVersion.id) } catch { console.error('Failed to clean up uploaded version after patch failure') } throw err } break } } isUploading.value = false isSubmitting.value = false await nextTick() modal.value?.hide() } catch (err) { isUploading.value = false isSubmitting.value = false const error = err as { data?: { description?: string } } addNotification({ title: 'Failed to save server compatibility', text: error.data?.description || String(err), type: 'error', }) } } function resetContext() { compatibilityType.value = null selectedProjectId.value = '' selectedVersionId.value = '' supportedGameVersions.value = [] recommendedGameVersion.value = null customModpackFile.value = null hasLicensePermission.value = false isEditingExistingCompatibility.value = false isSwitchingCompatibilityType.value = false } return { stageConfigs, modal, isSubmitting, isUploading, uploadProgress, compatibilityType, selectedProjectId, selectedVersionId, supportedGameVersions, recommendedGameVersion, customModpackFile, hasLicensePermission, isEditingExistingCompatibility, isSwitchingCompatibilityType, resetContext, handleSave, } } const selectCompatibilityTypeStage: StageConfigInput = { id: 'select-compatibility-type', stageContent: markRaw(SelectCompatibilityType), title: 'Compatibility type', cannotNavigateForward: (ctx) => !ctx.compatibilityType.value, leftButtonConfig: null, rightButtonConfig: null, } const selectVanillaVersionsStage: StageConfigInput = { id: 'select-vanilla-versions', stageContent: markRaw(SelectVanillaVersions), title: 'Vanilla versions', skip: (ctx) => ctx.compatibilityType.value !== 'vanilla' && !!ctx.compatibilityType.value, leftButtonConfig: (ctx) => ctx.isEditingExistingCompatibility.value ? { label: 'Cancel', icon: XIcon, onClick: () => ctx.modal.value?.hide(), } : { label: 'Back', icon: LeftArrowIcon, onClick: () => ctx.modal.value?.prevStage(), }, rightButtonConfig: (ctx) => ctx.isSwitchingCompatibilityType.value ? { label: 'Change type', icon: ArrowLeftRightIcon, iconPosition: 'before' as const, color: 'red' as const, disabled: ctx.isSubmitting.value || ctx.supportedGameVersions.value.length === 0 || !ctx.recommendedGameVersion.value, onClick: () => ctx.handleSave(), } : { label: ctx.isSubmitting.value ? ctx.isEditingExistingCompatibility.value ? 'Updating…' : 'Saving…' : ctx.isEditingExistingCompatibility.value ? 'Save changes' : 'Save', icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon, iconPosition: 'before' as const, iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined, color: 'green' as const, disabled: ctx.isSubmitting.value || ctx.supportedGameVersions.value.length === 0 || !ctx.recommendedGameVersion.value, onClick: () => ctx.handleSave(), }, nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value, } const selectPublishedModpackStage: StageConfigInput = { id: 'select-published-modpack', stageContent: markRaw(SelectPublishedModpack), title: 'Select modpack', skip: (ctx) => ctx.compatibilityType.value !== 'published-modpack', cannotNavigateForward: (ctx) => !ctx.selectedProjectId.value || !ctx.selectedVersionId.value, leftButtonConfig: (ctx) => ctx.isEditingExistingCompatibility.value ? { label: 'Cancel', icon: XIcon, onClick: () => ctx.modal.value?.hide(), } : { label: 'Back', icon: LeftArrowIcon, onClick: () => ctx.modal.value?.prevStage(), }, rightButtonConfig: (ctx) => ctx.isSwitchingCompatibilityType.value ? { label: 'Change type', icon: ArrowLeftRightIcon, iconPosition: 'before' as const, color: 'red' as const, disabled: ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value, onClick: () => ctx.handleSave(), } : { label: ctx.isSubmitting.value ? ctx.isEditingExistingCompatibility.value ? 'Updating…' : 'Saving…' : ctx.isEditingExistingCompatibility.value ? 'Save changes' : 'Save', icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon, iconPosition: 'before' as const, iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined, color: 'green' as const, disabled: ctx.isSubmitting.value || !ctx.selectedProjectId.value || !ctx.selectedVersionId.value, onClick: () => ctx.handleSave(), }, nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value, } function getUploadLabel(ctx: ServerCompatibilityContextValue): string { if (ctx.isUploading.value) { if (ctx.uploadProgress.value.progress >= 1) { return 'Saving…' } return `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%` } if (ctx.isSubmitting.value) { return ctx.isEditingExistingCompatibility.value ? 'Updating…' : 'Saving…' } return ctx.isEditingExistingCompatibility.value ? 'Save changes' : 'Save' } const uploadCustomModpackStage: StageConfigInput = { id: 'upload-custom-modpack', stageContent: markRaw(UploadCustomModpack), title: 'Custom modpack', skip: (ctx) => ctx.compatibilityType.value !== 'custom-modpack', disableClose: (ctx) => ctx.isUploading.value, leftButtonConfig: (ctx) => ctx.isEditingExistingCompatibility.value ? { label: 'Cancel', icon: XIcon, disabled: ctx.isUploading.value, onClick: () => ctx.modal.value?.hide(), } : { label: 'Back', icon: LeftArrowIcon, disabled: ctx.isUploading.value, onClick: () => ctx.modal.value?.prevStage(), }, rightButtonConfig: (ctx) => ctx.isSwitchingCompatibilityType.value ? { label: ctx.isUploading.value ? `Uploading ${Math.round(ctx.uploadProgress.value.progress * 100)}%` : 'Change type', icon: ctx.isSubmitting.value ? SpinnerIcon : ArrowLeftRightIcon, iconPosition: 'before' as const, iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined, color: 'red' as const, disabled: ctx.isSubmitting.value || !ctx.customModpackFile.value || !ctx.hasLicensePermission.value, onClick: () => ctx.handleSave(), buttonClass: 'tabular-nums', } : { label: getUploadLabel(ctx), icon: ctx.isSubmitting.value ? SpinnerIcon : SaveIcon, iconPosition: 'before' as const, iconClass: ctx.isSubmitting.value ? 'animate-spin' : undefined, color: 'green' as const, disabled: ctx.isSubmitting.value || !ctx.customModpackFile.value || !ctx.hasLicensePermission.value, onClick: () => ctx.handleSave(), buttonClass: 'tabular-nums', }, nonProgressStage: (ctx) => ctx.isEditingExistingCompatibility.value, } const stageConfigs: StageConfigInput[] = [ selectCompatibilityTypeStage, selectVanillaVersionsStage, selectPublishedModpackStage, uploadCustomModpackStage, ]