feat: reimpl (#5386)
This commit is contained in:
@@ -3,8 +3,6 @@
|
|||||||
|
|
||||||
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
import type { FunctionalComponent, SVGAttributes } from 'vue'
|
||||||
|
|
||||||
export type IconComponent = FunctionalComponent<SVGAttributes>
|
|
||||||
|
|
||||||
import _AffiliateIcon from './icons/affiliate.svg?component'
|
import _AffiliateIcon from './icons/affiliate.svg?component'
|
||||||
import _AlignLeftIcon from './icons/align-left.svg?component'
|
import _AlignLeftIcon from './icons/align-left.svg?component'
|
||||||
import _ArchiveIcon from './icons/archive.svg?component'
|
import _ArchiveIcon from './icons/archive.svg?component'
|
||||||
@@ -327,6 +325,8 @@ import _XCircleIcon from './icons/x-circle.svg?component'
|
|||||||
import _ZoomInIcon from './icons/zoom-in.svg?component'
|
import _ZoomInIcon from './icons/zoom-in.svg?component'
|
||||||
import _ZoomOutIcon from './icons/zoom-out.svg?component'
|
import _ZoomOutIcon from './icons/zoom-out.svg?component'
|
||||||
|
|
||||||
|
export type IconComponent = FunctionalComponent<SVGAttributes>
|
||||||
|
|
||||||
export const AffiliateIcon = _AffiliateIcon
|
export const AffiliateIcon = _AffiliateIcon
|
||||||
export const AlignLeftIcon = _AlignLeftIcon
|
export const AlignLeftIcon = _AlignLeftIcon
|
||||||
export const ArchiveIcon = _ArchiveIcon
|
export const ArchiveIcon = _ArchiveIcon
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal
|
||||||
|
ref="modal"
|
||||||
|
:header="cf ? `Installing a CurseForge modpack` : `Uploading .zip contents from URL`"
|
||||||
|
>
|
||||||
|
<form class="flex flex-col gap-5 md:w-[620px]" @submit.prevent="handleSubmit">
|
||||||
|
<!-- CurseForge stepper cards -->
|
||||||
|
<div v-if="cf" class="flex flex-col gap-2 w-full">
|
||||||
|
<div class="grid gap-2 sm:grid-cols-3">
|
||||||
|
<div
|
||||||
|
v-for="(step, i) in steps"
|
||||||
|
:key="i"
|
||||||
|
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-surface-4 p-4"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-highlight text-xs font-bold text-brand"
|
||||||
|
>
|
||||||
|
{{ i + 1 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm font-semibold leading-snug text-contrast">
|
||||||
|
{{ step.title }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs leading-relaxed text-secondary">
|
||||||
|
{{ step.description }}
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="step.link"
|
||||||
|
:href="step.link"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="mt-auto inline-flex items-center gap-1 text-xs font-semibold text-[#F16436] transition-all hover:underline"
|
||||||
|
>
|
||||||
|
Browse CurseForge
|
||||||
|
<ExternalIcon class="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL input -->
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div v-if="!cf" class="text-sm text-secondary">
|
||||||
|
Copy and paste the direct download URL of a .zip file.
|
||||||
|
</div>
|
||||||
|
<StyledInput
|
||||||
|
v-model="url"
|
||||||
|
:icon="LinkIcon"
|
||||||
|
type="url"
|
||||||
|
:placeholder="
|
||||||
|
cf
|
||||||
|
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
|
||||||
|
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
|
||||||
|
"
|
||||||
|
:disabled="submitted"
|
||||||
|
:error="touched && !!error"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="touched = true"
|
||||||
|
/>
|
||||||
|
<div v-if="touched && error" class="text-xs text-red">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Backup warning -->
|
||||||
|
<Admonition type="warning">
|
||||||
|
You may want to
|
||||||
|
<AutoLink
|
||||||
|
:to="`/hosting/manage/${serverId}/backups`"
|
||||||
|
class="font-semibold text-orange hover:underline"
|
||||||
|
>create a backup</AutoLink
|
||||||
|
>
|
||||||
|
before proceeding, as this process is irreversible and may permanently alter your world or
|
||||||
|
the files on your server.
|
||||||
|
</Admonition>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex gap-2 justify-start">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button
|
||||||
|
v-tooltip="error"
|
||||||
|
:disabled="submitted || !!error"
|
||||||
|
type="submit"
|
||||||
|
@click="handleSubmit"
|
||||||
|
>
|
||||||
|
<SpinnerIcon v-if="submitted" class="animate-spin" />
|
||||||
|
<DownloadIcon v-else />
|
||||||
|
{{ submitted ? 'Installing...' : 'Install' }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button type="button" @click="hide">
|
||||||
|
<XIcon />
|
||||||
|
{{ submitted ? 'Close' : 'Cancel' }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
ExternalIcon,
|
||||||
|
FileTextIcon,
|
||||||
|
LinkIcon,
|
||||||
|
SearchIcon,
|
||||||
|
SpinnerIcon,
|
||||||
|
XIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
injectModrinthClient,
|
||||||
|
injectModrinthServerContext,
|
||||||
|
injectNotificationManager,
|
||||||
|
} from '../../../../providers'
|
||||||
|
import Admonition from '../../../base/Admonition.vue'
|
||||||
|
import AutoLink from '../../../base/AutoLink.vue'
|
||||||
|
import ButtonStyled from '../../../base/ButtonStyled.vue'
|
||||||
|
import StyledInput from '../../../base/StyledInput.vue'
|
||||||
|
import NewModal from '../../../modal/NewModal.vue'
|
||||||
|
|
||||||
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const { serverId } = injectModrinthServerContext()
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
{
|
||||||
|
icon: SearchIcon,
|
||||||
|
title: 'Find the modpack',
|
||||||
|
description: 'Browse CurseForge and locate the modpack you want.',
|
||||||
|
link: 'https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: FileTextIcon,
|
||||||
|
title: 'Select a version',
|
||||||
|
description: 'Go to the "Files" tab and pick the version to install.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: LinkIcon,
|
||||||
|
title: 'Copy the URL',
|
||||||
|
description: 'Copy the version page URL and paste it below.',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const cf = ref(false)
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const url = ref('')
|
||||||
|
const submitted = ref(false)
|
||||||
|
const touched = ref(false)
|
||||||
|
|
||||||
|
const trimmedUrl = computed(() => url.value.trim())
|
||||||
|
|
||||||
|
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/
|
||||||
|
|
||||||
|
const error = computed(() => {
|
||||||
|
if (trimmedUrl.value.length === 0) {
|
||||||
|
return 'URL is required.'
|
||||||
|
}
|
||||||
|
if (cf.value && !regex.test(trimmedUrl.value)) {
|
||||||
|
return 'URL must be a CurseForge modpack version URL.'
|
||||||
|
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
|
||||||
|
return 'URL must be valid.'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
touched.value = true
|
||||||
|
if (error.value) return
|
||||||
|
|
||||||
|
submitted.value = true
|
||||||
|
try {
|
||||||
|
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
|
||||||
|
|
||||||
|
if (!cf.value || dry.modpack_name) {
|
||||||
|
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
|
||||||
|
hide()
|
||||||
|
} else {
|
||||||
|
submitted.value = false
|
||||||
|
addNotification({
|
||||||
|
title: 'CurseForge modpack not found',
|
||||||
|
text: `Could not find CurseForge modpack at that URL.`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
submitted.value = false
|
||||||
|
console.error('Error installing:', err)
|
||||||
|
addNotification({
|
||||||
|
title: 'Installation failed',
|
||||||
|
text: err instanceof Error ? err.message : 'An unknown error occurred',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = (isCf: boolean) => {
|
||||||
|
cf.value = isCf
|
||||||
|
url.value = ''
|
||||||
|
submitted.value = false
|
||||||
|
touched.value = false
|
||||||
|
modal.value?.show()
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
modal.value?.$el?.querySelector('input')?.focus()
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
@@ -3,3 +3,4 @@ export { default as FileDeleteItemModal } from './FileDeleteItemModal.vue'
|
|||||||
export { default as FileMoveItemModal } from './FileMoveItemModal.vue'
|
export { default as FileMoveItemModal } from './FileMoveItemModal.vue'
|
||||||
export { default as FileRenameItemModal } from './FileRenameItemModal.vue'
|
export { default as FileRenameItemModal } from './FileRenameItemModal.vue'
|
||||||
export { default as FileUploadConflictModal } from './FileUploadConflictModal.vue'
|
export { default as FileUploadConflictModal } from './FileUploadConflictModal.vue'
|
||||||
|
export { default as FileUploadZipUrlModal } from './FileUploadZipUrlModal.vue'
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
|
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
|
||||||
<FileUploadConflictModal ref="uploadConflictModal" @proceed="extractItem" />
|
<FileUploadConflictModal ref="uploadConflictModal" @proceed="extractItem" />
|
||||||
|
<FileUploadZipUrlModal ref="uploadZipUrlModal" />
|
||||||
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
|
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
|
||||||
<FileMoveItemModal
|
<FileMoveItemModal
|
||||||
ref="moveItemModal"
|
ref="moveItemModal"
|
||||||
@@ -288,6 +289,7 @@ import {
|
|||||||
FileMoveItemModal,
|
FileMoveItemModal,
|
||||||
FileRenameItemModal,
|
FileRenameItemModal,
|
||||||
FileUploadConflictModal,
|
FileUploadConflictModal,
|
||||||
|
FileUploadZipUrlModal,
|
||||||
} from '../../../components/servers/files/modals'
|
} from '../../../components/servers/files/modals'
|
||||||
import {
|
import {
|
||||||
injectModrinthClient,
|
injectModrinthClient,
|
||||||
@@ -392,6 +394,7 @@ const renameItemModal = ref<InstanceType<typeof FileRenameItemModal>>()
|
|||||||
const moveItemModal = ref<InstanceType<typeof FileMoveItemModal>>()
|
const moveItemModal = ref<InstanceType<typeof FileMoveItemModal>>()
|
||||||
const deleteItemModal = ref<InstanceType<typeof FileDeleteItemModal>>()
|
const deleteItemModal = ref<InstanceType<typeof FileDeleteItemModal>>()
|
||||||
const uploadConflictModal = ref<InstanceType<typeof FileUploadConflictModal>>()
|
const uploadConflictModal = ref<InstanceType<typeof FileUploadConflictModal>>()
|
||||||
|
const uploadZipUrlModal = ref<InstanceType<typeof FileUploadZipUrlModal>>()
|
||||||
|
|
||||||
const newItemType = ref<'file' | 'directory'>('file')
|
const newItemType = ref<'file' | 'directory'>('file')
|
||||||
const selectedItem = ref<Kyros.Files.v0.DirectoryItem | null>(null)
|
const selectedItem = ref<Kyros.Files.v0.DirectoryItem | null>(null)
|
||||||
@@ -919,13 +922,8 @@ function showCreateModal(type: 'file' | 'directory') {
|
|||||||
createItemModal.value?.show()
|
createItemModal.value?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
function showUnzipFromUrlModal(_cf: boolean) {
|
function showUnzipFromUrlModal(cf: boolean) {
|
||||||
// TODO: Implement unzip from URL modal
|
uploadZipUrlModal.value?.show(cf)
|
||||||
addNotification({
|
|
||||||
title: 'Not implemented',
|
|
||||||
text: 'Unzip from URL is not yet implemented',
|
|
||||||
type: 'info',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function showRenameModal(item: Kyros.Files.v0.DirectoryItem) {
|
function showRenameModal(item: Kyros.Files.v0.DirectoryItem) {
|
||||||
@@ -1125,20 +1123,41 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
|
type QueuedOpWithState = Archon.Websocket.v0.QueuedFilesystemOp & { state: 'queued' }
|
||||||
|
|
||||||
|
const dismissedOpIds = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
const ops = computed<(QueuedOpWithState | Archon.Websocket.v0.FilesystemOperation)[]>(() => [
|
const ops = computed<(QueuedOpWithState | Archon.Websocket.v0.FilesystemOperation)[]>(() => [
|
||||||
...localQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
...localQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
||||||
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
...fsQueuedOps.value.map((x) => ({ ...x, state: 'queued' }) satisfies QueuedOpWithState),
|
||||||
...fsOps.value,
|
...fsOps.value.filter((op) => !op.id || !dismissedOpIds.value.has(op.id)),
|
||||||
])
|
])
|
||||||
|
|
||||||
async function dismissOrCancelOp(opId: string, action: 'dismiss' | 'cancel') {
|
async function dismissOrCancelOp(opId: string, action: 'dismiss' | 'cancel') {
|
||||||
|
if (action === 'dismiss') {
|
||||||
|
dismissedOpIds.value = new Set([...dismissedOpIds.value, opId])
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.kyros.files_v0.modifyOperation(opId, action)
|
await client.kyros.files_v0.modifyOperation(opId, action)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (action === 'dismiss') return
|
||||||
console.error(`Failed to ${action} operation:`, error)
|
console.error(`Failed to ${action} operation:`, error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => fsOps.value,
|
||||||
|
(newOps) => {
|
||||||
|
for (const op of newOps) {
|
||||||
|
if (op.state === 'done' && op.id && !dismissedOpIds.value.has(op.id)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
dismissOrCancelOp(op.id, 'dismiss')
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => fsOps.value,
|
() => fsOps.value,
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user