Files
Rocketmc/apps/app-frontend/src/components/ui/CurseForgeProfileImportModal.vue

381 lines
10 KiB
Vue

<template>
<ModalWrapper ref="modal" :header="'Import from CurseForge Profile Code'">
<div class="modal-body">
<div class="input-row">
<p class="input-label">Profile Code</p>
<div class="iconified-input">
<SearchIcon aria-hidden="true" class="text-lg" />
<input ref="codeInput" v-model="profileCode" autocomplete="off" class="h-12 card-shadow"
spellcheck="false" type="text" placeholder="Enter CurseForge profile code" maxlength="20"
@keyup.enter="importProfile" />
<Button v-if="profileCode" class="r-btn" @click="() => (profileCode = '')">
<XIcon />
</Button>
</div>
</div>
<div v-if="metadata && !importing" class="profile-info">
<h3>Profile Information</h3>
<p><strong>Name:</strong> {{ metadata.name }}</p>
</div>
<div v-if="error" class="error-message">
<p>{{ error }}</p>
</div>
<div v-if="importing && importProgress.visible" class="progress-section">
<div class="progress-info">
<span class="progress-text">{{ importProgress.message }}</span>
<span class="progress-percentage">{{ Math.floor(importProgress.percentage) }}%</span>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${importProgress.percentage}%` }"></div>
</div>
</div>
<div class="button-row">
<Button @click="hide" :disabled="importing">
<XIcon />
Cancel
</Button>
<Button v-if="!metadata" @click="fetchMetadata" :disabled="!profileCode.trim() || fetching"
color="secondary">
<SearchIcon v-if="!fetching" />
{{ fetching ? 'Checking...' : 'Check Profile' }}
</Button>
<Button v-if="metadata" @click="importProfile" :disabled="importing" color="primary">
<DownloadIcon v-if="!importing" />
{{ importing ? 'Importing...' : 'Import Profile' }}
</Button>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { Button } from '@modrinth/ui'
import {
XIcon,
SearchIcon,
DownloadIcon
} from '@modrinth/assets'
import {
fetch_curseforge_profile_metadata,
import_curseforge_profile
} from '@/helpers/import.js'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener } from '@/helpers/events.js'
const props = defineProps({
closeParent: {
type: Function,
default: null
}
})
const router = useRouter()
const modal = ref(null)
const codeInput = ref(null)
const profileCode = ref('')
const metadata = ref(null)
const fetching = ref(false)
const importing = ref(false)
const error = ref('')
const importProgress = ref({
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
})
let unlistenLoading = null
let activeLoadingBarId = null
let progressFallbackTimer = null
defineExpose({
show: () => {
profileCode.value = ''
metadata.value = null
fetching.value = false
importing.value = false
error.value = ''
importProgress.value = {
visible: false,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
modal.value?.show()
nextTick(() => {
setTimeout(() => {
codeInput.value?.focus()
}, 100)
})
trackEvent('CurseForgeProfileImportStart', { source: 'ImportModal' })
},
})
const hide = () => {
modal.value?.hide()
}
const fetchMetadata = async () => {
if (!profileCode.value.trim()) return
fetching.value = true
error.value = ''
try {
const result = await fetch_curseforge_profile_metadata(profileCode.value.trim())
metadata.value = result
trackEvent('CurseForgeProfileMetadataFetched', {
profileCode: profileCode.value.trim()
})
} catch (err) {
console.error('Failed to fetch CurseForge profile metadata:', err)
error.value = 'Failed to fetch profile information. Please check the code and try again.'
handleError(err)
} finally {
fetching.value = false
}
}
const importProfile = async () => {
if (!profileCode.value.trim()) return
importing.value = true
error.value = ''
activeLoadingBarId = null // Reset for new import session
importProgress.value = {
visible: true,
percentage: 0,
message: 'Starting import...',
totalMods: 0,
downloadedMods: 0
}
// Fallback progress timer in case loading events don't work
progressFallbackTimer = setInterval(() => {
if (importing.value && importProgress.value.percentage < 90) {
// Slowly increment progress as a fallback
importProgress.value.percentage = Math.min(90, importProgress.value.percentage + 1)
}
}, 1000)
try {
const { result, profilePath } = await import_curseforge_profile(profileCode.value.trim())
trackEvent('CurseForgeProfileImported', {
profileCode: profileCode.value.trim()
})
hide()
// Close the parent modal if provided
if (props.closeParent) {
props.closeParent()
}
// Navigate to the imported profile
await router.push(`/instance/${encodeURIComponent(profilePath)}`)
} catch (err) {
console.error('Failed to import CurseForge profile:', err)
error.value = 'Failed to import profile. Please try again.'
handleError(err)
} finally {
importing.value = false
importProgress.value.visible = false
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
progressFallbackTimer = null
}
activeLoadingBarId = null
}
}
onMounted(async () => {
// Listen for loading events to update progress
unlistenLoading = await loading_listener((event) => {
console.log('Loading event received:', event) // Debug log
// Handle all loading events that could be related to CurseForge profile import
const isCurseForgeEvent = event.event?.type === 'curseforge_profile_download'
const hasProfileName = event.event?.profile_name && importing.value
if ((isCurseForgeEvent || hasProfileName) && importing.value) {
// Store the loading bar ID for this import session
if (!activeLoadingBarId) {
activeLoadingBarId = event.loader_uuid
}
// Only process events for our current import session
if (event.loader_uuid === activeLoadingBarId) {
if (event.fraction !== null && event.fraction !== undefined) {
const baseProgress = (event.fraction || 0) * 100
// Calculate custom progress based on the message
let finalProgress = baseProgress
const message = event.message || 'Importing profile...'
// Custom progress calculation for different stages
if (message.includes('Fetching') || message.includes('metadata')) {
finalProgress = Math.min(10, baseProgress)
} else if (message.includes('Downloading profile ZIP') || message.includes('profile ZIP')) {
finalProgress = Math.min(15, 10 + (baseProgress - 10) * 0.5)
} else if (message.includes('Extracting') || message.includes('ZIP')) {
finalProgress = Math.min(20, 15 + (baseProgress - 15) * 0.5)
} else if (message.includes('Configuring') || message.includes('profile')) {
finalProgress = Math.min(30, 20 + (baseProgress - 20) * 0.5)
} else if (message.includes('Copying') || message.includes('files')) {
finalProgress = Math.min(40, 30 + (baseProgress - 30) * 0.5)
} else if (message.includes('Downloaded mod') && message.includes(' of ')) {
// Parse "Downloaded mod X of Y" message
const match = message.match(/Downloaded mod (\d+) of (\d+)/)
if (match) {
const current = parseInt(match[1])
const total = parseInt(match[2])
// Mods take 40% of progress (from 40% to 80%)
const modProgress = (current / total) * 40
finalProgress = 40 + modProgress
} else {
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.5)
}
} else if (message.includes('Downloading mod') || message.includes('mods')) {
// General mod downloading stage (40% to 80%)
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.4)
} else if (message.includes('Installing Minecraft') || message.includes('Minecraft')) {
finalProgress = Math.min(95, 80 + (baseProgress - 80) * 0.75)
} else if (message.includes('Finalizing') || message.includes('completed')) {
finalProgress = Math.min(100, 95 + (baseProgress - 95))
} else {
// Default: use the base progress but ensure minimum progression
finalProgress = Math.max(importProgress.value.percentage, baseProgress)
}
importProgress.value.percentage = Math.min(100, Math.max(0, finalProgress))
importProgress.value.message = message
} else {
// Loading complete
importProgress.value.percentage = 100
importProgress.value.message = 'Import completed!'
activeLoadingBarId = null
}
}
}
})
})
onUnmounted(() => {
if (unlistenLoading) {
unlistenLoading()
}
if (progressFallbackTimer) {
clearInterval(progressFallbackTimer)
}
})
</script>
<style lang="scss" scoped>
.modal-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
.input-row {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-label {
font-weight: 600;
color: var(--color-contrast);
margin: 0;
}
.profile-info {
background: var(--color-bg);
border: 1px solid var(--color-button);
border-radius: var(--radius-md);
padding: 1rem;
h3 {
margin: 0 0 0.5rem 0;
color: var(--color-contrast);
}
p {
margin: 0.25rem 0;
color: var(--color-base);
}
}
.error-message {
background: var(--color-red);
border: 1px solid var(--color-red);
border-radius: var(--radius-md);
padding: 0.75rem;
p {
margin: 0;
color: var(--color-contrast);
font-weight: 500;
}
}
.progress-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.progress-info {
display: flex;
justify-content: space-between;
align-items: center;
.progress-text {
color: var(--color-base);
font-size: 0.875rem;
}
.progress-percentage {
color: var(--color-contrast);
font-size: 0.875rem;
font-weight: 600;
}
}
.progress-bar-container {
width: 100%;
height: 4px;
background: var(--color-button);
border-radius: 2px;
overflow: hidden;
}
.progress-bar {
height: 100%;
background: var(--color-brand);
border-radius: 2px;
transition: width 0.3s ease;
min-width: 0;
}
.button-row {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: auto;
}
</style>