mirror of
https://github.com/modrinth/code.git
synced 2026-08-26 17:44:50 +00:00
refactor: align files tab with content tab design (#5621)
* fix: files.vue bugs before styling changes
* feat: move files tab to shared layout structure
* fix: qa
* fix: qa
* fix: bugs
* fix: lint
* fix: admonition cleanup with progress + actions
* fix: cleanup
* fix: modals
* fix: admon title
* fix: i18n standard
* fix: lint + i18n pass
* fix: remove transition
* fix: type errors
* feat: files tab in app
* fix: qa
* fix: backup item minmax
* fix: use ContentPageHeader for server panel
* fix: lint
* fix: lint
* fix: lint
* feat: page leave safety
* fix: lint
* fix: cargo fmt fix
* fix: blank in prod
* fix: content card table stuff
* Revert "fix: blank in prod"
This reverts commit 74758fe185.
* fix: import
* feat: browse worlds/servers flow
* fix: worlds tab parity with content tab
* fix: perf bug + shader filter pill copy
* feat: singleplayer filter
* fix: ordering
* fix: breadcrumbs
* fix: lint
* fix: qa
* feat: store server proj id when adding to a non-linked instance
* fix: lint
* fix: i18n + qa
* fix: conflict
* qa: already installed modal + placeholders not server-specific
* fix: qa
* fix: add + edit server modals
* fix: qa
* fix: security
* fix: devin flags
* fix: lint
* chore: change file to break build cache
* fix: admon
* fix: import path stuff
* feat: qa
* fix: fmt fmt idiot
---------
Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="transform scale-75 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-125 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-75 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit min-w-[180px] flex-col gap-2 overflow-hidden rounded-2xl border border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleCopyFilename"
|
||||
>
|
||||
<ClipboardCopyIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<ClipboardCopyIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="ctx.openInFolder" type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleOpenInFolder"
|
||||
>
|
||||
<FolderOpenIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.openInFolderButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="h-px w-full bg-surface-5" />
|
||||
<template v-for="(option, index) in menuOptions" :key="index">
|
||||
<div
|
||||
v-if="'divider' in option && option.divider && option.shown !== false"
|
||||
class="h-px w-full bg-surface-5"
|
||||
/>
|
||||
<ButtonStyled
|
||||
v-else-if="'id' in option && option.shown !== false"
|
||||
type="transparent"
|
||||
:color="option.color"
|
||||
>
|
||||
<button
|
||||
v-tooltip="option.tooltip"
|
||||
:disabled="option.disabled"
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleOptionClick(option)"
|
||||
>
|
||||
<slot :name="option.id" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
import type { FileContextMenuOption, FileItem } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const visible = ref(false)
|
||||
const menuRef = ref<HTMLElement>()
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const currentItem = ref<FileItem | null>(null)
|
||||
const menuOptions = ref<FileContextMenuOption[]>([])
|
||||
|
||||
function show(item: FileItem, x: number, y: number, options: typeof menuOptions.value) {
|
||||
currentItem.value = item
|
||||
menuOptions.value = options
|
||||
position.value = { x, y }
|
||||
visible.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (!menuRef.value) return
|
||||
const rect = menuRef.value.getBoundingClientRect()
|
||||
const padding = 10
|
||||
if (rect.right > window.innerWidth - padding) {
|
||||
position.value.x = Math.max(padding, x - rect.width)
|
||||
}
|
||||
if (rect.bottom > window.innerHeight - padding) {
|
||||
position.value.y = Math.max(padding, y - rect.height)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
currentItem.value = null
|
||||
}
|
||||
|
||||
function handleCopyFilename() {
|
||||
if (!currentItem.value) return
|
||||
navigator.clipboard.writeText(currentItem.value.name)
|
||||
addNotification({ title: formatMessage(commonMessages.copiedFilenameLabel), type: 'success' })
|
||||
hide()
|
||||
}
|
||||
|
||||
function getFullPath() {
|
||||
if (!currentItem.value) return ''
|
||||
const basePath = ctx.basePath?.value
|
||||
const itemPath = currentItem.value.path
|
||||
return basePath ? `${basePath}/${itemPath}`.replace(/\/+/g, '/') : itemPath
|
||||
}
|
||||
|
||||
function handleCopyPath() {
|
||||
if (!currentItem.value) return
|
||||
navigator.clipboard.writeText(getFullPath())
|
||||
addNotification({ title: formatMessage(commonMessages.copiedPathLabel), type: 'success' })
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleOpenInFolder() {
|
||||
if (!currentItem.value) return
|
||||
ctx.openInFolder?.(getFullPath())
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleOptionClick(option: { action?: () => void }) {
|
||||
option.action?.()
|
||||
hide()
|
||||
}
|
||||
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function onEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
document.addEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onClickOutside)
|
||||
document.removeEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (!v) currentItem.value = null
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,55 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
|
||||
<FileIcon class="size-28" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ message }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('refetch')">
|
||||
<RefreshCwIcon class="h-5 w-5" />
|
||||
{{ formatMessage(messages.tryAgain) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('home')">
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
{{ formatMessage(messages.goToHome) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, HomeIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
tryAgain: {
|
||||
id: 'files.error.try-again',
|
||||
defaultMessage: 'Try again',
|
||||
},
|
||||
goToHome: {
|
||||
id: 'files.error.go-to-home',
|
||||
defaultMessage: 'Go to home folder',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
message: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refetch: []
|
||||
home: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<header
|
||||
class="@container flex select-none flex-col gap-4"
|
||||
:aria-label="formatMessage(messages.fileNavigation)"
|
||||
>
|
||||
<div v-if="!isEditing" class="flex items-center gap-2 @[800px]:hidden">
|
||||
<StyledInput
|
||||
:model-value="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.searchFiles)"
|
||||
class="!h-10"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<nav
|
||||
:aria-label="formatMessage(messages.breadcrumbNavigation)"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||
>
|
||||
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||
<li class="mr-4 flex-shrink-0">
|
||||
<ButtonStyled circular>
|
||||
<button
|
||||
v-tooltip="formatMessage(messages.backToHome)"
|
||||
type="button"
|
||||
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
@click="$emit('navigateHome')"
|
||||
@mouseenter="$emit('prefetchHome')"
|
||||
>
|
||||
<HomeIcon />
|
||||
<span class="sr-only">{{ formatMessage(messages.home) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
|
||||
<ol
|
||||
ref="breadcrumbOuter"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0"
|
||||
:class="{ 'breadcrumb-fade-mask': isBreadcrumbOverflowing }"
|
||||
:style="
|
||||
isBreadcrumbOverflowing
|
||||
? { '--scroll-distance': `-${breadcrumbOverflowAmount}px` }
|
||||
: undefined
|
||||
"
|
||||
@mouseenter="onBreadcrumbMouseEnter"
|
||||
@mouseleave="onBreadcrumbMouseLeave"
|
||||
>
|
||||
<TransitionGroup
|
||||
ref="breadcrumbInner"
|
||||
name="breadcrumb"
|
||||
tag="span"
|
||||
class="relative flex w-fit items-center"
|
||||
:class="{ 'breadcrumbs-scroll': isBreadcrumbAnimating }"
|
||||
@animationiteration="onBreadcrumbAnimationIteration"
|
||||
>
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbs"
|
||||
:key="`${segment || index}-group`"
|
||||
class="relative flex shrink-0 items-center text-sm"
|
||||
>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer whitespace-nowrap focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||
:aria-current="
|
||||
!isEditing && index === breadcrumbs.length - 1 ? 'location' : undefined
|
||||
"
|
||||
:class="{
|
||||
'!text-contrast': !isEditing && index === breadcrumbs.length - 1,
|
||||
}"
|
||||
@click="$emit('navigate', index)"
|
||||
>
|
||||
{{ segment || '' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ChevronRightIcon
|
||||
v-if="index < breadcrumbs.length - 1 || isEditing"
|
||||
class="size-4 flex-shrink-0 text-secondary"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
<li v-if="isEditing && editingFileName" class="flex items-center px-3 text-base">
|
||||
<span class="font-semibold !text-contrast" aria-current="location">
|
||||
{{ editingFileName }}
|
||||
</span>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<div v-if="!isEditing" class="flex flex-shrink-0 items-center gap-2">
|
||||
<StyledInput
|
||||
id="search-folder"
|
||||
:model-value="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.searchFiles)"
|
||||
class="!h-10 hidden @[800px]:inline-flex"
|
||||
input-class="!h-10"
|
||||
wrapper-class="w-full sm:w-[280px]"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
/>
|
||||
|
||||
<ButtonStyled v-if="showRefreshButton" type="outlined">
|
||||
<button
|
||||
type="button"
|
||||
class="flex !h-10 items-center gap-2 !border-[1px] !border-surface-5"
|
||||
:disabled="refreshing"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 transition-transform"
|
||||
:class="refreshing ? 'animate-spin' : ''"
|
||||
/>
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="outlined">
|
||||
<OverflowMenu
|
||||
:dropdown-id="`create-new-${baseId}`"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
:aria-label="formatMessage(messages.createNew)"
|
||||
:disabled="disabled"
|
||||
:tooltip="disabled ? disabledTooltip : undefined"
|
||||
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
|
||||
:options="[
|
||||
{ id: 'file', action: () => $emit('create', 'file') },
|
||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||
{ id: 'upload', action: () => $emit('upload') },
|
||||
{ divider: true, shown: showInstallFromUrl ?? false },
|
||||
{ id: 'upload-zip', shown: false, action: () => $emit('uploadZip') },
|
||||
{
|
||||
id: 'install-from-url',
|
||||
shown: showInstallFromUrl ?? false,
|
||||
action: () => $emit('unzipFromUrl', false),
|
||||
},
|
||||
{
|
||||
id: 'install-cf-pack',
|
||||
shown: showInstallFromUrl ?? false,
|
||||
action: () => $emit('unzipFromUrl', true),
|
||||
},
|
||||
]"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
|
||||
<template #file>
|
||||
<BoxIcon aria-hidden="true" /> {{ formatMessage(messages.newFile) }}
|
||||
</template>
|
||||
<template #directory>
|
||||
<FolderOpenIcon aria-hidden="true" /> {{ formatMessage(messages.newFolder) }}
|
||||
</template>
|
||||
<template #upload>
|
||||
<UploadIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFile) }}
|
||||
</template>
|
||||
<template #upload-zip>
|
||||
<FileArchiveIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFromZip) }}
|
||||
</template>
|
||||
<template #install-from-url>
|
||||
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFromZipUrl) }}
|
||||
</template>
|
||||
<template #install-cf-pack>
|
||||
<CurseForgeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.installCurseForgePack) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isEditingImage && isLogFile" class="flex gap-2">
|
||||
<Button
|
||||
v-tooltip="formatMessage(messages.shareToMclogs)"
|
||||
icon-only
|
||||
transparent
|
||||
:aria-label="formatMessage(messages.shareToMclogs)"
|
||||
@click="$emit('share')"
|
||||
>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
ChevronRightIcon,
|
||||
CurseForgeIcon,
|
||||
DropdownIcon,
|
||||
FileArchiveIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
ShareIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import Button from '#ui/components/base/Button.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
fileNavigation: {
|
||||
id: 'files.navbar.file-navigation',
|
||||
defaultMessage: 'File navigation',
|
||||
},
|
||||
breadcrumbNavigation: {
|
||||
id: 'files.navbar.breadcrumb-navigation',
|
||||
defaultMessage: 'Breadcrumb navigation',
|
||||
},
|
||||
backToHome: {
|
||||
id: 'files.navbar.back-to-home',
|
||||
defaultMessage: 'Back to home',
|
||||
},
|
||||
home: {
|
||||
id: 'files.navbar.home',
|
||||
defaultMessage: 'Home',
|
||||
},
|
||||
searchFiles: {
|
||||
id: 'files.navbar.search-files',
|
||||
defaultMessage: 'Search files',
|
||||
},
|
||||
createNew: {
|
||||
id: 'files.navbar.create-new',
|
||||
defaultMessage: 'Create new...',
|
||||
},
|
||||
newFile: {
|
||||
id: 'files.navbar.new-file',
|
||||
defaultMessage: 'New file',
|
||||
},
|
||||
newFolder: {
|
||||
id: 'files.navbar.new-folder',
|
||||
defaultMessage: 'New folder',
|
||||
},
|
||||
uploadFile: {
|
||||
id: 'files.navbar.upload-file',
|
||||
defaultMessage: 'Upload file',
|
||||
},
|
||||
uploadFromZip: {
|
||||
id: 'files.navbar.upload-from-zip',
|
||||
defaultMessage: 'Upload from .zip file',
|
||||
},
|
||||
uploadFromZipUrl: {
|
||||
id: 'files.navbar.upload-from-zip-url',
|
||||
defaultMessage: 'Upload from .zip URL',
|
||||
},
|
||||
installCurseForgePack: {
|
||||
id: 'files.navbar.install-curseforge-pack',
|
||||
defaultMessage: 'Install CurseForge pack',
|
||||
},
|
||||
shareToMclogs: {
|
||||
id: 'files.navbar.share-to-mclogs',
|
||||
defaultMessage: 'Share to mclo.gs',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbs: string[]
|
||||
isEditing: boolean
|
||||
editingFileName?: string
|
||||
editingFilePath?: string
|
||||
isEditingImage?: boolean
|
||||
searchQuery: string
|
||||
showRefreshButton?: boolean
|
||||
showInstallFromUrl?: boolean
|
||||
baseId: string
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [index: number]
|
||||
navigateHome: []
|
||||
prefetchHome: []
|
||||
'update:searchQuery': [value: string]
|
||||
create: [type: 'file' | 'directory']
|
||||
upload: []
|
||||
uploadZip: []
|
||||
unzipFromUrl: [cf: boolean]
|
||||
refresh: []
|
||||
share: []
|
||||
}>()
|
||||
|
||||
const refreshing = ref(false)
|
||||
|
||||
function handleRefresh() {
|
||||
emit('refresh')
|
||||
refreshing.value = true
|
||||
setTimeout(() => {
|
||||
refreshing.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const breadcrumbOuter = ref<HTMLElement | null>(null)
|
||||
const breadcrumbInner = ref<{ $el: HTMLElement } | null>(null)
|
||||
const isBreadcrumbOverflowing = ref(false)
|
||||
const isBreadcrumbAnimating = ref(false)
|
||||
const breadcrumbOverflowAmount = ref(0)
|
||||
|
||||
let bcHovered = false
|
||||
let bcStopping = false
|
||||
|
||||
function checkBreadcrumbOverflow() {
|
||||
const inner = breadcrumbInner.value?.$el
|
||||
if (!breadcrumbOuter.value || !inner) return
|
||||
const overflow = inner.scrollWidth - breadcrumbOuter.value.clientWidth
|
||||
isBreadcrumbOverflowing.value = overflow > 0
|
||||
breadcrumbOverflowAmount.value = overflow + 12
|
||||
}
|
||||
|
||||
function onBreadcrumbMouseEnter() {
|
||||
bcHovered = true
|
||||
bcStopping = false
|
||||
if (isBreadcrumbOverflowing.value) {
|
||||
isBreadcrumbAnimating.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onBreadcrumbMouseLeave() {
|
||||
bcHovered = false
|
||||
if (isBreadcrumbAnimating.value) {
|
||||
bcStopping = true
|
||||
}
|
||||
}
|
||||
|
||||
function onBreadcrumbAnimationIteration() {
|
||||
if (bcStopping && !bcHovered) {
|
||||
isBreadcrumbAnimating.value = false
|
||||
bcStopping = false
|
||||
}
|
||||
}
|
||||
|
||||
let bcResizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
checkBreadcrumbOverflow()
|
||||
bcResizeObserver = new ResizeObserver(checkBreadcrumbOverflow)
|
||||
if (breadcrumbOuter.value) bcResizeObserver.observe(breadcrumbOuter.value)
|
||||
const innerEl = breadcrumbInner.value?.$el
|
||||
if (innerEl) bcResizeObserver.observe(innerEl)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
bcResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.breadcrumbs,
|
||||
() => {
|
||||
requestAnimationFrame(checkBreadcrumbOverflow)
|
||||
},
|
||||
)
|
||||
|
||||
const isLogFile = computed(() => {
|
||||
return (
|
||||
props.editingFilePath?.startsWith('logs') ||
|
||||
props.editingFilePath?.startsWith('crash-reports') ||
|
||||
props.editingFilePath?.endsWith('.log')
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.breadcrumb-move,
|
||||
.breadcrumb-enter-active,
|
||||
.breadcrumb-leave-active {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.breadcrumb-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.9);
|
||||
}
|
||||
|
||||
.breadcrumb-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px) scale(0.8);
|
||||
filter: blur(4px);
|
||||
}
|
||||
|
||||
.breadcrumb-leave-active {
|
||||
position: relative;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.breadcrumb-move {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.breadcrumb-fade-mask {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 12px,
|
||||
black calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.breadcrumbs-scroll {
|
||||
animation: breadcrumb-scroll 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breadcrumb-scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
35%,
|
||||
65% {
|
||||
transform: translateX(var(--scroll-distance));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" class="mb-4">
|
||||
<template #icon>
|
||||
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{
|
||||
ctx.uploadingLabel
|
||||
? ctx.uploadingLabel(
|
||||
ctx.uploadState.value.completedFiles,
|
||||
ctx.uploadState.value.totalFiles,
|
||||
)
|
||||
: formatMessage(messages.uploadingFiles, {
|
||||
completed: ctx.uploadState.value.completedFiles,
|
||||
total: ctx.uploadState.value.totalFiles,
|
||||
})
|
||||
}}
|
||||
<span v-if="ctx.uploadState.value.currentFileName" class="font-normal text-secondary">
|
||||
— {{ ctx.uploadState.value.currentFileName }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="text-secondary">
|
||||
{{
|
||||
formatMessage(messages.uploadProgress, {
|
||||
uploaded: formatBytes(ctx.uploadState.value.uploadedBytes),
|
||||
total: formatBytes(ctx.uploadState.value.totalBytes),
|
||||
percent: Math.round(uploadOverallProgress * 100),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template v-if="ctx.cancelUpload" #top-right-actions>
|
||||
<ButtonStyled type="outlined" color="blue">
|
||||
<button class="!border" @click="ctx.cancelUpload?.()">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #progress>
|
||||
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
|
||||
</template>
|
||||
</Admonition>
|
||||
</Transition>
|
||||
<TransitionGroup
|
||||
name="fs-op"
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<Admonition
|
||||
v-for="op in activeOperations"
|
||||
:key="`fs-op-${op.op}-${op.src}`"
|
||||
:type="op.state === 'done' ? 'success' : op.state?.startsWith('fail') ? 'critical' : 'info'"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #icon="{ iconClass }">
|
||||
<PackageOpenIcon :class="iconClass" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{
|
||||
formatMessage(messages.extracting, {
|
||||
source: op.src.includes('https://') ? formatMessage(messages.modpackFromUrl) : op.src,
|
||||
})
|
||||
}}
|
||||
<span v-if="op.state === 'done'" class="font-normal text-green">
|
||||
— {{ formatMessage(commonMessages.doneLabel) }}</span
|
||||
>
|
||||
<span v-else-if="op.state?.startsWith('fail')" class="font-normal text-red">
|
||||
— {{ formatMessage(messages.failed) }}</span
|
||||
>
|
||||
</template>
|
||||
<span class="text-secondary">
|
||||
{{
|
||||
formatMessage(messages.extracted, {
|
||||
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
|
||||
})
|
||||
}}
|
||||
<template v-if="'current_file' in op && op.current_file">
|
||||
— {{ op.current_file?.split('/')?.pop() }}
|
||||
</template>
|
||||
</span>
|
||||
<template v-if="op.id && ctx.dismissOperation" #top-right-actions>
|
||||
<ButtonStyled
|
||||
v-if="op.state !== 'done' && !op.state?.startsWith('fail')"
|
||||
type="outlined"
|
||||
color="blue"
|
||||
>
|
||||
<button class="!border" @click="ctx.dismissOperation?.(op.id!, 'cancel')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="op.state === 'done' || op.state?.startsWith('fail')"
|
||||
circular
|
||||
type="transparent"
|
||||
hover-color-fill="background"
|
||||
:color="op.state === 'done' ? 'green' : 'red'"
|
||||
>
|
||||
<button @click="ctx.dismissOperation?.(op.id!, 'dismiss')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #progress>
|
||||
<ProgressBar
|
||||
:progress="'progress' in op ? (op.progress ?? 0) : 0"
|
||||
:max="1"
|
||||
:color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'"
|
||||
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
|
||||
full-width
|
||||
/>
|
||||
</template>
|
||||
</Admonition>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PackageOpenIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import ProgressBar from '#ui/components/base/ProgressBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadingFiles: {
|
||||
id: 'files.operations.uploading-files',
|
||||
defaultMessage: 'Uploading files ({completed}/{total})',
|
||||
},
|
||||
uploadProgress: {
|
||||
id: 'files.operations.upload-progress',
|
||||
defaultMessage: '{uploaded} / {total} ({percent}%)',
|
||||
},
|
||||
extracting: {
|
||||
id: 'files.operations.extracting',
|
||||
defaultMessage: 'Extracting {source}',
|
||||
},
|
||||
modpackFromUrl: {
|
||||
id: 'files.operations.modpack-from-url',
|
||||
defaultMessage: 'modpack from URL',
|
||||
},
|
||||
failed: {
|
||||
id: 'files.operations.failed',
|
||||
defaultMessage: 'Failed',
|
||||
},
|
||||
extracted: {
|
||||
id: 'files.operations.extracted',
|
||||
defaultMessage: '{size} extracted',
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const activeOperations = computed(() => ctx.activeOperations?.value ?? [])
|
||||
|
||||
const uploadOverallProgress = computed(() => {
|
||||
const state = ctx.uploadState?.value
|
||||
if (!state || !state.isUploading || state.totalFiles === 0) return 0
|
||||
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="sticky top-0 z-10 flex h-12 w-full select-none flex-row items-center justify-between bg-surface-3 pl-3 pr-4 font-medium transition-[border-radius] duration-100"
|
||||
:class="
|
||||
isStuck
|
||||
? 'rounded-none border-0 border-y border-solid border-surface-4 shadow-md before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-5 before:bg-surface-3'
|
||||
: 'rounded-t-[20px]'
|
||||
"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-3">
|
||||
<Checkbox
|
||||
:model-value="allSelected"
|
||||
:indeterminate="someSelected && !allSelected"
|
||||
@update:model-value="$emit('toggle-all')"
|
||||
/>
|
||||
<button
|
||||
class="flex appearance-none items-center gap-1.5 border-0 bg-transparent p-0 font-semibold hover:text-primary"
|
||||
:class="sortField === 'name' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'name')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.name) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'name' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'name' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex shrink-0 items-center gap-4 @[800px]:gap-12">
|
||||
<button
|
||||
class="hidden w-[100px] appearance-none items-center justify-start gap-1 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'size')"
|
||||
>
|
||||
<span class="ml-2">{{ formatMessage(messages.size) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'size' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'size' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="hidden w-[160px] appearance-none items-center justify-start gap-1 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'created' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'created')"
|
||||
>
|
||||
<span class="ml-2">{{ formatMessage(messages.created) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'created' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'created' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
class="hidden w-[160px] appearance-none items-center justify-start gap-1 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'modified')"
|
||||
>
|
||||
<span class="ml-2">{{ formatMessage(messages.modified) }}</span>
|
||||
<ChevronUpIcon
|
||||
v-if="sortField === 'modified' && !sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<ChevronDownIcon
|
||||
v-if="sortField === 'modified' && sortDesc"
|
||||
class="h-4 w-4"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<span class="min-w-[51px] shrink-0 text-right font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.actionsLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileSortField } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
name: {
|
||||
id: 'files.table-header.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
size: {
|
||||
id: 'files.table-header.size',
|
||||
defaultMessage: 'Size',
|
||||
},
|
||||
created: {
|
||||
id: 'files.table-header.created',
|
||||
defaultMessage: 'Created',
|
||||
},
|
||||
modified: {
|
||||
id: 'files.table-header.modified',
|
||||
defaultMessage: 'Modified',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
sortField: FileSortField
|
||||
sortDesc: boolean
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
isStuck: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
sort: [field: FileSortField]
|
||||
'toggle-all': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<li
|
||||
role="button"
|
||||
:class="[containerClasses, isDragSource ? 'opacity-50' : '']"
|
||||
tabindex="0"
|
||||
:data-file-path="path"
|
||||
:data-file-type="type"
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<div class="pointer-events-none flex flex-1 items-center gap-3 truncate">
|
||||
<Checkbox
|
||||
class="pointer-events-auto"
|
||||
:model-value="selected"
|
||||
@click.stop
|
||||
@update:model-value="emit('toggle-select')"
|
||||
/>
|
||||
<div class="pointer-events-none flex size-5 items-center justify-center">
|
||||
<component
|
||||
:is="iconComponent"
|
||||
class="size-5 group-hover:text-contrast group-focus:text-contrast"
|
||||
/>
|
||||
</div>
|
||||
<div class="pointer-events-none flex flex-col truncate">
|
||||
<span
|
||||
class="pointer-events-none truncate group-hover:text-contrast group-focus:text-contrast"
|
||||
>
|
||||
{{ name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 @[800px]:gap-12">
|
||||
<span class="hidden w-[100px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedSize }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedCreationDate }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedModifiedDate }}
|
||||
</span>
|
||||
<div class="flex min-w-[51px] shrink-0 items-center justify-end">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<TeleportOverflowMenu :options="menuOptions">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #copy-filename
|
||||
><ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}</template
|
||||
>
|
||||
<template #copy-full-path
|
||||
><ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}</template
|
||||
>
|
||||
<template #open-in-folder
|
||||
><FolderOpenIcon /> {{ formatMessage(commonMessages.openInFolderButton) }}</template
|
||||
>
|
||||
<template #extract
|
||||
><PackageOpenIcon /> {{ formatMessage(commonMessages.extractButton) }}</template
|
||||
>
|
||||
<template #rename
|
||||
><EditIcon /> {{ formatMessage(commonMessages.renameButton) }}</template
|
||||
>
|
||||
<template #move
|
||||
><RightArrowIcon /> {{ formatMessage(commonMessages.moveButton) }}</template
|
||||
>
|
||||
<template #download
|
||||
><DownloadIcon />
|
||||
{{
|
||||
ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton)
|
||||
}}</template
|
||||
>
|
||||
<template #delete
|
||||
><TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||
>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
BracesIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderCogIcon,
|
||||
FolderOpenIcon,
|
||||
GlassesIcon,
|
||||
GlobeIcon,
|
||||
MoreHorizontalIcon,
|
||||
PackageOpenIcon,
|
||||
PaintbrushIcon,
|
||||
RightArrowIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import { useFormatDateTime } from '#ui/composables/format-date-time'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { getFileExtensionIcon } from '#ui/utils/auto-icons'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import {
|
||||
getFileExtension,
|
||||
isEditableFile as isEditableFileExt,
|
||||
isImageFile,
|
||||
} from '#ui/utils/file-extensions'
|
||||
|
||||
import {
|
||||
fileDragActive,
|
||||
fileDragData,
|
||||
fileDragTarget,
|
||||
startFileDrag,
|
||||
wasRecentDrag,
|
||||
} from '../composables/file-drag-state'
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
import type { FileItem } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
itemCount: {
|
||||
id: 'files.row.item-count',
|
||||
defaultMessage: '{count, plural, one {# item} other {# items}}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<
|
||||
FileItem & {
|
||||
index: number
|
||||
isLast: boolean
|
||||
selected: boolean
|
||||
writeDisabled?: boolean
|
||||
writeDisabledTooltip?: string
|
||||
}
|
||||
>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover' | 'navigate',
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'>,
|
||||
): void
|
||||
(
|
||||
e: 'moveDirectTo',
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'> & { destination: string },
|
||||
): void
|
||||
(e: 'contextmenu', x: number, y: number): void
|
||||
(e: 'toggle-select'): void
|
||||
}>()
|
||||
|
||||
const isDropTarget = computed(
|
||||
() => fileDragActive.value && fileDragTarget.value === props.path && props.type === 'directory',
|
||||
)
|
||||
const isDragSource = computed(() => fileDragActive.value && fileDragData.value?.path === props.path)
|
||||
|
||||
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
|
||||
|
||||
const formatDateTime = useFormatDateTime({
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const dropTarget = isDropTarget.value
|
||||
return [
|
||||
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-4 pl-3 pr-4 py-3 focus:!outline-none',
|
||||
dropTarget
|
||||
? '!bg-brand-highlight'
|
||||
: props.selected
|
||||
? 'bg-surface-2.5'
|
||||
: props.index % 2 === 0
|
||||
? 'bg-surface-2'
|
||||
: 'bg-surface-1.5',
|
||||
props.isLast ? 'rounded-b-[20px]' : '',
|
||||
isEditableFile.value || props.type === 'directory' ? 'cursor-pointer hover:bg-surface-2.5' : '',
|
||||
'transition-colors duration-100 focus:!outline-none',
|
||||
]
|
||||
})
|
||||
|
||||
const fileExtension = computed(() => getFileExtension(props.name))
|
||||
|
||||
const isZip = computed(() => fileExtension.value === 'zip')
|
||||
|
||||
function getFullPath() {
|
||||
const basePath = ctx.basePath?.value
|
||||
return basePath ? `${basePath}/${props.path}`.replace(/\/+/g, '/') : props.path
|
||||
}
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
const item = { name: props.name, type: props.type, path: props.path }
|
||||
const wd = props.writeDisabled
|
||||
const wdTooltip = props.writeDisabledTooltip
|
||||
return [
|
||||
{
|
||||
id: 'copy-filename',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.name)
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.copiedFilenameLabel),
|
||||
type: 'success',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'copy-full-path',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(getFullPath())
|
||||
addNotification({ title: formatMessage(commonMessages.copiedPathLabel), type: 'success' })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'open-in-folder',
|
||||
icon: FolderOpenIcon,
|
||||
shown: !!ctx.openInFolder,
|
||||
action: () => ctx.openInFolder?.(getFullPath()),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
id: 'extract',
|
||||
shown: isZip.value,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('extract', item),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: isZip.value,
|
||||
},
|
||||
{
|
||||
id: 'rename',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('rename', item),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('move', item),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
action: () => emit('download', item),
|
||||
shown: props.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => emit('delete', item),
|
||||
color: 'red' as const,
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
if (props.name === 'config') return FolderCogIcon
|
||||
if (props.name === 'world' || props.name === 'saves') return GlobeIcon
|
||||
if (props.name === 'mods') return BoxIcon
|
||||
if (props.name === 'resourcepacks') return PaintbrushIcon
|
||||
if (props.name === 'shaderpacks') return GlassesIcon
|
||||
if (props.name === 'datapacks') return BracesIcon
|
||||
return FolderOpenIcon
|
||||
}
|
||||
|
||||
return getFileExtensionIcon(fileExtension.value)
|
||||
})
|
||||
|
||||
const formattedModifiedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000)
|
||||
return formatDateTime(date)
|
||||
})
|
||||
|
||||
const formattedCreationDate = computed(() => {
|
||||
const date = new Date(props.created * 1000)
|
||||
return formatDateTime(date)
|
||||
})
|
||||
|
||||
const isEditableFile = computed(() => {
|
||||
if (props.type === 'file') {
|
||||
const ext = fileExtension.value
|
||||
return !props.name.includes('.') || isEditableFileExt(ext) || isImageFile(ext)
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const formattedSize = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
return formatMessage(messages.itemCount, { count: props.count ?? 0 })
|
||||
}
|
||||
|
||||
if (props.size === undefined) return ''
|
||||
const bytes = props.size
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
|
||||
return `${size} ${units[exponent]}`
|
||||
})
|
||||
|
||||
function openContextMenu(event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
emit('contextmenu', event.clientX, event.clientY)
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
emit('hover', { name: props.name, type: props.type, path: props.path })
|
||||
}
|
||||
|
||||
const isNavigating = ref(false)
|
||||
|
||||
function selectItem() {
|
||||
if (isNavigating.value || wasRecentDrag()) return
|
||||
isNavigating.value = true
|
||||
|
||||
const item = { name: props.name, type: props.type, path: props.path }
|
||||
if (props.type === 'directory') {
|
||||
emit('navigate', item)
|
||||
} else if (props.type === 'file' && isEditableFile.value) {
|
||||
emit('edit', item)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigating.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
startFileDrag(
|
||||
{ name: props.name, type: props.type, path: props.path },
|
||||
e,
|
||||
(source, destination) => {
|
||||
emit('moveDirectTo', {
|
||||
name: source.name,
|
||||
type: source.type as FileItem['type'],
|
||||
path: source.path,
|
||||
destination,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div
|
||||
ref="editorContainer"
|
||||
class="flex flex-col overflow-hidden rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
>
|
||||
<component
|
||||
:is="props.editorComponent"
|
||||
v-if="!isEditingImage && !isLoading && props.editorComponent"
|
||||
v-model:value="fileContent"
|
||||
:lang="editorLanguage"
|
||||
theme="modrinth"
|
||||
:print-margin="false"
|
||||
:style="{ height: editorHeight, fontSize: '0.875rem' }"
|
||||
class="ace-modrinth rounded-[20px]"
|
||||
@init="onEditorInit"
|
||||
/>
|
||||
<FileImageViewer v-else-if="isEditingImage && imagePreview" :image-blob="imagePreview" />
|
||||
<div
|
||||
v-else-if="isLoading || !props.editorComponent"
|
||||
class="flex items-center justify-center rounded-[20px] bg-bg-raised"
|
||||
:style="{ height: editorHeight }"
|
||||
>
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { getEditorLanguage, getFileExtension, isImageFile } from '#ui/utils/file-extensions'
|
||||
|
||||
import { injectFileManager } from '../../providers/file-manager'
|
||||
import type { EditingFile } from '../../types'
|
||||
import FileImageViewer from './FileImageViewer.vue'
|
||||
|
||||
interface MclogsResponse {
|
||||
success: boolean
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
file: EditingFile | null
|
||||
editorComponent: Component | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
failedToOpenTitle: {
|
||||
id: 'files.editor.failed-to-open-title',
|
||||
defaultMessage: 'Failed to open file',
|
||||
},
|
||||
failedToOpenText: {
|
||||
id: 'files.editor.failed-to-open-text',
|
||||
defaultMessage: 'Could not load file contents.',
|
||||
},
|
||||
fileSavedTitle: {
|
||||
id: 'files.editor.file-saved-title',
|
||||
defaultMessage: 'File saved',
|
||||
},
|
||||
fileSavedText: {
|
||||
id: 'files.editor.file-saved-text',
|
||||
defaultMessage: 'Your file has been saved.',
|
||||
},
|
||||
saveFailedTitle: {
|
||||
id: 'files.editor.save-failed-title',
|
||||
defaultMessage: 'Save failed',
|
||||
},
|
||||
saveFailedText: {
|
||||
id: 'files.editor.save-failed-text',
|
||||
defaultMessage: 'Could not save the file.',
|
||||
},
|
||||
logUrlCopiedTitle: {
|
||||
id: 'files.editor.log-url-copied-title',
|
||||
defaultMessage: 'Log URL copied',
|
||||
},
|
||||
logUrlCopiedText: {
|
||||
id: 'files.editor.log-url-copied-text',
|
||||
defaultMessage: 'Your log file URL has been copied to your clipboard.',
|
||||
},
|
||||
failedToShareTitle: {
|
||||
id: 'files.editor.failed-to-share-title',
|
||||
defaultMessage: 'Failed to share file',
|
||||
},
|
||||
failedToShareText: {
|
||||
id: 'files.editor.failed-to-share-text',
|
||||
defaultMessage: 'Could not upload to mclo.gs.',
|
||||
},
|
||||
})
|
||||
|
||||
const fileContent = ref('')
|
||||
const originalContent = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const imagePreview = ref<Blob | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const editorInstance = ref<unknown>(null)
|
||||
const editorContainer = ref<HTMLElement | null>(null)
|
||||
const editorHeight = ref('300px')
|
||||
|
||||
function updateEditorHeight() {
|
||||
if (editorContainer.value) {
|
||||
const top = editorContainer.value.getBoundingClientRect().top
|
||||
const padding = 24
|
||||
editorHeight.value = `${Math.max(300, window.innerHeight - top - padding)}px`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(updateEditorHeight)
|
||||
window.addEventListener('resize', updateEditorHeight)
|
||||
})
|
||||
|
||||
const editorLanguage = computed(() => {
|
||||
const ext = getFileExtension(props.file?.name ?? '')
|
||||
return getEditorLanguage(ext)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
async (newFile) => {
|
||||
if (newFile) {
|
||||
await loadFileContent(newFile)
|
||||
nextTick(updateEditorHeight)
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadFileContent(file: { name: string; path: string }) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
window.scrollTo(0, 0)
|
||||
const extension = getFileExtension(file.name)
|
||||
const normalizedPath = file.path.startsWith('/') ? file.path : `/${file.path}`
|
||||
|
||||
if (isImageFile(extension)) {
|
||||
const content = await ctx.readFileAsBlob(normalizedPath)
|
||||
isEditingImage.value = true
|
||||
imagePreview.value = content
|
||||
} else {
|
||||
isEditingImage.value = false
|
||||
const content = await ctx.readFile(normalizedPath)
|
||||
fileContent.value = content
|
||||
originalContent.value = content
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file content:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.failedToOpenTitle),
|
||||
text: formatMessage(messages.failedToOpenText),
|
||||
type: 'error',
|
||||
})
|
||||
emit('close')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() => !isEditingImage.value && !isLoading.value && fileContent.value !== originalContent.value,
|
||||
)
|
||||
|
||||
function revertChanges() {
|
||||
fileContent.value = originalContent.value
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
fileContent.value = ''
|
||||
originalContent.value = ''
|
||||
isEditingImage.value = false
|
||||
imagePreview.value = null
|
||||
}
|
||||
|
||||
function onEditorInit(editor: {
|
||||
commands: {
|
||||
addCommand: (cmd: {
|
||||
name: string
|
||||
bindKey: { win: string; mac: string }
|
||||
exec: () => void
|
||||
}) => void
|
||||
}
|
||||
}) {
|
||||
editorInstance.value = editor
|
||||
|
||||
editor.commands.addCommand({
|
||||
name: 'save',
|
||||
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
|
||||
exec: () => saveFileContent(false),
|
||||
})
|
||||
}
|
||||
|
||||
async function saveFileContent(exit: boolean = false) {
|
||||
if (!props.file) return
|
||||
|
||||
try {
|
||||
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
|
||||
await ctx.writeFile(normalizedPath, fileContent.value)
|
||||
|
||||
originalContent.value = fileContent.value
|
||||
|
||||
if (exit) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: formatMessage(messages.fileSavedTitle),
|
||||
text: formatMessage(messages.fileSavedText),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving file content:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.saveFailedTitle),
|
||||
text: formatMessage(messages.saveFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function shareToMclogs() {
|
||||
if (ctx.shareToMclogs) {
|
||||
await ctx.shareToMclogs(fileContent.value)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.mclo.gs/1/log', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({ content: fileContent.value }),
|
||||
})
|
||||
|
||||
const data = (await response.json()) as MclogsResponse
|
||||
|
||||
if (data.success && data.url) {
|
||||
await navigator.clipboard.writeText(data.url)
|
||||
addNotification({
|
||||
title: formatMessage(messages.logUrlCopiedTitle),
|
||||
text: formatMessage(messages.logUrlCopiedText),
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing file:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.failedToShareTitle),
|
||||
text: formatMessage(messages.failedToShareText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateEditorHeight)
|
||||
editorInstance.value = null
|
||||
resetState()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
saveFileContent,
|
||||
shareToMclogs,
|
||||
close,
|
||||
isEditingImage,
|
||||
fileContent,
|
||||
hasUnsavedChanges,
|
||||
revertChanges,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex h-[750px] items-center justify-center overflow-hidden rounded-[20px] bg-black"
|
||||
>
|
||||
<div v-if="state.hasError" class="flex flex-col items-center justify-center gap-4">
|
||||
<TriangleAlertIcon class="size-8 text-red" />
|
||||
<p class="m-0 text-secondary">
|
||||
{{ state.errorMessage || formatMessage(messages.invalidImage) }}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imageRef"
|
||||
:src="imageObjectUrl"
|
||||
class="max-h-full max-w-full rounded-lg object-contain"
|
||||
:class="{ 'cursor-zoom-in': !zoomed, 'cursor-zoom-out': zoomed }"
|
||||
:alt="formatMessage(messages.viewedImageAlt)"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@click="toggleZoom"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isReady"
|
||||
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-2xl bg-surface-3/80 p-1.5 backdrop-blur-sm"
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.zoomIn)" @click="zoomIn">
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.zoomOut)" @click="zoomOut">
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.resetZoom)" @click="resetZoom">
|
||||
<span class="px-1 text-sm tabular-nums">{{ Math.round(scale * 100) }}%</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlertIcon, ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
invalidImage: {
|
||||
id: 'files.image_viewer.invalid_image',
|
||||
defaultMessage: 'Invalid or empty image file.',
|
||||
},
|
||||
viewedImageAlt: {
|
||||
id: 'files.image_viewer.viewed_image_alt',
|
||||
defaultMessage: 'Viewed image',
|
||||
},
|
||||
zoomIn: {
|
||||
id: 'files.image_viewer.zoom_in',
|
||||
defaultMessage: 'Zoom in',
|
||||
},
|
||||
zoomOut: {
|
||||
id: 'files.image_viewer.zoom_out',
|
||||
defaultMessage: 'Zoom out',
|
||||
},
|
||||
resetZoom: {
|
||||
id: 'files.image_viewer.reset_zoom',
|
||||
defaultMessage: 'Reset zoom',
|
||||
},
|
||||
imageTooLarge: {
|
||||
id: 'files.image_viewer.image_too_large',
|
||||
defaultMessage: 'Image too large to view (max {maxDimension}x{maxDimension} pixels)',
|
||||
},
|
||||
loadFailed: {
|
||||
id: 'files.image_viewer.load_failed',
|
||||
defaultMessage: 'Failed to load image',
|
||||
},
|
||||
})
|
||||
|
||||
const MAX_IMAGE_DIMENSION = 4096
|
||||
|
||||
const props = defineProps<{
|
||||
imageBlob: Blob
|
||||
}>()
|
||||
|
||||
const state = ref({
|
||||
isLoading: true,
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
})
|
||||
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const imageObjectUrl = ref('')
|
||||
const scale = ref(1)
|
||||
const zoomed = ref(false)
|
||||
|
||||
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
|
||||
|
||||
function updateImageUrl(blob: Blob) {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
imageObjectUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
function handleImageLoad() {
|
||||
const img = imageRef.value
|
||||
if (img && (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION)) {
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = formatMessage(messages.imageTooLarge, {
|
||||
maxDimension: MAX_IMAGE_DIMENSION,
|
||||
})
|
||||
}
|
||||
state.value.isLoading = false
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
state.value.isLoading = false
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = formatMessage(messages.loadFailed)
|
||||
}
|
||||
|
||||
function toggleZoom() {
|
||||
if (zoomed.value) {
|
||||
resetZoom()
|
||||
} else {
|
||||
scale.value = 2
|
||||
zoomed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale.value = Math.min(scale.value * 1.25, 5)
|
||||
zoomed.value = scale.value > 1
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale.value = Math.max(scale.value * 0.8, 0.1)
|
||||
zoomed.value = scale.value > 1
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
scale.value = 1
|
||||
zoomed.value = false
|
||||
}
|
||||
|
||||
watch(scale, (s) => {
|
||||
if (imageRef.value) {
|
||||
imageRef.value.style.transform = s === 1 ? '' : `scale(${s})`
|
||||
imageRef.value.style.transition = 'transform 0.2s ease-out'
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (!newBlob) return
|
||||
state.value.isLoading = true
|
||||
state.value.hasError = false
|
||||
scale.value = 1
|
||||
zoomed.value = false
|
||||
updateImageUrl(newBlob)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) updateImageUrl(props.imageBlob)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header, { type })" max-width="500px">
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(fileValidationMessages.nameLabel)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="createInput"
|
||||
v-model="itemName"
|
||||
:placeholder="
|
||||
formatMessage(
|
||||
type === 'file' ? messages.placeholderFile : messages.placeholderDirectory,
|
||||
)
|
||||
"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-sm text-red">{{ error }}</div>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error && submitted" @click="handleSubmit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
{{ formatMessage(messages.createButton, { type }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { fileValidationMessages } from './file-validation-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.create-modal.header',
|
||||
defaultMessage: 'Create a {type, select, directory {folder} other {file}}',
|
||||
},
|
||||
placeholderFile: {
|
||||
id: 'files.create-modal.placeholder-file',
|
||||
defaultMessage: 'e.g. config.yml',
|
||||
},
|
||||
placeholderDirectory: {
|
||||
id: 'files.create-modal.placeholder-directory',
|
||||
defaultMessage: 'e.g. my-folder',
|
||||
},
|
||||
createButton: {
|
||||
id: 'files.create-modal.create-button',
|
||||
defaultMessage: 'Create {type, select, directory {folder} other {file}}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'file' | 'directory'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [name: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const createInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return formatMessage(fileValidationMessages.nameRequired)
|
||||
}
|
||||
if (props.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidFile)
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidDirectory)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('create', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
itemName.value = ''
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
createInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="danger" :header="formatMessage(messages.header)" max-width="500px">
|
||||
<Admonition type="critical" class="md:min-w-[400px]">
|
||||
<template #header>{{ formatMessage(messages.deletingName, { name: item?.name }) }}</template>
|
||||
{{ formatMessage(messages.deleteWarning, { type: item?.type }) }}
|
||||
</Admonition>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="handleSubmit">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { 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 type { FileItem } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.delete-modal.header',
|
||||
defaultMessage: 'Delete file',
|
||||
},
|
||||
deletingName: {
|
||||
id: 'files.delete-modal.deleting-name',
|
||||
defaultMessage: 'Deleting "{name}"',
|
||||
},
|
||||
deleteWarning: {
|
||||
id: 'files.delete-modal.warning',
|
||||
defaultMessage:
|
||||
'{type, select, directory {This folder and all its contents will be permanently deleted. This action cannot be undone.} other {This file will be permanently deleted. This action cannot be undone.}}',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('delete')
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { type: item?.type })"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.currentLocation)
|
||||
}}</span>
|
||||
<span class="text-secondary">{{ `${currentPath}/${item?.name}`.replace('//', '/') }}</span>
|
||||
</div>
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.destinationPath)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="destinationInput"
|
||||
v-model="destination"
|
||||
:placeholder="formatMessage(messages.destinationPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleSubmit">
|
||||
<RightArrowIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.moveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.move-modal.header',
|
||||
defaultMessage: '{type, select, directory {Move folder} other {Move file}}',
|
||||
},
|
||||
currentLocation: {
|
||||
id: 'files.move-modal.current-location',
|
||||
defaultMessage: 'Current location',
|
||||
},
|
||||
destinationPath: {
|
||||
id: 'files.move-modal.destination-path',
|
||||
defaultMessage: 'Destination path',
|
||||
},
|
||||
destinationPlaceholder: {
|
||||
id: 'files.move-modal.destination-placeholder',
|
||||
defaultMessage: 'e.g. /my-folder',
|
||||
},
|
||||
})
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
currentPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
move: [destination: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const destination = ref('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const path = destination.value.replace('//', '/')
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
emit('move', normalized)
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
destination.value = ''
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
destinationInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { name: item?.name })"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.newNameLabel) }}</span>
|
||||
<StyledInput ref="renameInput" v-model="itemName" wrapper-class="w-full" />
|
||||
<div v-if="submitted && error" class="text-sm text-red">{{ error }}</div>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error && submitted" @click="handleSubmit">
|
||||
<EditIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.renameButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
import { fileValidationMessages } from './file-validation-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.rename-modal.header',
|
||||
defaultMessage: 'Rename {name}',
|
||||
},
|
||||
newNameLabel: {
|
||||
id: 'files.rename-modal.new-name-label',
|
||||
defaultMessage: 'New name',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
rename: [newName: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const renameInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return formatMessage(fileValidationMessages.nameRequired)
|
||||
}
|
||||
if (props.item?.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidFile)
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidDirectory)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('rename', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = (item: { name: string; type: string }) => {
|
||||
itemName.value = item.name
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
renameInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="warning" :header="formatMessage(messages.header)" max-width="500px">
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.body) }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="handleDiscard">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.discard) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="handleSave">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from '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'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.unsaved-changes-modal.header',
|
||||
defaultMessage: 'Unsaved changes',
|
||||
},
|
||||
body: {
|
||||
id: 'files.unsaved-changes-modal.body',
|
||||
defaultMessage:
|
||||
'You have unsaved changes that will be lost if you leave. Would you like to save before leaving?',
|
||||
},
|
||||
discard: {
|
||||
id: 'files.unsaved-changes-modal.discard',
|
||||
defaultMessage: 'Discard',
|
||||
},
|
||||
})
|
||||
|
||||
export type UnsavedChangesResult = 'cancel' | 'discard' | 'save'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
let resolvePromise: ((value: UnsavedChangesResult) => void) | null = null
|
||||
|
||||
function prompt(): Promise<UnsavedChangesResult> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
modal.value?.show()
|
||||
})
|
||||
}
|
||||
|
||||
function resolve(result: UnsavedChangesResult) {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(result)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
resolve('cancel')
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
resolve('discard')
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
resolve('save')
|
||||
}
|
||||
|
||||
defineExpose({ prompt })
|
||||
</script>
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :closable="true" no-padding>
|
||||
<div class="max-w-[500px]">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<Admonition type="warning" :header="formatMessage(messages.warningHeader)">
|
||||
<span>
|
||||
<template v-if="hasMany">
|
||||
{{ formatMessage(messages.overwriteManyWarning) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(messages.overwriteWarning, { count: files.length }) }}
|
||||
</template>
|
||||
</span>
|
||||
</Admonition>
|
||||
|
||||
<div v-if="files.length" class="flex gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<MinusIcon />
|
||||
{{ formatMessage(messages.overwrittenCount, { count: files.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="files.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="(file, index) in files"
|
||||
:key="file"
|
||||
class="grid grid-cols-[auto_auto_1fr] items-center min-h-10 h-10 gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-between">
|
||||
<div class="w-[1px] h-2"></div>
|
||||
<MinusIcon class="text-red" />
|
||||
<div
|
||||
:class="index === files.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">{{
|
||||
formatMessage(messages.overwrittenLabel)
|
||||
}}</span>
|
||||
<span
|
||||
v-tooltip="file"
|
||||
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ file }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleProceed">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.overwriteButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, MinusIcon, XIcon } from '@modrinth/assets'
|
||||
import { 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'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.conflict-modal.header',
|
||||
defaultMessage: 'Extract summary',
|
||||
},
|
||||
warningHeader: {
|
||||
id: 'files.conflict-modal.warning-header',
|
||||
defaultMessage: 'Files will be overwritten',
|
||||
},
|
||||
overwriteManyWarning: {
|
||||
id: 'files.conflict-modal.overwrite-many-warning',
|
||||
defaultMessage:
|
||||
'Over 100 files will be overwritten if you proceed with extraction; here are some of them.',
|
||||
},
|
||||
overwriteWarning: {
|
||||
id: 'files.conflict-modal.overwrite-warning',
|
||||
defaultMessage:
|
||||
'The following {count} files already exist on your server, and will be overwritten if you proceed with extraction.',
|
||||
},
|
||||
overwrittenCount: {
|
||||
id: 'files.conflict-modal.overwritten-count',
|
||||
defaultMessage: '{count} overwritten',
|
||||
},
|
||||
overwrittenLabel: {
|
||||
id: 'files.conflict-modal.overwritten-label',
|
||||
defaultMessage: 'Overwritten',
|
||||
},
|
||||
overwriteButton: {
|
||||
id: 'files.conflict-modal.overwrite-button',
|
||||
defaultMessage: 'Overwrite',
|
||||
},
|
||||
})
|
||||
|
||||
const path = ref('')
|
||||
const files = ref<string[]>([])
|
||||
|
||||
const emit = defineEmits<{
|
||||
proceed: [path: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const hasMany = computed(() => files.value.length > 100)
|
||||
|
||||
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||
path.value = zipPath
|
||||
files.value = conflictingFiles
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const handleProceed = () => {
|
||||
hide()
|
||||
emit('proceed', path.value)
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="cf ? formatMessage(messages.cfHeader) : formatMessage(messages.zipHeader)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 md:w-[700px]" @submit.prevent="handleSubmit">
|
||||
<!-- CurseForge stepper cards -->
|
||||
<div v-if="cf" class="flex gap-4">
|
||||
<div
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
class="flex flex-1 flex-col gap-2 overflow-clip rounded-[20px] bg-surface-2 p-3"
|
||||
>
|
||||
<span
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-4 font-medium text-contrast"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<div class="font-semibold leading-snug text-contrast">
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div class="text-sm leading-relaxed text-secondary">
|
||||
{{ step.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL input -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label v-if="cf" class="text-base font-semibold text-contrast">{{
|
||||
formatMessage(messages.enterLink)
|
||||
}}</label>
|
||||
<div v-else class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.zipDescription) }}
|
||||
</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>
|
||||
|
||||
<!-- Inline backup creator -->
|
||||
<InlineBackupCreator
|
||||
:backup-name="formatMessage(messages.backupName)"
|
||||
hide-shift-click-hint
|
||||
@update:buttons-disabled="backupInProgress = $event"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex w-full items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" class="!border !border-surface-4" @click="hide">
|
||||
<XIcon />
|
||||
{{
|
||||
submitted
|
||||
? formatMessage(commonMessages.closeButton)
|
||||
: formatMessage(commonMessages.cancelButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="error"
|
||||
:disabled="submitted || !!error || backupInProgress"
|
||||
type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<SpinnerIcon v-if="submitted" class="animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
submitted
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: formatMessage(messages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
LinkIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers/api-client'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from '../../../content-tab/components/modals/InlineBackupCreator.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
cfHeader: {
|
||||
id: 'files.zip-url-modal.cf-header',
|
||||
defaultMessage: 'Install a CurseForge modpack',
|
||||
},
|
||||
zipHeader: {
|
||||
id: 'files.zip-url-modal.zip-header',
|
||||
defaultMessage: 'Uploading .zip contents from URL',
|
||||
},
|
||||
enterLink: {
|
||||
id: 'files.zip-url-modal.enter-link',
|
||||
defaultMessage: 'Enter link',
|
||||
},
|
||||
zipDescription: {
|
||||
id: 'files.zip-url-modal.zip-description',
|
||||
defaultMessage: 'Copy and paste the direct download URL of a .zip file.',
|
||||
},
|
||||
installButton: {
|
||||
id: 'files.zip-url-modal.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
stepFindTitle: {
|
||||
id: 'files.zip-url-modal.step-find-title',
|
||||
defaultMessage: 'Find the modpack',
|
||||
},
|
||||
stepFindDescription: {
|
||||
id: 'files.zip-url-modal.step-find-description',
|
||||
defaultMessage: 'Browse CurseForge and locate the modpack you want.',
|
||||
},
|
||||
stepSelectTitle: {
|
||||
id: 'files.zip-url-modal.step-select-title',
|
||||
defaultMessage: 'Select a version',
|
||||
},
|
||||
stepSelectDescription: {
|
||||
id: 'files.zip-url-modal.step-select-description',
|
||||
defaultMessage: 'Go to the "Files" tab and pick the version to install.',
|
||||
},
|
||||
stepCopyTitle: {
|
||||
id: 'files.zip-url-modal.step-copy-title',
|
||||
defaultMessage: 'Copy the URL',
|
||||
},
|
||||
stepCopyDescription: {
|
||||
id: 'files.zip-url-modal.step-copy-description',
|
||||
defaultMessage: 'Copy the version page URL and paste it below.',
|
||||
},
|
||||
errorUrlRequired: {
|
||||
id: 'files.zip-url-modal.error-url-required',
|
||||
defaultMessage: 'URL is required.',
|
||||
},
|
||||
errorCfUrl: {
|
||||
id: 'files.zip-url-modal.error-cf-url',
|
||||
defaultMessage: 'URL must be a CurseForge modpack version URL.',
|
||||
},
|
||||
errorUrlInvalid: {
|
||||
id: 'files.zip-url-modal.error-url-invalid',
|
||||
defaultMessage: 'URL must be valid.',
|
||||
},
|
||||
cfNotFoundTitle: {
|
||||
id: 'files.zip-url-modal.cf-not-found-title',
|
||||
defaultMessage: 'CurseForge modpack not found',
|
||||
},
|
||||
cfNotFoundText: {
|
||||
id: 'files.zip-url-modal.cf-not-found-text',
|
||||
defaultMessage: 'Could not find CurseForge modpack at that URL.',
|
||||
},
|
||||
installFailedTitle: {
|
||||
id: 'files.zip-url-modal.install-failed-title',
|
||||
defaultMessage: 'Installation failed',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'files.zip-url-modal.unknown-error',
|
||||
defaultMessage: 'An unknown error occurred',
|
||||
},
|
||||
backupName: {
|
||||
id: 'files.zip-url-modal.backup-name',
|
||||
defaultMessage: 'CurseForge modpack install',
|
||||
},
|
||||
})
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: SearchIcon,
|
||||
title: formatMessage(messages.stepFindTitle),
|
||||
description: formatMessage(messages.stepFindDescription),
|
||||
},
|
||||
{
|
||||
icon: FileTextIcon,
|
||||
title: formatMessage(messages.stepSelectTitle),
|
||||
description: formatMessage(messages.stepSelectDescription),
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
title: formatMessage(messages.stepCopyTitle),
|
||||
description: formatMessage(messages.stepCopyDescription),
|
||||
},
|
||||
]
|
||||
|
||||
const cf = ref(false)
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const url = ref('')
|
||||
const submitted = ref(false)
|
||||
const touched = ref(false)
|
||||
const backupInProgress = 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 formatMessage(messages.errorUrlRequired)
|
||||
}
|
||||
if (cf.value && !regex.test(trimmedUrl.value)) {
|
||||
return formatMessage(messages.errorCfUrl)
|
||||
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
|
||||
return formatMessage(messages.errorUrlInvalid)
|
||||
}
|
||||
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: formatMessage(messages.cfNotFoundTitle),
|
||||
text: formatMessage(messages.cfNotFoundText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
submitted.value = false
|
||||
console.error('Error installing:', err)
|
||||
addNotification({
|
||||
title: formatMessage(messages.installFailedTitle),
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.unknownError),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const show = (isCf: boolean) => {
|
||||
cf.value = isCf
|
||||
url.value = ''
|
||||
submitted.value = false
|
||||
touched.value = false
|
||||
backupInProgress.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
modal.value?.$el?.querySelector('input')?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
import { defineMessages } from '#ui/composables/i18n'
|
||||
|
||||
export const fileValidationMessages = defineMessages({
|
||||
nameLabel: {
|
||||
id: 'files.validation.name-label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
nameRequired: {
|
||||
id: 'files.validation.name-required',
|
||||
defaultMessage: 'Name is required.',
|
||||
},
|
||||
nameInvalidFile: {
|
||||
id: 'files.validation.name-invalid-file',
|
||||
defaultMessage:
|
||||
'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.',
|
||||
},
|
||||
nameInvalidDirectory: {
|
||||
id: 'files.validation.name-invalid-directory',
|
||||
defaultMessage:
|
||||
'Name must contain only alphanumeric characters, dashes, underscores, or spaces.',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<slot />
|
||||
<div
|
||||
v-if="isDragging"
|
||||
:class="[
|
||||
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black/60 text-contrast shadow',
|
||||
overlayClass,
|
||||
]"
|
||||
>
|
||||
<div class="text-center">
|
||||
<UploadIcon class="mx-auto h-16 w-16 shadow-2xl" />
|
||||
<p class="mt-2 text-xl">
|
||||
{{
|
||||
formatMessage(messages.dropToUpload, {
|
||||
type: type ? type.toLocaleLowerCase() : undefined,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
filesDropped: [files: File[]]
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
overlayClass?: string
|
||||
type?: string
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
dropToUpload: {
|
||||
id: 'files.upload.drag-and-drop.drop-to-upload',
|
||||
defaultMessage: 'Drop {type, select, undefined {files} other {{type}s}} here to upload',
|
||||
},
|
||||
})
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragCounter = ref(0)
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
dragCounter.value++
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
const handleDragLeave = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
dragCounter.value--
|
||||
if (dragCounter.value === 0) {
|
||||
isDragging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
isDragging.value = false
|
||||
dragCounter.value = 0
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (files) {
|
||||
emit('filesDropped', Array.from(files))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,392 @@
|
||||
<template>
|
||||
<div>
|
||||
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||
<div
|
||||
ref="statusContentRef"
|
||||
v-bind="$attrs"
|
||||
:class="['flex flex-col p-4 text-sm text-contrast']"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2 font-bold">
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{
|
||||
formatMessage(messages.fileUploads, {
|
||||
fileType: props.fileType ? props.fileType : formatMessage(messages.file),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span>{{
|
||||
activeUploads.length > 0
|
||||
? formatMessage(messages.uploadsLeft, { count: activeUploads.length })
|
||||
: ''
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="item in uploadQueue"
|
||||
:key="item.file.name"
|
||||
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<div class="flex flex-1 items-center gap-2 truncate">
|
||||
<transition-group name="status-icon" mode="out-in">
|
||||
<SpinnerIcon
|
||||
v-show="item.status === 'uploading'"
|
||||
key="spinner"
|
||||
class="absolute !size-4 animate-spin"
|
||||
/>
|
||||
<CheckCircleIcon
|
||||
v-show="item.status === 'completed'"
|
||||
key="check"
|
||||
class="absolute size-4 text-green"
|
||||
/>
|
||||
<XCircleIcon
|
||||
v-show="
|
||||
item.status.includes('error') ||
|
||||
item.status === 'cancelled' ||
|
||||
item.status === 'incorrect-type'
|
||||
"
|
||||
key="error"
|
||||
class="absolute size-4 text-red"
|
||||
/>
|
||||
</transition-group>
|
||||
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||
<span class="text-secondary">{{ item.size }}</span>
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>{{ formatMessage(commonMessages.doneLabel) }}</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-file-exists'">
|
||||
<span class="text-red">{{ formatMessage(messages.failedFileExists) }}</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-generic'">
|
||||
<span class="text-red">{{
|
||||
formatMessage(messages.failedGeneric, {
|
||||
error: item.error?.message || formatMessage(messages.unexpectedError),
|
||||
})
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">{{ formatMessage(messages.failedIncorrectType) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>{{ formatMessage(commonMessages.cancelButton) }}</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">{{ formatMessage(messages.cancelled) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||
<div
|
||||
class="h-full bg-contrast transition-all duration-200"
|
||||
:style="{ width: item.progress + '%' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckCircleIcon, FolderOpenIcon, SpinnerIcon, XCircleIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers/api-client'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const messages = defineMessages({
|
||||
file: {
|
||||
id: 'files.upload-dropdown.file',
|
||||
defaultMessage: 'File',
|
||||
},
|
||||
fileUploads: {
|
||||
id: 'files.upload-dropdown.file-uploads',
|
||||
defaultMessage: '{fileType} uploads',
|
||||
},
|
||||
uploadsLeft: {
|
||||
id: 'files.upload-dropdown.uploads-left',
|
||||
defaultMessage: ' - {count} left',
|
||||
},
|
||||
failedFileExists: {
|
||||
id: 'files.upload-dropdown.failed-file-exists',
|
||||
defaultMessage: 'Failed - File already exists',
|
||||
},
|
||||
failedGeneric: {
|
||||
id: 'files.upload-dropdown.failed-generic',
|
||||
defaultMessage: 'Failed - {error}',
|
||||
},
|
||||
unexpectedError: {
|
||||
id: 'files.upload-dropdown.unexpected-error',
|
||||
defaultMessage: 'An unexpected error occurred.',
|
||||
},
|
||||
failedIncorrectType: {
|
||||
id: 'files.upload-dropdown.failed-incorrect-type',
|
||||
defaultMessage: 'Failed - Incorrect file type',
|
||||
},
|
||||
cancelled: {
|
||||
id: 'files.upload-dropdown.cancelled',
|
||||
defaultMessage: 'Cancelled',
|
||||
},
|
||||
incorrectFileType: {
|
||||
id: 'files.upload-dropdown.incorrect-file-type',
|
||||
defaultMessage: 'Upload had incorrect file type',
|
||||
},
|
||||
failedToUpload: {
|
||||
id: 'files.upload-dropdown.failed-to-upload',
|
||||
defaultMessage: 'Failed to upload {fileName}',
|
||||
},
|
||||
})
|
||||
|
||||
interface UploadItem {
|
||||
file: File
|
||||
progress: number
|
||||
status:
|
||||
| 'pending'
|
||||
| 'uploading'
|
||||
| 'completed'
|
||||
| 'error-file-exists'
|
||||
| 'error-generic'
|
||||
| 'cancelled'
|
||||
| 'incorrect-type'
|
||||
size: string
|
||||
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
|
||||
error?: Error
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentPath: string
|
||||
fileType?: string
|
||||
marginBottom?: number
|
||||
acceptedTypes?: Array<string>
|
||||
}
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
uploadComplete: []
|
||||
}>()
|
||||
|
||||
const uploadStatusRef = ref<HTMLElement | null>(null)
|
||||
const statusContentRef = ref<HTMLElement | null>(null)
|
||||
const uploadQueue = ref<UploadItem[]>([])
|
||||
|
||||
const isUploading = computed(() => uploadQueue.value.length > 0)
|
||||
const activeUploads = computed(() =>
|
||||
uploadQueue.value.filter((item) => item.status === 'pending' || item.status === 'uploading'),
|
||||
)
|
||||
|
||||
const onUploadStatusEnter = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
|
||||
;(el as HTMLElement).style.height = '0'
|
||||
|
||||
void (el as HTMLElement).offsetHeight
|
||||
;(el as HTMLElement).style.height = `${height}px`
|
||||
}
|
||||
|
||||
const onUploadStatusLeave = (el: Element) => {
|
||||
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
|
||||
;(el as HTMLElement).style.height = `${height}px`
|
||||
|
||||
void (el as HTMLElement).offsetHeight
|
||||
;(el as HTMLElement).style.height = '0'
|
||||
}
|
||||
|
||||
watch(
|
||||
uploadQueue,
|
||||
() => {
|
||||
if (!uploadStatusRef.value) return
|
||||
const el = uploadStatusRef.value
|
||||
const itemsHeight = uploadQueue.value.length * 32
|
||||
const headerHeight = 12
|
||||
const gap = 8
|
||||
const padding = 32
|
||||
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0)
|
||||
el.style.height = `${totalHeight}px`
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return bytes + ' B'
|
||||
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
|
||||
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
|
||||
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
|
||||
}
|
||||
|
||||
const cancelUpload = (item: UploadItem) => {
|
||||
if (item.uploader && item.status === 'uploading') {
|
||||
item.uploader.cancel()
|
||||
item.status = 'cancelled'
|
||||
|
||||
setTimeout(async () => {
|
||||
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name)
|
||||
if (index !== -1) {
|
||||
uploadQueue.value.splice(index, 1)
|
||||
await nextTick()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
}
|
||||
|
||||
const badFileTypeMsg = formatMessage(messages.incorrectFileType)
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const uploadItem: UploadItem = {
|
||||
file,
|
||||
progress: 0,
|
||||
status: 'pending',
|
||||
size: formatFileSize(file.size),
|
||||
}
|
||||
|
||||
uploadQueue.value.push(uploadItem)
|
||||
|
||||
try {
|
||||
if (
|
||||
props.acceptedTypes &&
|
||||
!props.acceptedTypes.includes(file.type) &&
|
||||
!props.acceptedTypes.some((type) => file.name.endsWith(type))
|
||||
) {
|
||||
throw new Error(badFileTypeMsg)
|
||||
}
|
||||
|
||||
uploadItem.status = 'uploading'
|
||||
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
|
||||
|
||||
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
|
||||
onProgress: ({ progress }) => {
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (index !== -1) {
|
||||
uploadQueue.value[index].progress = Math.round(progress)
|
||||
}
|
||||
},
|
||||
})
|
||||
uploadItem.uploader = uploader
|
||||
|
||||
await uploader.promise
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
||||
uploadQueue.value[index].status = 'completed'
|
||||
uploadQueue.value[index].progress = 100
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1)
|
||||
await nextTick()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
emit('uploadComplete')
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
||||
const target = uploadQueue.value[index]
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message === badFileTypeMsg) {
|
||||
target.status = 'incorrect-type'
|
||||
} else if (target.progress === 100 && error.message.includes('401')) {
|
||||
target.status = 'error-file-exists'
|
||||
} else {
|
||||
target.status = 'error-generic'
|
||||
target.error = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(async () => {
|
||||
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||
if (removeIndex !== -1) {
|
||||
uploadQueue.value.splice(removeIndex, 1)
|
||||
await nextTick()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
if (error instanceof Error && error.message !== 'Upload cancelled') {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.uploadFailedLabel),
|
||||
text: formatMessage(messages.failedToUpload, { fileName: file.name }),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
uploadFile,
|
||||
cancelUpload,
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.upload-status {
|
||||
overflow: hidden;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
.upload-status-enter-active,
|
||||
.upload-status-leave-active {
|
||||
transition: height 0.2s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.upload-status-enter-from,
|
||||
.upload-status-leave-to {
|
||||
height: 0 !important;
|
||||
}
|
||||
|
||||
.status-icon-enter-active,
|
||||
.status-icon-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.status-icon-enter-from,
|
||||
.status-icon-leave-to {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.status-icon-enter-to,
|
||||
.status-icon-leave-from {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user