You've already forked AstralRinth
forked from didirus/AstralRinth
feat: modrinth hosting - files tab refactor (#4912)
* feat: api-client module for content v0 * feat: delete unused components + modules + setting * feat: xhr uploading * feat: fs module -> api-client * feat: migrate files.vue to use tanstack * fix: mem leak + other issues * fix: build * feat: switch to monaco * fix: go back to using ace, but improve preloading + theme * fix: styling + dead attrs * feat: match figma * fix: padding * feat: files-new for ui page structure * feat: finalize files.vue * fix: lint * fix: qa * fix: dep * fix: lint * fix: lockfile merge * feat: icons on navtab * fix: surface alternating on table * fix: hover surface color --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
55
packages/ui/src/components/base/FloatingActionBar.vue
Normal file
55
packages/ui/src/components/base/FloatingActionBar.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
shown: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="floating-action-bar" appear>
|
||||
<div v-if="shown" class="floating-action-bar fixed w-full z-10 left-0 p-4 bottom-0">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[77rem] p-4"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.floating-action-bar {
|
||||
transition: bottom 0.25s ease-in-out;
|
||||
}
|
||||
|
||||
.floating-action-bar-enter-active {
|
||||
transition:
|
||||
transform 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
|
||||
opacity 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96);
|
||||
}
|
||||
|
||||
.floating-action-bar-leave-active {
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
opacity 0.25s ease;
|
||||
}
|
||||
|
||||
.floating-action-bar-enter-from {
|
||||
transform: scale(0.5) translateY(10rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.floating-action-bar-leave-to {
|
||||
transform: scale(0.96) translateY(0.25rem);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@media (any-hover: none) and (max-width: 640px) {
|
||||
.floating-action-bar {
|
||||
bottom: var(--size-mobile-navbar-height);
|
||||
}
|
||||
|
||||
.expanded-mobile-nav .floating-action-bar {
|
||||
bottom: var(--size-mobile-navbar-height-expanded);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -5,6 +5,7 @@ import { type Component, computed } from 'vue'
|
||||
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||
import { commonMessages } from '../../utils'
|
||||
import ButtonStyled from './ButtonStyled.vue'
|
||||
import FloatingActionBar from './FloatingActionBar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -53,64 +54,21 @@ function localizeIfPossible(message: MessageDescriptor | string) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition name="pop-in">
|
||||
<div v-if="shown" class="fixed w-full z-10 left-0 p-4 unsaved-changes-popup">
|
||||
<div
|
||||
class="flex items-center gap-2 rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[77rem] p-4"
|
||||
>
|
||||
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled v-if="canReset" type="transparent">
|
||||
<button :disabled="saving" @click="(e) => emit('reset', e)">
|
||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="saving" @click="(e) => emit('save', e)">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
<component :is="saveIcon" v-else />
|
||||
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
<FloatingActionBar :shown="shown">
|
||||
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled v-if="canReset" type="transparent">
|
||||
<button :disabled="saving" @click="(e) => emit('reset', e)">
|
||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="saving" @click="(e) => emit('save', e)">
|
||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||
<component :is="saveIcon" v-else />
|
||||
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</Transition>
|
||||
</FloatingActionBar>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pop-in-enter-active {
|
||||
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
|
||||
}
|
||||
|
||||
.pop-in-leave-active {
|
||||
transition: all 0.25s ease;
|
||||
}
|
||||
|
||||
.pop-in-enter-from {
|
||||
scale: 0.5;
|
||||
translate: 0 10rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pop-in-leave-to {
|
||||
scale: 0.96;
|
||||
translate: 0 0.25rem;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.unsaved-changes-popup {
|
||||
transition: bottom 0.25s ease-in-out;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
@media (any-hover: none) and (max-width: 640px) {
|
||||
.unsaved-changes-popup {
|
||||
bottom: var(--size-mobile-navbar-height);
|
||||
}
|
||||
|
||||
.expanded-mobile-nav .unsaved-changes-popup {
|
||||
bottom: var(--size-mobile-navbar-height-expanded);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,6 +26,7 @@ export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
|
||||
export { default as FileInput } from './FileInput.vue'
|
||||
export type { FilterBarOption } from './FilterBar.vue'
|
||||
export { default as FilterBar } from './FilterBar.vue'
|
||||
export { default as FloatingActionBar } from './FloatingActionBar.vue'
|
||||
export { default as HeadingLink } from './HeadingLink.vue'
|
||||
export { default as HorizontalRule } from './HorizontalRule.vue'
|
||||
export { default as IconSelect } from './IconSelect.vue'
|
||||
|
||||
232
packages/ui/src/components/servers/files/FileNavbar.vue
Normal file
232
packages/ui/src/components/servers/files/FileNavbar.vue
Normal file
@@ -0,0 +1,232 @@
|
||||
<template>
|
||||
<header
|
||||
class="flex select-none flex-col justify-between gap-2 sm:flex-row sm:items-center"
|
||||
aria-label="File navigation"
|
||||
>
|
||||
<nav
|
||||
aria-label="Breadcrumb navigation"
|
||||
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="'Back to home'"
|
||||
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">Home</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
|
||||
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
|
||||
<TransitionGroup
|
||||
name="breadcrumb"
|
||||
tag="span"
|
||||
class="relative flex min-w-0 flex-shrink items-center"
|
||||
>
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbs"
|
||||
:key="`${segment || index}-group`"
|
||||
class="relative flex min-w-0 flex-shrink items-center text-sm"
|
||||
>
|
||||
<div class="flex min-w-0 flex-shrink items-center">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer truncate 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-sm">
|
||||
<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">
|
||||
<div class="iconified-input w-full sm:w-[280px]">
|
||||
<SearchIcon aria-hidden="true" class="!text-secondary" />
|
||||
<input
|
||||
id="search-folder"
|
||||
:value="searchQuery"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
class="h-10 w-full rounded-[14px] border-0 bg-surface-4 text-sm"
|
||||
placeholder="Search files"
|
||||
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ButtonStyled type="outlined">
|
||||
<OverflowMenu
|
||||
:dropdown-id="`create-new-${baseId}`"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
aria-label="Create new..."
|
||||
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 },
|
||||
{ id: 'upload-zip', shown: false, action: () => $emit('uploadZip') },
|
||||
{ id: 'install-from-url', action: () => $emit('unzipFromUrl', false) },
|
||||
{ id: 'install-cf-pack', 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" /> New file </template>
|
||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||
<template #upload-zip>
|
||||
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
|
||||
</template>
|
||||
<template #install-from-url>
|
||||
<LinkIcon aria-hidden="true" /> Upload from .zip URL
|
||||
</template>
|
||||
<template #install-cf-pack>
|
||||
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isEditingImage" class="flex gap-2">
|
||||
<Button
|
||||
v-if="isLogFile"
|
||||
v-tooltip="'Share to mclo.gs'"
|
||||
icon-only
|
||||
transparent
|
||||
aria-label="Share to mclo.gs"
|
||||
@click="$emit('share')"
|
||||
>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
<ButtonStyled type="transparent">
|
||||
<TeleportOverflowMenu
|
||||
aria-label="Save file"
|
||||
:options="[
|
||||
{ id: 'save', action: () => $emit('save') },
|
||||
{ id: 'save-as', action: () => $emit('saveAs') },
|
||||
{ id: 'save-restart', action: () => $emit('saveRestart') },
|
||||
]"
|
||||
>
|
||||
<SaveIcon aria-hidden="true" />
|
||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
|
||||
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
|
||||
<template #save-restart>
|
||||
<RefreshCwIcon aria-hidden="true" />
|
||||
Save & restart
|
||||
</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
ChevronRightIcon,
|
||||
CurseForgeIcon,
|
||||
DropdownIcon,
|
||||
FileArchiveIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
SearchIcon,
|
||||
ShareIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { Button, ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import TeleportOverflowMenu from './explorer/TeleportOverflowMenu.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbs: string[]
|
||||
isEditing: boolean
|
||||
editingFileName?: string
|
||||
editingFilePath?: string
|
||||
isEditingImage?: boolean
|
||||
searchQuery: string
|
||||
baseId: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
navigate: [index: number]
|
||||
navigateHome: []
|
||||
prefetchHome: []
|
||||
'update:searchQuery': [value: string]
|
||||
create: [type: 'file' | 'directory']
|
||||
upload: []
|
||||
uploadZip: []
|
||||
unzipFromUrl: [cf: boolean]
|
||||
save: []
|
||||
saveAs: []
|
||||
saveRestart: []
|
||||
share: []
|
||||
}>()
|
||||
|
||||
const isLogFile = computed(() => {
|
||||
return props.editingFilePath?.startsWith('logs') || 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;
|
||||
}
|
||||
</style>
|
||||
236
packages/ui/src/components/servers/files/editor/FileEditor.vue
Normal file
236
packages/ui/src/components/servers/files/editor/FileEditor.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full flex-col gap-4">
|
||||
<div class="flex flex-col overflow-hidden rounded-[20px] shadow-md">
|
||||
<div class="h-full w-full flex-grow">
|
||||
<component
|
||||
:is="props.editorComponent"
|
||||
v-if="!isEditingImage && props.editorComponent"
|
||||
v-model:value="fileContent"
|
||||
:lang="editorLanguage"
|
||||
theme="modrinth"
|
||||
:print-margin="false"
|
||||
style="height: 750px; font-size: 1rem"
|
||||
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 h-[750px] items-center justify-center rounded-[20px] bg-bg-raised"
|
||||
>
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import {
|
||||
getEditorLanguage,
|
||||
getFileExtension,
|
||||
injectModrinthClient,
|
||||
injectModrinthServerContext,
|
||||
injectNotificationManager,
|
||||
isImageFile,
|
||||
} from '@modrinth/ui'
|
||||
import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { type Component, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import FileImageViewer from './FileImageViewer.vue'
|
||||
|
||||
interface MclogsResponse {
|
||||
success: boolean
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
file: { name: string; type: string; path: string } | null
|
||||
editorComponent: Component | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const notifications = injectNotificationManager()
|
||||
const { addNotification } = notifications
|
||||
const client = injectModrinthClient()
|
||||
const serverContext = injectModrinthServerContext()
|
||||
const { serverId } = serverContext
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
||||
|
||||
const fileContent = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const imagePreview = ref<Blob | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const editorInstance = ref<unknown>(null)
|
||||
|
||||
const editorLanguage = computed(() => {
|
||||
const ext = getFileExtension(props.file?.name ?? '')
|
||||
return getEditorLanguage(ext)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
async (newFile) => {
|
||||
if (newFile) {
|
||||
await loadFileContent(newFile)
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadFileContent(file: { name: string; type: string; path: string }) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
window.scrollTo(0, 0)
|
||||
const extension = getFileExtension(file.name)
|
||||
|
||||
if (file.type === 'file' && isImageFile(extension)) {
|
||||
const content = await client.kyros.files_v0.downloadFile(file.path)
|
||||
isEditingImage.value = true
|
||||
imagePreview.value = content
|
||||
} else {
|
||||
isEditingImage.value = false
|
||||
const cachedContent = queryClient.getQueryData<string>(['file-content', serverId, file.path])
|
||||
if (cachedContent) {
|
||||
fileContent.value = cachedContent
|
||||
} else {
|
||||
const content = await client.kyros.files_v0.downloadFile(file.path)
|
||||
fileContent.value = await content.text()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file content:', error)
|
||||
addNotification({
|
||||
title: 'Failed to open file',
|
||||
text: 'Could not load file contents.',
|
||||
type: 'error',
|
||||
})
|
||||
emit('close')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
fileContent.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 = true) {
|
||||
if (!props.file) return
|
||||
|
||||
try {
|
||||
await client.kyros.files_v0.updateFile(props.file.path, fileContent.value)
|
||||
|
||||
if (exit) {
|
||||
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||
emit('close')
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: 'File saved',
|
||||
text: 'Your file has been saved.',
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving file content:', error)
|
||||
addNotification({ title: 'Save failed', text: 'Could not save the file.', type: 'error' })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAndRestart() {
|
||||
await saveFileContent(false)
|
||||
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||
|
||||
addNotification({
|
||||
title: 'Server restarted',
|
||||
text: 'Your server has been restarted.',
|
||||
type: 'success',
|
||||
})
|
||||
|
||||
emit('close')
|
||||
}
|
||||
|
||||
async function shareToMclogs() {
|
||||
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: 'Log URL copied',
|
||||
text: 'Your log file URL has been copied to your clipboard.',
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing file:', error)
|
||||
addNotification({
|
||||
title: 'Failed to share file',
|
||||
text: 'Could not upload to mclo.gs.',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (modulesLoaded) {
|
||||
await modulesLoaded
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
editorInstance.value = null
|
||||
resetState()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
saveFileContent,
|
||||
saveAndRestart,
|
||||
shareToMclogs,
|
||||
close,
|
||||
isEditingImage,
|
||||
fileContent,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||
<div
|
||||
ref="container"
|
||||
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-[20px] bg-black active:cursor-grabbing"
|
||||
@mousedown="startPan"
|
||||
@mousemove="handlePan"
|
||||
@mouseup="stopPan"
|
||||
@mouseleave="stopPan"
|
||||
@wheel.prevent="handleWheel"
|
||||
>
|
||||
<div v-if="state.isLoading" />
|
||||
<div
|
||||
v-if="state.hasError"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||
>
|
||||
<TriangleAlertIcon class="size-8 text-red" />
|
||||
<p class="m-0">{{ state.errorMessage || 'Invalid or empty image file.' }}</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imageRef"
|
||||
:src="imageObjectUrl"
|
||||
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
||||
:style="imageStyle"
|
||||
alt="Viewed image"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="!state.hasError"
|
||||
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
|
||||
>
|
||||
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
|
||||
<button v-tooltip="'Zoom in'">
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
|
||||
<button v-tooltip="'Zoom out'">
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent" @click="reset">
|
||||
<button>
|
||||
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
|
||||
<span class="ml-4 text-sm text-blue">Reset</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlertIcon, ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const ZOOM_MIN = 0.1
|
||||
const ZOOM_MAX = 5
|
||||
const ZOOM_IN_FACTOR = 1.2
|
||||
const ZOOM_OUT_FACTOR = 0.8
|
||||
const INITIAL_SCALE = 0.5
|
||||
const MAX_IMAGE_DIMENSION = 4096
|
||||
|
||||
const props = defineProps<{
|
||||
imageBlob: Blob
|
||||
}>()
|
||||
|
||||
const state = ref({
|
||||
scale: INITIAL_SCALE,
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
isPanning: false,
|
||||
startX: 0,
|
||||
startY: 0,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
})
|
||||
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
const imageObjectUrl = ref('')
|
||||
const rafId = ref(0)
|
||||
|
||||
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
|
||||
|
||||
const imageStyle = computed(() => ({
|
||||
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
|
||||
transition: state.value.isPanning ? 'none' : 'transform 0.3s ease-out',
|
||||
}))
|
||||
|
||||
const validateImageDimensions = (img: HTMLImageElement): boolean => {
|
||||
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const updateImageUrl = (blob: Blob) => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
imageObjectUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
|
||||
state.value.isLoading = false
|
||||
return
|
||||
}
|
||||
state.value.isLoading = false
|
||||
reset()
|
||||
}
|
||||
|
||||
const handleImageError = () => {
|
||||
state.value.isLoading = false
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = 'Failed to load image'
|
||||
}
|
||||
|
||||
const zoom = (factor: number) => {
|
||||
const newScale = state.value.scale * factor
|
||||
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX))
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
state.value.scale = INITIAL_SCALE
|
||||
state.value.translateX = 0
|
||||
state.value.translateY = 0
|
||||
}
|
||||
|
||||
const startPan = (e: MouseEvent) => {
|
||||
state.value.isPanning = true
|
||||
state.value.startX = e.clientX - state.value.translateX
|
||||
state.value.startY = e.clientY - state.value.translateY
|
||||
}
|
||||
|
||||
const handlePan = (e: MouseEvent) => {
|
||||
if (!state.value.isPanning) return
|
||||
cancelAnimationFrame(rafId.value)
|
||||
rafId.value = requestAnimationFrame(() => {
|
||||
state.value.translateX = e.clientX - state.value.startX
|
||||
state.value.translateY = e.clientY - state.value.startY
|
||||
})
|
||||
}
|
||||
|
||||
const stopPan = () => {
|
||||
state.value.isPanning = false
|
||||
}
|
||||
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
const delta = e.deltaY * -0.001
|
||||
const factor = 1 + delta
|
||||
zoom(factor)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (!newBlob) return
|
||||
state.value.isLoading = true
|
||||
state.value.hasError = false
|
||||
updateImageUrl(newBlob)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) updateImageUrl(props.imageBlob)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
cancelAnimationFrame(rafId.value)
|
||||
})
|
||||
</script>
|
||||
2
packages/ui/src/components/servers/files/editor/index.ts
Normal file
2
packages/ui/src/components/servers/files/editor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as FileEditor } from './FileEditor.vue'
|
||||
export { default as FileImageViewer } from './FileImageViewer.vue'
|
||||
346
packages/ui/src/components/servers/files/explorer/FileItem.vue
Normal file
346
packages/ui/src/components/servers/files/explorer/FileItem.vue
Normal file
@@ -0,0 +1,346 @@
|
||||
<template>
|
||||
<li
|
||||
role="button"
|
||||
:class="[
|
||||
containerClasses,
|
||||
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
|
||||
isDragging ? 'opacity-50' : '',
|
||||
]"
|
||||
tabindex="0"
|
||||
draggable="true"
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@dragstart="handleDragStart"
|
||||
@dragend="handleDragEnd"
|
||||
@dragenter.prevent="handleDragEnter"
|
||||
@dragover.prevent="handleDragOver"
|
||||
@dragleave.prevent="handleDragLeave"
|
||||
@drop.prevent="handleDrop"
|
||||
>
|
||||
<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" />
|
||||
</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 md:gap-12">
|
||||
<span class="hidden w-[100px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedSize }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedCreationDate }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||
{{ formattedModifiedDate }}
|
||||
</span>
|
||||
<ButtonStyled circular type="transparent">
|
||||
<TeleportOverflowMenu :options="menuOptions">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #extract><PackageOpenIcon /> Extract</template>
|
||||
<template #rename><EditIcon /> Rename</template>
|
||||
<template #move><RightArrowIcon /> Move</template>
|
||||
<template #download><DownloadIcon /> Download</template>
|
||||
<template #delete><TrashIcon /> Delete</template>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderCogIcon,
|
||||
FolderOpenIcon,
|
||||
GlobeIcon,
|
||||
MoreHorizontalIcon,
|
||||
PackageOpenIcon,
|
||||
PaletteIcon,
|
||||
RightArrowIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
getFileExtension,
|
||||
getFileExtensionIcon,
|
||||
isEditableFile as isEditableFileExt,
|
||||
isImageFile,
|
||||
} from '@modrinth/ui'
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||
|
||||
interface FileItemProps {
|
||||
name: string
|
||||
type: 'directory' | 'file'
|
||||
size?: number
|
||||
count?: number
|
||||
modified: number
|
||||
created: number
|
||||
path: string
|
||||
index: number
|
||||
isLast: boolean
|
||||
selected: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<FileItemProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
rename: [item: { name: string; type: string; path: string }]
|
||||
move: [item: { name: string; type: string; path: string }]
|
||||
download: [item: { name: string; type: string; path: string }]
|
||||
delete: [item: { name: string; type: string; path: string }]
|
||||
edit: [item: { name: string; type: string; path: string }]
|
||||
extract: [item: { name: string; type: string; path: string }]
|
||||
hover: [item: { name: string; type: string; path: string }]
|
||||
moveDirectTo: [item: { name: string; type: string; path: string; destination: string }]
|
||||
contextmenu: [x: number, y: number]
|
||||
'toggle-select': []
|
||||
}>()
|
||||
|
||||
const isDragOver = ref(false)
|
||||
const isDragging = ref(false)
|
||||
|
||||
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
|
||||
|
||||
const route = shallowRef(useRoute())
|
||||
const router = useRouter()
|
||||
|
||||
const containerClasses = computed(() => [
|
||||
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-3 px-4 py-3 focus:!outline-none',
|
||||
props.selected ? 'bg-surface-3' : props.index % 2 === 0 ? 'bg-surface-2' : 'file-row-alt',
|
||||
props.isLast ? 'rounded-b-[20px] border-b' : '',
|
||||
isEditableFile.value ? 'cursor-pointer' : props.type === 'directory' ? 'cursor-pointer' : '',
|
||||
isDragOver.value ? '!bg-brand-highlight' : '',
|
||||
'transition-colors duration-100 hover:!bg-surface-4 hover:!brightness-100 focus:!bg-surface-4 focus:!brightness-100',
|
||||
])
|
||||
|
||||
const fileExtension = computed(() => getFileExtension(props.name))
|
||||
|
||||
const isZip = computed(() => fileExtension.value === 'zip')
|
||||
|
||||
const menuOptions = computed(() => [
|
||||
{
|
||||
id: 'extract',
|
||||
shown: isZip.value,
|
||||
action: () => emit('extract', { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
divider: true,
|
||||
shown: isZip.value,
|
||||
},
|
||||
{
|
||||
id: 'rename',
|
||||
action: () => emit('rename', { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
action: () => emit('move', { name: props.name, type: props.type, path: props.path }),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
action: () => emit('download', { name: props.name, type: props.type, path: props.path }),
|
||||
shown: props.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
action: () => emit('delete', { name: props.name, type: props.type, path: props.path }),
|
||||
color: 'red' as const,
|
||||
},
|
||||
])
|
||||
|
||||
const iconComponent = computed(() => {
|
||||
if (props.type === 'directory') {
|
||||
if (props.name === 'config') return FolderCogIcon
|
||||
if (props.name === 'world') return GlobeIcon
|
||||
if (props.name === 'resourcepacks') return PaletteIcon
|
||||
return FolderOpenIcon
|
||||
}
|
||||
|
||||
return getFileExtensionIcon(fileExtension.value)
|
||||
})
|
||||
|
||||
const formattedModifiedDate = computed(() => {
|
||||
const date = new Date(props.modified * 1000)
|
||||
return `${date.toLocaleDateString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: '2-digit',
|
||||
})}, ${date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
})}`
|
||||
})
|
||||
|
||||
const formattedCreationDate = computed(() => {
|
||||
const date = new Date(props.created * 1000)
|
||||
return `${date.toLocaleDateString('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: '2-digit',
|
||||
})}, ${date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
hour12: true,
|
||||
})}`
|
||||
})
|
||||
|
||||
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 `${props.count} ${props.count === 1 ? 'item' : 'items'}`
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
function navigateToFolder() {
|
||||
const currentPath = route.value.query.path?.toString() || ''
|
||||
const newPath = currentPath.endsWith('/')
|
||||
? `${currentPath}${props.name}`
|
||||
: `${currentPath}/${props.name}`
|
||||
router.push({ query: { path: newPath, page: 1 } })
|
||||
}
|
||||
|
||||
const isNavigating = ref(false)
|
||||
|
||||
function selectItem() {
|
||||
if (isNavigating.value) return
|
||||
isNavigating.value = true
|
||||
|
||||
if (props.type === 'directory') {
|
||||
navigateToFolder()
|
||||
} else if (props.type === 'file' && isEditableFile.value) {
|
||||
emit('edit', { name: props.name, type: props.type, path: props.path })
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigating.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function handleDragStart(event: DragEvent) {
|
||||
if (!event.dataTransfer) return
|
||||
isDragging.value = true
|
||||
|
||||
const dragGhost = document.createElement('div')
|
||||
dragGhost.className =
|
||||
'fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none'
|
||||
|
||||
const nameSpan = document.createElement('span')
|
||||
nameSpan.className = 'font-bold truncate text-contrast'
|
||||
nameSpan.textContent = props.name
|
||||
|
||||
dragGhost.appendChild(nameSpan)
|
||||
document.body.appendChild(dragGhost)
|
||||
|
||||
event.dataTransfer.setDragImage(dragGhost, 0, 0)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
document.body.removeChild(dragGhost)
|
||||
})
|
||||
|
||||
event.dataTransfer.setData(
|
||||
'application/modrinth-file-move',
|
||||
JSON.stringify({
|
||||
name: props.name,
|
||||
type: props.type,
|
||||
path: props.path,
|
||||
}),
|
||||
)
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
}
|
||||
|
||||
function isChildPath(parentPath: string, childPath: string) {
|
||||
return childPath.startsWith(parentPath + '/')
|
||||
}
|
||||
|
||||
function handleDragEnd() {
|
||||
isDragging.value = false
|
||||
}
|
||||
|
||||
function handleDragEnter() {
|
||||
if (props.type !== 'directory') return
|
||||
isDragOver.value = true
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragEvent) {
|
||||
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||
event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
function handleDragLeave() {
|
||||
isDragOver.value = false
|
||||
}
|
||||
|
||||
function handleDrop(event: DragEvent) {
|
||||
isDragOver.value = false
|
||||
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||
|
||||
try {
|
||||
const dragData = JSON.parse(event.dataTransfer.getData('application/modrinth-file-move'))
|
||||
|
||||
if (dragData.path === props.path) return
|
||||
|
||||
if (dragData.type === 'directory' && isChildPath(dragData.path, props.path)) {
|
||||
console.error('Cannot move a folder into its own subfolder')
|
||||
return
|
||||
}
|
||||
|
||||
emit('moveDirectTo', {
|
||||
name: dragData.name,
|
||||
type: dragData.type,
|
||||
path: dragData.path,
|
||||
destination: props.path,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error handling file drop:', error)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.file-row-alt {
|
||||
background: color-mix(in srgb, var(--surface-2), black 10%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="sticky top-0 z-20 flex w-full select-none flex-row items-center justify-between border border-b-0 border-solid border-surface-3 bg-surface-3 p-4 text-sm font-medium transition-[border-radius] duration-100 before:pointer-events-none before:absolute before:inset-x-0 before:-top-5 before:h-5 before:bg-surface-3"
|
||||
:class="isStuck ? 'rounded-none' : '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 bg-transparent text-contrast hover:text-brand"
|
||||
@click="$emit('sort', 'name')"
|
||||
>
|
||||
<span>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 md:gap-12">
|
||||
<button
|
||||
class="hidden w-[100px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'size')"
|
||||
>
|
||||
<span class="ml-2">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 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'created')"
|
||||
>
|
||||
<span class="ml-2">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 bg-transparent text-primary hover:text-brand md:flex"
|
||||
@click="$emit('sort', 'modified')"
|
||||
>
|
||||
<span class="ml-2">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="w-[51px] text-right text-primary">Actions</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
import { Checkbox } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
sortField: string
|
||||
sortDesc: boolean
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
isStuck: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
sort: [field: string]
|
||||
'toggle-all': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,40 @@
|
||||
<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" />
|
||||
Try again
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('home')">
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
Go to home folder
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, HomeIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
message: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
refetch: []
|
||||
home: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div ref="listContainer" class="relative w-full">
|
||||
<div
|
||||
:style="{
|
||||
position: 'relative',
|
||||
minHeight: `${totalHeight}px`,
|
||||
}"
|
||||
>
|
||||
<ul
|
||||
class="list-none"
|
||||
:style="{
|
||||
position: 'absolute',
|
||||
top: `${visibleTop}px`,
|
||||
width: '100%',
|
||||
margin: 0,
|
||||
padding: 0,
|
||||
}"
|
||||
>
|
||||
<FileItem
|
||||
v-for="(item, idx) in visibleItems"
|
||||
:key="item.path"
|
||||
:count="item.count"
|
||||
:created="item.created"
|
||||
:modified="item.modified"
|
||||
:name="item.name"
|
||||
:path="item.path"
|
||||
:type="item.type"
|
||||
:size="item.size"
|
||||
:index="visibleRange.start + idx"
|
||||
:is-last="visibleRange.start + idx === props.items.length - 1"
|
||||
:selected="selectedItems.has(item.path)"
|
||||
@delete="$emit('delete', item)"
|
||||
@rename="$emit('rename', item)"
|
||||
@extract="$emit('extract', item)"
|
||||
@download="$emit('download', item)"
|
||||
@move="$emit('move', item)"
|
||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||
@edit="$emit('edit', item)"
|
||||
@hover="$emit('hover', item)"
|
||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||
@toggle-select="$emit('toggle-select', item.path)"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Kyros } from '@modrinth/api-client'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import FileItem from './FileItem.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: Kyros.Files.v0.DirectoryItem[]
|
||||
selectedItems: Set<string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: [item: Kyros.Files.v0.DirectoryItem]
|
||||
rename: [item: Kyros.Files.v0.DirectoryItem]
|
||||
download: [item: Kyros.Files.v0.DirectoryItem]
|
||||
move: [item: Kyros.Files.v0.DirectoryItem]
|
||||
edit: [item: Kyros.Files.v0.DirectoryItem]
|
||||
moveDirectTo: [item: { name: string; type: string; path: string; destination: string }]
|
||||
extract: [item: Kyros.Files.v0.DirectoryItem]
|
||||
hover: [item: Kyros.Files.v0.DirectoryItem]
|
||||
contextmenu: [item: Kyros.Files.v0.DirectoryItem, x: number, y: number]
|
||||
loadMore: []
|
||||
'toggle-select': [path: string]
|
||||
}>()
|
||||
|
||||
const ITEM_HEIGHT = 61
|
||||
const BUFFER_SIZE = 5
|
||||
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const windowScrollY = ref(0)
|
||||
const windowHeight = ref(0)
|
||||
|
||||
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT)
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
if (!listContainer.value) return { start: 0, end: 0 }
|
||||
|
||||
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
|
||||
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop)
|
||||
|
||||
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT)
|
||||
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - BUFFER_SIZE),
|
||||
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
|
||||
}
|
||||
})
|
||||
|
||||
const visibleTop = computed(() => {
|
||||
return visibleRange.value.start * ITEM_HEIGHT
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
|
||||
})
|
||||
|
||||
function handleScroll() {
|
||||
windowScrollY.value = window.scrollY
|
||||
|
||||
if (!listContainer.value) return
|
||||
|
||||
const containerBottom = listContainer.value.getBoundingClientRect().bottom
|
||||
const remainingScroll = containerBottom - window.innerHeight
|
||||
|
||||
if (remainingScroll < windowHeight.value * 0.2) {
|
||||
emit('loadMore')
|
||||
}
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
windowHeight.value = window.innerHeight
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
windowHeight.value = window.innerHeight
|
||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
handleScroll()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<div data-pyro-telepopover-wrapper class="relative">
|
||||
<button
|
||||
ref="triggerRef"
|
||||
class="teleport-overflow-menu-trigger"
|
||||
:aria-expanded="isOpen"
|
||||
:aria-haspopup="true"
|
||||
@mousedown="handleMouseDown"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<slot></slot>
|
||||
</button>
|
||||
<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="isOpen"
|
||||
ref="menuRef"
|
||||
data-pyro-telepopover-root
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
|
||||
:style="menuStyle"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<template
|
||||
v-for="(option, index) in filteredOptions"
|
||||
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||
>
|
||||
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
||||
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||
<button
|
||||
v-if="typeof option.action === 'function'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</button>
|
||||
<AutoLink
|
||||
v-else-if="typeof option.action === 'string'"
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el) menuItemsRef[index] = el as HTMLElement
|
||||
}
|
||||
"
|
||||
:to="option.action"
|
||||
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||
:aria-selected="index === selectedIndex"
|
||||
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||
@click="handleItemClick(option, index)"
|
||||
@focus="selectedIndex = index"
|
||||
@mouseover="handleMouseOver(index)"
|
||||
>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</AutoLink>
|
||||
<span v-else>
|
||||
<slot :name="option.id">{{ option.id }}</slot>
|
||||
</span>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { AutoLink, ButtonStyled } from '@modrinth/ui'
|
||||
import { onClickOutside, useElementHover } from '@vueuse/core'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
interface Option {
|
||||
id: string
|
||||
action?: (() => void) | string
|
||||
shown?: boolean
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
divider: true
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
type Item = Option | Divider
|
||||
|
||||
function isDivider(item: Item): item is Divider {
|
||||
return (item as Divider).divider
|
||||
}
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
options: Item[]
|
||||
hoverable?: boolean
|
||||
}>(),
|
||||
{
|
||||
hoverable: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [option: Option]
|
||||
}>()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const selectedIndex = ref(-1)
|
||||
const menuRef = ref<HTMLElement | null>(null)
|
||||
const triggerRef = ref<HTMLElement | null>(null)
|
||||
const isMouseDown = ref(false)
|
||||
const typeAheadBuffer = ref('')
|
||||
const typeAheadTimeout = ref<number | null>(null)
|
||||
const menuItemsRef = ref<HTMLElement[]>([])
|
||||
|
||||
const hoveringTrigger = useElementHover(triggerRef)
|
||||
const hoveringMenu = useElementHover(menuRef)
|
||||
|
||||
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
|
||||
|
||||
const menuStyle = ref({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
})
|
||||
|
||||
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
|
||||
|
||||
const calculateMenuPosition = () => {
|
||||
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
|
||||
|
||||
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||
const menuRect = menuRef.value.getBoundingClientRect()
|
||||
const menuWidth = menuRect.width
|
||||
const menuHeight = menuRect.height
|
||||
const margin = 8
|
||||
|
||||
let top: number
|
||||
let left: number
|
||||
|
||||
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
|
||||
top = triggerRect.bottom + margin
|
||||
} else if (triggerRect.top - menuHeight - margin >= 0) {
|
||||
top = triggerRect.top - menuHeight - margin
|
||||
} else {
|
||||
top = Math.max(margin, window.innerHeight - menuHeight - margin)
|
||||
}
|
||||
|
||||
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
|
||||
left = triggerRect.left
|
||||
} else if (triggerRect.right - menuWidth - margin >= 0) {
|
||||
left = triggerRect.right - menuWidth
|
||||
} else {
|
||||
left = Math.max(margin, window.innerWidth - menuWidth - margin)
|
||||
}
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
left: `${left}px`,
|
||||
}
|
||||
}
|
||||
|
||||
const toggleMenu = (event: MouseEvent) => {
|
||||
event.stopPropagation()
|
||||
if (!props.hoverable) {
|
||||
if (isOpen.value) {
|
||||
closeMenu()
|
||||
} else {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openMenu = () => {
|
||||
isOpen.value = true
|
||||
disableBodyScroll()
|
||||
nextTick(() => {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
document.addEventListener('mousemove', handleMouseMove)
|
||||
focusFirstMenuItem()
|
||||
})
|
||||
}
|
||||
|
||||
const closeMenu = () => {
|
||||
isOpen.value = false
|
||||
selectedIndex.value = -1
|
||||
enableBodyScroll()
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
}
|
||||
|
||||
const selectOption = (option: Option) => {
|
||||
emit('select', option)
|
||||
if (typeof option.action === 'function') {
|
||||
option.action()
|
||||
}
|
||||
closeMenu()
|
||||
}
|
||||
|
||||
const handleMouseDown = (event: MouseEvent) => {
|
||||
event.preventDefault()
|
||||
isMouseDown.value = true
|
||||
}
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (props.hoverable) {
|
||||
openMenu()
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
if (props.hoverable) {
|
||||
setTimeout(() => {
|
||||
if (!hovering.value) {
|
||||
closeMenu()
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMouseMove = (event: MouseEvent) => {
|
||||
if (!isOpen.value || !isMouseDown.value) return
|
||||
|
||||
const menuRect = menuRef.value?.getBoundingClientRect()
|
||||
if (!menuRect) return
|
||||
|
||||
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
|
||||
if (!menuItems) return
|
||||
|
||||
for (let i = 0; i < menuItems.length; i++) {
|
||||
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
|
||||
if (
|
||||
event.clientX >= itemRect.left &&
|
||||
event.clientX <= itemRect.right &&
|
||||
event.clientY >= itemRect.top &&
|
||||
event.clientY <= itemRect.bottom
|
||||
) {
|
||||
selectedIndex.value = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleItemClick = (option: Option, index: number) => {
|
||||
selectedIndex.value = index
|
||||
selectOption(option)
|
||||
}
|
||||
|
||||
const handleMouseOver = (index: number) => {
|
||||
selectedIndex.value = index
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
|
||||
const disableBodyScroll = () => {
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
|
||||
const enableBodyScroll = () => {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
|
||||
const focusFirstMenuItem = () => {
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
menuItemsRef.value[0]?.focus?.()
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!isOpen.value) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
openMenu()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
break
|
||||
case 'Home':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = 0
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
case 'End':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
selectedIndex.value = filteredOptions.value.length - 1
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
case 'Enter':
|
||||
case ' ':
|
||||
event.preventDefault()
|
||||
if (selectedIndex.value >= 0) {
|
||||
const option = filteredOptions.value[selectedIndex.value]
|
||||
if (isDivider(option)) break
|
||||
selectOption(option)
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
closeMenu()
|
||||
triggerRef.value?.focus?.()
|
||||
break
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
if (menuItemsRef.value.length > 0) {
|
||||
if (event.shiftKey) {
|
||||
selectedIndex.value =
|
||||
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||
} else {
|
||||
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||
}
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
break
|
||||
default:
|
||||
if (event.key.length === 1) {
|
||||
typeAheadBuffer.value += event.key.toLowerCase()
|
||||
const matchIndex = filteredOptions.value.findIndex(
|
||||
(option) =>
|
||||
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||
)
|
||||
if (matchIndex !== -1) {
|
||||
selectedIndex.value = matchIndex
|
||||
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||
}
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
typeAheadTimeout.value = setTimeout(() => {
|
||||
typeAheadBuffer.value = ''
|
||||
}, 1000) as unknown as number
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const handleResizeOrScroll = () => {
|
||||
if (isOpen.value) {
|
||||
menuStyle.value = calculateMenuPosition()
|
||||
}
|
||||
}
|
||||
|
||||
const throttle = <T extends unknown[]>(
|
||||
func: (...args: T) => void,
|
||||
limit: number,
|
||||
): ((...args: T) => void) => {
|
||||
let inThrottle: boolean
|
||||
return function (...args: T) {
|
||||
if (!inThrottle) {
|
||||
func(...args)
|
||||
inThrottle = true
|
||||
setTimeout(() => (inThrottle = false), limit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
|
||||
|
||||
onMounted(() => {
|
||||
triggerRef.value?.addEventListener('keydown', handleKeydown)
|
||||
window.addEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.addEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
triggerRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
window.removeEventListener('resize', throttledHandleResizeOrScroll)
|
||||
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
|
||||
document.removeEventListener('mousemove', handleMouseMove)
|
||||
if (typeAheadTimeout.value) {
|
||||
clearTimeout(typeAheadTimeout.value)
|
||||
}
|
||||
enableBodyScroll()
|
||||
})
|
||||
|
||||
watch(isOpen, (newValue) => {
|
||||
if (newValue) {
|
||||
nextTick(() => {
|
||||
menuRef.value?.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
} else {
|
||||
menuRef.value?.removeEventListener('keydown', handleKeydown)
|
||||
}
|
||||
})
|
||||
|
||||
onClickOutside(menuRef, (event) => {
|
||||
if (!triggerRef.value?.contains(event.target as Node)) {
|
||||
closeMenu()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as FileItem } from './FileItem.vue'
|
||||
export { default as FileLabelBar } from './FileLabelBar.vue'
|
||||
export { default as FileManagerError } from './FileManagerError.vue'
|
||||
export { default as FileVirtualList } from './FileVirtualList.vue'
|
||||
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
|
||||
5
packages/ui/src/components/servers/files/index.ts
Normal file
5
packages/ui/src/components/servers/files/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './editor'
|
||||
export * from './explorer'
|
||||
export { default as FileNavbar } from './FileNavbar.vue'
|
||||
export * from './modals'
|
||||
export * from './upload'
|
||||
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Creating a ${displayType}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="createInput"
|
||||
v-model="itemName"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
|
||||
required
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
Create {{ displayType }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'file' | 'directory'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [name: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const displayType = computed(() => (props.type === 'directory' ? 'folder' : props.type))
|
||||
const createInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return 'Name is required.'
|
||||
}
|
||||
if (props.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
|
||||
}
|
||||
}
|
||||
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,78 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="danger" :header="`Deleting ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div
|
||||
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-brand-red bg-bg-red p-6 shadow-md"
|
||||
>
|
||||
<div
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full bg-highlight-red p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||
>
|
||||
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
|
||||
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
|
||||
<span
|
||||
v-if="item?.type === 'directory'"
|
||||
class="text-xs text-secondary group-hover:text-primary"
|
||||
>
|
||||
{{ item?.count }} items
|
||||
</span>
|
||||
<span v-else class="text-xs text-secondary group-hover:text-primary">
|
||||
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="red">
|
||||
<button type="submit">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
Delete {{ item?.type }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
item: {
|
||||
name: string
|
||||
type: string
|
||||
count?: number
|
||||
size?: number
|
||||
} | 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,82 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Moving ${item?.name}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<input
|
||||
ref="destinationInput"
|
||||
v-model="destination"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
placeholder="e.g. /mods/modname"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-nowrap">
|
||||
New location:
|
||||
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
|
||||
<span class="text-secondary">/root</span>{{ newpath }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button type="submit">
|
||||
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||
Move
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ArrowBigUpDashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const props = defineProps<{
|
||||
item: { name: string } | null
|
||||
currentPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
move: [destination: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const destination = ref('')
|
||||
const newpath = computed(() => {
|
||||
const path = destination.value.replace('//', '/')
|
||||
return path.startsWith('/') ? path : `/${path}`
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('move', newpath.value)
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
destination.value = props.currentPath
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
destinationInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,94 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
|
||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="font-semibold text-contrast">Name</div>
|
||||
<input
|
||||
ref="renameInput"
|
||||
v-model="itemName"
|
||||
autofocus
|
||||
type="text"
|
||||
class="bg-bg-input w-full rounded-lg p-4"
|
||||
required
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||
</div>
|
||||
<div class="flex justify-start gap-4">
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error" type="submit">
|
||||
<EditIcon class="h-5 w-5" />
|
||||
Rename
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button type="button" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
Cancel
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</form>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, XIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
item: { name: string; type: string } | 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 'Name is required.'
|
||||
}
|
||||
if (props.item?.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<ConfirmModal
|
||||
ref="modal"
|
||||
title="Do you want to overwrite these conflicting files?"
|
||||
:proceed-label="`Overwrite`"
|
||||
:proceed-icon="CheckIcon"
|
||||
@proceed="proceed"
|
||||
>
|
||||
<div class="flex max-w-[30rem] flex-col gap-4">
|
||||
<p class="m-0 font-semibold leading-normal">
|
||||
<template v-if="hasMany">
|
||||
Over 100 files will be overwritten if you proceed with extraction; here is just some of
|
||||
them:
|
||||
</template>
|
||||
<template v-else>
|
||||
The following {{ files.length }} files already exist on your server, and will be
|
||||
overwritten if you proceed with extraction:
|
||||
</template>
|
||||
</p>
|
||||
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
|
||||
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
|
||||
<XIcon class="shrink-0 text-red" /> {{ file }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||
import { ConfirmModal } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
const path = ref('')
|
||||
const files = ref<string[]>([])
|
||||
|
||||
const emit = defineEmits<{
|
||||
proceed: [path: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof ConfirmModal>>()
|
||||
|
||||
const hasMany = computed(() => files.value.length > 100)
|
||||
|
||||
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||
path.value = zipPath
|
||||
files.value = conflictingFiles
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const proceed = () => {
|
||||
emit('proceed', path.value)
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
5
packages/ui/src/components/servers/files/modals/index.ts
Normal file
5
packages/ui/src/components/servers/files/modals/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as FileCreateItemModal } from './FileCreateItemModal.vue'
|
||||
export { default as FileDeleteItemModal } from './FileDeleteItemModal.vue'
|
||||
export { default as FileMoveItemModal } from './FileMoveItemModal.vue'
|
||||
export { default as FileRenameItemModal } from './FileRenameItemModal.vue'
|
||||
export { default as FileUploadConflictModal } from './FileUploadConflictModal.vue'
|
||||
@@ -0,0 +1,75 @@
|
||||
<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">
|
||||
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
filesDropped: [files: File[]]
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
overlayClass?: string
|
||||
type?: string
|
||||
}>()
|
||||
|
||||
const isDragging = ref(false)
|
||||
const dragCounter = ref(0)
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
event.preventDefault()
|
||||
if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
|
||||
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 isInternalMove = event.dataTransfer?.types.includes('application/modrinth-file-move')
|
||||
if (isInternalMove) return
|
||||
|
||||
const files = event.dataTransfer?.files
|
||||
if (files) {
|
||||
emit('filesDropped', Array.from(files))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,333 @@
|
||||
<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">
|
||||
{{ props.fileType ? props.fileType : 'File' }} uploads
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}</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>Done</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-file-exists'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-generic'">
|
||||
<span class="text-red"
|
||||
>Failed - {{ item.error?.message || 'An unexpected error occured.' }}</span
|
||||
>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</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>Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">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 { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
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 = 'Upload had incorrect file type'
|
||||
|
||||
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: 'Upload failed',
|
||||
text: `Failed to upload ${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>
|
||||
2
packages/ui/src/components/servers/files/upload/index.ts
Normal file
2
packages/ui/src/components/servers/files/upload/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as FileUploadDragAndDrop } from './FileUploadDragAndDrop.vue'
|
||||
export { default as FileUploadDropdown } from './FileUploadDropdown.vue'
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './backups'
|
||||
export * from './files'
|
||||
export * from './icons'
|
||||
export * from './labels'
|
||||
export * from './marketing'
|
||||
|
||||
@@ -350,7 +350,6 @@ const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
|
||||
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
|
||||
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
|
||||
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
|
||||
// const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>()
|
||||
|
||||
const backupRestoreDisabled = computed(() => {
|
||||
if (props.isServerRunning) {
|
||||
@@ -400,10 +399,6 @@ const showCreateModel = () => {
|
||||
createBackupModal.value?.show()
|
||||
}
|
||||
|
||||
// const showbackupSettingsModal = () => {
|
||||
// backupSettingsModal.value?.show()
|
||||
// }
|
||||
|
||||
function triggerDownloadAnimation() {
|
||||
overTheTopDownloadAnimation.value = true
|
||||
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
|
||||
|
||||
1320
packages/ui/src/pages/hosting/manage/files.vue
Normal file
1320
packages/ui/src/pages/hosting/manage/files.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,2 +1,3 @@
|
||||
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
|
||||
export { default as ServersManageFilesPage } from './hosting/manage/files.vue'
|
||||
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
|
||||
|
||||
@@ -16,6 +16,11 @@ export type BackupProgressEntry = {
|
||||
|
||||
export type BackupsState = Map<string, BackupProgressEntry>
|
||||
|
||||
export interface FilesystemAuth {
|
||||
url: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface ModrinthServerContext {
|
||||
readonly serverId: string
|
||||
readonly server: Ref<Archon.Servers.v0.Server>
|
||||
@@ -26,6 +31,12 @@ export interface ModrinthServerContext {
|
||||
readonly isServerRunning: ComputedRef<boolean>
|
||||
readonly backupsState: Reactive<BackupsState>
|
||||
markBackupCancelled: (backupId: string) => void
|
||||
|
||||
// Filesystem state
|
||||
readonly fsAuth: Ref<FilesystemAuth | null>
|
||||
readonly fsOps: Ref<Archon.Websocket.v0.FilesystemOperation[]>
|
||||
readonly fsQueuedOps: Ref<Archon.Websocket.v0.QueuedFilesystemOp[]>
|
||||
refreshFsAuth: () => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectModrinthServerContext, provideModrinthServerContext] =
|
||||
|
||||
15
packages/ui/src/utils/ace-theme.ts
Normal file
15
packages/ui/src/utils/ace-theme.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import cssText from '@modrinth/assets/styles/ace.css?raw'
|
||||
import ace from 'ace-builds'
|
||||
|
||||
ace['define'](
|
||||
'ace/theme/modrinth',
|
||||
['require', 'exports', 'module', 'ace/lib/dom'],
|
||||
function (require, exports, _module) {
|
||||
exports.isDark = false
|
||||
exports.cssClass = 'ace-modrinth'
|
||||
exports.cssText = cssText
|
||||
|
||||
const dom = require('ace/lib/dom')
|
||||
dom.importCssString(exports.cssText, exports.cssClass, false)
|
||||
},
|
||||
)
|
||||
152
packages/ui/src/utils/file-extensions.ts
Normal file
152
packages/ui/src/utils/file-extensions.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// File extension constants
|
||||
export const CODE_EXTENSIONS = [
|
||||
'json',
|
||||
'json5',
|
||||
'jsonc',
|
||||
'java',
|
||||
'kt',
|
||||
'kts',
|
||||
'sh',
|
||||
'bat',
|
||||
'ps1',
|
||||
'yml',
|
||||
'yaml',
|
||||
'toml',
|
||||
'js',
|
||||
'ts',
|
||||
'py',
|
||||
'rb',
|
||||
'php',
|
||||
'html',
|
||||
'css',
|
||||
'cpp',
|
||||
'c',
|
||||
'h',
|
||||
'rs',
|
||||
'go',
|
||||
] as const
|
||||
|
||||
export const TEXT_EXTENSIONS = [
|
||||
'txt',
|
||||
'md',
|
||||
'log',
|
||||
'cfg',
|
||||
'conf',
|
||||
'properties',
|
||||
'ini',
|
||||
'sk',
|
||||
] as const
|
||||
|
||||
export const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'] as const
|
||||
|
||||
export const ARCHIVE_EXTENSIONS = ['zip'] as const
|
||||
|
||||
// Type for extension strings
|
||||
export type CodeExtension = (typeof CODE_EXTENSIONS)[number]
|
||||
export type TextExtension = (typeof TEXT_EXTENSIONS)[number]
|
||||
export type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
||||
export type ArchiveExtension = (typeof ARCHIVE_EXTENSIONS)[number]
|
||||
|
||||
/**
|
||||
* Extract file extension from filename (lowercase)
|
||||
*/
|
||||
export function getFileExtension(filename: string): string {
|
||||
return filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if extension is a code file
|
||||
*/
|
||||
export function isCodeFile(ext: string): boolean {
|
||||
return (CODE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if extension is a text file
|
||||
*/
|
||||
export function isTextFile(ext: string): boolean {
|
||||
return (TEXT_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if extension is an image file
|
||||
*/
|
||||
export function isImageFile(ext: string): boolean {
|
||||
return (IMAGE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if extension is an archive file
|
||||
*/
|
||||
export function isArchiveFile(ext: string): boolean {
|
||||
return (ARCHIVE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is editable (code or text)
|
||||
*/
|
||||
export function isEditableFile(ext: string): boolean {
|
||||
return isCodeFile(ext) || isTextFile(ext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Ace editor language mode for a file extension
|
||||
*/
|
||||
export function getEditorLanguage(ext: string): string {
|
||||
const lowered = ext.toLowerCase()
|
||||
switch (lowered) {
|
||||
// Code files
|
||||
case 'json':
|
||||
case 'json5':
|
||||
case 'jsonc':
|
||||
return 'json'
|
||||
case 'toml':
|
||||
return 'toml'
|
||||
case 'sh':
|
||||
return 'sh'
|
||||
case 'bat':
|
||||
return 'batchfile'
|
||||
case 'ps1':
|
||||
return 'powershell'
|
||||
case 'yml':
|
||||
case 'yaml':
|
||||
return 'yaml'
|
||||
case 'js':
|
||||
return 'javascript'
|
||||
case 'ts':
|
||||
return 'typescript'
|
||||
case 'py':
|
||||
return 'python'
|
||||
case 'rb':
|
||||
return 'ruby'
|
||||
case 'php':
|
||||
return 'php'
|
||||
case 'html':
|
||||
return 'html'
|
||||
case 'css':
|
||||
return 'css'
|
||||
case 'java':
|
||||
case 'kt':
|
||||
case 'kts':
|
||||
return 'java'
|
||||
case 'cpp':
|
||||
case 'c':
|
||||
case 'h':
|
||||
return 'c_cpp'
|
||||
case 'rs':
|
||||
return 'rust'
|
||||
case 'go':
|
||||
return 'golang'
|
||||
// Text files
|
||||
case 'md':
|
||||
return 'markdown'
|
||||
case 'properties':
|
||||
return 'properties'
|
||||
case 'ini':
|
||||
case 'cfg':
|
||||
case 'conf':
|
||||
return 'ini'
|
||||
default:
|
||||
return 'text'
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * from './auto-icons'
|
||||
export * from './common-messages'
|
||||
export * from './events'
|
||||
export * from './file-extensions'
|
||||
export * from './game-modes'
|
||||
export * from './notices'
|
||||
export * from './savable'
|
||||
|
||||
Reference in New Issue
Block a user