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:
Calum H.
2026-01-06 00:35:51 +00:00
committed by GitHub
parent 61d4a34f0f
commit 099011a177
89 changed files with 5863 additions and 2091 deletions

View File

@@ -309,14 +309,13 @@
</template>
<script setup lang="ts">
import { DownloadIcon, UpdatedIcon } from '@modrinth/assets'
import { DownloadIcon, PaletteIcon, UpdatedIcon } from '@modrinth/assets'
import { Button, Card, DropdownSelect } from '@modrinth/ui'
import { formatCategoryHeader, formatMoney, formatNumber } from '@modrinth/utils'
import dayjs from 'dayjs'
import { computed } from 'vue'
import { UiChartsChart as Chart, UiChartsCompactChart as CompactChart } from '#components'
import PaletteIcon from '~/assets/icons/palette.svg?component'
import {
analyticsSetToCSVString,
countryCodeToFlag,

View File

@@ -1,172 +0,0 @@
<template>
<NewModal ref="modal" header="Editing auto backup settings">
<div class="flex flex-col gap-4 md:w-[600px]">
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Auto backup</div>
<p class="m-0">
Automatically create a backup of your server
<strong>{{ backupIntervalsLabel.toLowerCase() }}</strong>
</p>
</div>
<div v-if="isLoadingSettings" class="py-2 text-sm text-secondary">Loading settings...</div>
<template v-else>
<input
id="auto-backup-toggle"
v-model="autoBackupEnabled"
class="switch stylized-toggle"
type="checkbox"
:disabled="isSaving"
/>
<div class="flex flex-col gap-2">
<div class="font-semibold text-contrast">Interval</div>
<p class="m-0">
The amount of time between each backup. This will only backup your server if it has been
modified since the last backup.
</p>
</div>
<Combobox
:id="'interval-field'"
v-model="backupIntervalsLabel"
:disabled="!autoBackupEnabled || isSaving"
name="interval"
:options="Object.keys(backupIntervals).map((k) => ({ value: k, label: k }))"
:display-value="backupIntervalsLabel"
/>
<div class="mt-4 flex justify-start gap-4">
<ButtonStyled color="brand">
<button :disabled="!hasChanges || isSaving" @click="saveSettings">
<SaveIcon class="h-5 w-5" />
{{ isSaving ? 'Saving...' : 'Save changes' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="isSaving" @click="modal?.hide()">
<XIcon />
Cancel
</button>
</ButtonStyled>
</div>
</template>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { SaveIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const props = defineProps<{
server: ModrinthServer
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const initialSettings = ref<{ interval: number; enabled: boolean } | null>(null)
const autoBackupEnabled = ref(false)
const isLoadingSettings = ref(true)
const isSaving = ref(false)
const backupIntervals = {
'Every 3 hours': 3,
'Every 6 hours': 6,
'Every 12 hours': 12,
Daily: 24,
}
const backupIntervalsLabel = ref<keyof typeof backupIntervals>('Every 6 hours')
const autoBackupInterval = computed({
get: () => backupIntervals[backupIntervalsLabel.value],
set: (value) => {
const [label] =
Object.entries(backupIntervals).find(([_, interval]) => interval === value) || []
if (label) backupIntervalsLabel.value = label as keyof typeof backupIntervals
},
})
const hasChanges = computed(() => {
if (!initialSettings.value) return false
return (
autoBackupEnabled.value !== initialSettings.value.enabled ||
(initialSettings.value.enabled && autoBackupInterval.value !== initialSettings.value.interval)
)
})
const fetchSettings = async () => {
isLoadingSettings.value = true
try {
const settings = await props.server.backups?.getAutoBackup()
initialSettings.value = settings as { interval: number; enabled: boolean }
autoBackupEnabled.value = settings?.enabled ?? false
autoBackupInterval.value = settings?.interval || 6
return true
} catch (error) {
console.error('Error fetching backup settings:', error)
addNotification({
title: 'Error',
text: 'Failed to load backup settings',
type: 'error',
})
return false
} finally {
isLoadingSettings.value = false
}
}
const saveSettings = async () => {
isSaving.value = true
try {
await props.server.backups?.updateAutoBackup(
autoBackupEnabled.value ? 'enable' : 'disable',
autoBackupInterval.value,
)
initialSettings.value = {
enabled: autoBackupEnabled.value,
interval: autoBackupInterval.value,
}
addNotification({
title: 'Success',
text: 'Backup settings updated successfully',
type: 'success',
})
modal.value?.hide()
} catch (error) {
console.error('Error saving backup settings:', error)
addNotification({
title: 'Error',
text: 'Failed to save backup settings',
type: 'error',
})
} finally {
isSaving.value = false
}
}
defineExpose({
show: async () => {
const success = await fetchSettings()
if (success) {
modal.value?.show()
}
},
})
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,7 +1,6 @@
<template>
<li
role="button"
data-pyro-file
:class="[
containerClasses,
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
@@ -12,6 +11,7 @@
@click="selectItem"
@contextmenu="openContextMenu"
@keydown="(e) => e.key === 'Enter' && selectItem()"
@mouseenter="handleMouseEnter"
@dragstart="handleDragStart"
@dragend="handleDragEnd"
@dragenter.prevent="handleDragEnter"
@@ -19,35 +19,32 @@
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<div
data-pyro-file-metadata
class="pointer-events-none flex w-full items-center gap-4 truncate"
>
<div
class="pointer-events-none flex size-8 items-center justify-center rounded-full bg-bg-raised p-[6px] group-hover:bg-brand-highlight group-hover:text-brand group-focus:bg-brand-highlight group-focus:text-brand"
:class="isEditableFile ? 'group-active:scale-[0.8]' : ''"
>
<component :is="iconComponent" class="size-6" />
<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 w-full flex-col truncate">
<div class="pointer-events-none flex flex-col truncate">
<span
class="pointer-events-none w-[98%] truncate font-bold group-hover:text-contrast group-focus:text-contrast"
class="pointer-events-none truncate group-hover:text-contrast group-focus:text-contrast"
>
{{ name }}
</span>
<span class="pointer-events-none text-xs text-secondary group-hover:text-primary">
{{ subText }}
</span>
</div>
</div>
<div
data-pyro-file-actions
class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12"
>
<span class="hidden w-[160px] text-nowrap font-mono text-sm text-secondary md:flex">
<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="w-[160px] text-nowrap font-mono text-sm text-secondary">
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
{{ formattedModifiedDate }}
</span>
<ButtonStyled circular type="transparent">
@@ -71,22 +68,23 @@ import {
FolderOpenIcon,
MoreHorizontalIcon,
PackageOpenIcon,
PaletteIcon,
RightArrowIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
CODE_EXTENSIONS,
Checkbox,
getFileExtension,
getFileExtensionIcon,
IMAGE_EXTENSIONS,
TEXT_EXTENSIONS,
isEditableFile as isEditableFileExt,
isImageFile,
} from '@modrinth/ui'
import { computed, h, ref, shallowRef } from 'vue'
import { renderToString } from 'vue/server-renderer'
import { useRoute, useRouter } from 'vue-router'
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
import PaletteIcon from '~/assets/icons/palette.svg?component'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
@@ -98,17 +96,21 @@ interface FileItemProps {
modified: number
created: number
path: string
index: number
isLast: boolean
selected: boolean
}
const props = defineProps<FileItemProps>()
const emit = defineEmits<{
(
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract',
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover',
item: { name: string; type: string; path: string },
): void
(e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void
(e: 'contextmenu', x: number, y: number): void
(e: 'toggle-select'): void
}>()
const isDragOver = ref(false)
@@ -120,12 +122,15 @@ const route = shallowRef(useRoute())
const router = useRouter()
const containerClasses = computed(() => [
'group m-0 p-0 focus:!outline-none flex w-full select-none items-center justify-between overflow-hidden border-0 border-b border-solid border-bg-raised p-3 last:border-none hover:bg-bg-raised focus:bg-bg-raised',
'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.index % 2 === 0 ? 'bg-surface-2' : 'bg-surface-3',
props.isLast ? 'rounded-b-[20px] border-b' : '',
isEditableFile.value ? 'cursor-pointer' : props.type === 'directory' ? 'cursor-pointer' : '',
isDragOver.value ? 'bg-brand-highlight' : '',
isDragOver.value ? '!bg-brand-highlight' : '',
'hover:brightness-110 focus:brightness-110',
])
const fileExtension = computed(() => props.name.split('.').pop()?.toLowerCase() || '')
const fileExtension = computed(() => getFileExtension(props.name))
const isZip = computed(() => fileExtension.value === 'zip')
@@ -170,13 +175,6 @@ const iconComponent = computed(() => {
return getFileExtensionIcon(fileExtension.value)
})
const subText = computed(() => {
if (props.type === 'directory') {
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
}
return formattedSize.value
})
const formattedModifiedDate = computed(() => {
const date = new Date(props.modified * 1000)
return `${date.toLocaleDateString('en-US', {
@@ -206,17 +204,16 @@ const formattedCreationDate = computed(() => {
const isEditableFile = computed(() => {
if (props.type === 'file') {
const ext = fileExtension.value
return (
!props.name.includes('.') ||
TEXT_EXTENSIONS.includes(ext) ||
CODE_EXTENSIONS.includes(ext) ||
IMAGE_EXTENSIONS.includes(ext)
)
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'
@@ -226,12 +223,16 @@ const formattedSize = computed(() => {
return `${size} ${units[exponent]}`
})
const openContextMenu = (event: MouseEvent) => {
function openContextMenu(event: MouseEvent) {
event.preventDefault()
emit('contextmenu', event.clientX, event.clientY)
}
const navigateToFolder = () => {
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}`
@@ -241,7 +242,7 @@ const navigateToFolder = () => {
const isNavigating = ref(false)
const selectItem = () => {
function selectItem() {
if (isNavigating.value) return
isNavigating.value = true
@@ -256,11 +257,12 @@ const selectItem = () => {
}, 500)
}
const getDragIcon = async () => {
async function getDragIcon() {
// Reuse iconComponent computed for consistency
return await renderToString(h(iconComponent.value))
}
const handleDragStart = async (event: DragEvent) => {
async function handleDragStart(event: DragEvent) {
if (!event.dataTransfer) return
isDragging.value = true
@@ -291,7 +293,7 @@ const handleDragStart = async (event: DragEvent) => {
})
event.dataTransfer.setData(
'application/pyro-file-move',
'application/modrinth-file-move',
JSON.stringify({
name: props.name,
type: props.type,
@@ -301,34 +303,34 @@ const handleDragStart = async (event: DragEvent) => {
event.dataTransfer.effectAllowed = 'move'
}
const isChildPath = (parentPath: string, childPath: string) => {
function isChildPath(parentPath: string, childPath: string) {
return childPath.startsWith(parentPath + '/')
}
const handleDragEnd = () => {
function handleDragEnd() {
isDragging.value = false
}
const handleDragEnter = () => {
function handleDragEnter() {
if (props.type !== 'directory') return
isDragOver.value = true
}
const handleDragOver = (event: DragEvent) => {
function handleDragOver(event: DragEvent) {
if (props.type !== 'directory' || !event.dataTransfer) return
event.dataTransfer.dropEffect = 'move'
}
const handleDragLeave = () => {
function handleDragLeave() {
isDragOver.value = false
}
const handleDrop = (event: DragEvent) => {
function handleDrop(event: DragEvent) {
isDragOver.value = false
if (props.type !== 'directory' || !event.dataTransfer) return
try {
const dragData = JSON.parse(event.dataTransfer.getData('application/pyro-file-move'))
const dragData = JSON.parse(event.dataTransfer.getData('application/modrinth-file-move'))
if (dragData.path === props.path) return

View File

@@ -2,7 +2,7 @@
<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-500">{{ title }}</h3>
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
<p class="m-0 text-sm text-secondary">
{{ message }}
</p>

View File

@@ -1,11 +1,10 @@
<template>
<div ref="listContainer" data-pyro-files-virtual-list-root class="relative w-full">
<div ref="listContainer" class="relative w-full">
<div
:style="{
position: 'relative',
minHeight: `${totalHeight}px`,
}"
data-pyro-files-virtual-height-watcher
>
<ul
class="list-none"
@@ -16,10 +15,9 @@
margin: 0,
padding: 0,
}"
data-pyro-files-virtual-list
>
<FileItem
v-for="item in visibleItems"
v-for="(item, idx) in visibleItems"
:key="item.path"
:count="item.count"
:created="item.created"
@@ -28,6 +26,9 @@
: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)"
@@ -35,7 +36,9 @@
@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>
@@ -49,15 +52,17 @@ import FileItem from './FileItem.vue'
const props = defineProps<{
items: any[]
selectedItems: Set<string>
}>()
const emit = defineEmits<{
(
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract',
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract' | 'hover',
item: any,
): void
(e: 'contextmenu', item: any, x: number, y: number): void
(e: 'loadMore'): void
(e: 'toggle-select', path: string): void
}>()
const ITEM_HEIGHT = 61
@@ -92,7 +97,7 @@ const visibleItems = computed(() => {
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
})
const handleScroll = () => {
function handleScroll() {
windowScrollY.value = window.scrollY
if (!listContainer.value) return
@@ -105,7 +110,7 @@ const handleScroll = () => {
}
}
const handleResize = () => {
function handleResize() {
windowHeight.value = window.innerHeight
}

View File

@@ -1,11 +1,6 @@
<template>
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
<header
:class="[
'duration-20 top-0 flex select-none flex-col justify-between gap-2 bg-table-alternateRow p-3 transition-[border-radius] sm:h-12 sm:flex-row',
!isStuck ? 'rounded-t-2xl' : 'sticky top-0 z-20',
]"
data-pyro-files-state="browsing"
class="flex select-none flex-col justify-between gap-2 sm:flex-row sm:items-center"
aria-label="File navigation"
>
<nav
@@ -13,20 +8,17 @@
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="-ml-1 flex-shrink-0">
<ButtonStyled type="transparent">
<li class="mr-4 flex-shrink-0">
<ButtonStyled circular>
<button
v-tooltip="'Back to home'"
type="button"
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="$emit('navigate', -1)"
@mouseenter="$emit('prefetch-home')"
>
<span
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<HomeIcon class="h-5 w-5" />
<span class="sr-only">Home</span>
</span>
<HomeIcon />
<span class="sr-only">Home</span>
</button>
</ButtonStyled>
</li>
@@ -70,58 +62,28 @@
</ol>
</nav>
<div class="flex flex-shrink-0 items-center gap-1">
<div class="flex w-full flex-row-reverse sm:flex-row">
<ButtonStyled type="transparent">
<TeleportOverflowMenu
position="bottom"
direction="left"
aria-label="Filter view"
:options="[
{ id: 'all', action: () => $emit('filter', 'all') },
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
]"
>
<div class="flex items-center gap-1">
<FilterIcon aria-hidden="true" class="h-5 w-5" />
<span class="hidden text-sm font-medium sm:block">
{{ filterLabel }}
</span>
</div>
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<template #all>Show all</template>
<template #filesOnly>Files only</template>
<template #foldersOnly>Folders only</template>
</TeleportOverflowMenu>
</ButtonStyled>
<div class="mx-1 w-full text-sm sm:w-48">
<label for="search-folder" class="sr-only">Search folder</label>
<div class="relative">
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2"
aria-hidden="true"
/>
<input
id="search-folder"
:value="searchQuery"
type="search"
name="search"
autocomplete="off"
class="h-8 min-h-[unset] w-full border-[1px] border-solid border-divider bg-transparent py-2 pl-9"
placeholder="Search..."
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
<div 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="transparent">
<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') },
@@ -132,8 +94,8 @@
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
]"
>
<PlusIcon aria-hidden="true" />
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
<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>
@@ -159,7 +121,6 @@ import {
CurseForgeIcon,
DropdownIcon,
FileArchiveIcon,
FilterIcon,
FolderOpenIcon,
HomeIcon,
LinkIcon,
@@ -168,12 +129,8 @@ import {
UploadIcon,
} from '@modrinth/assets'
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
import { useIntersectionObserver } from '@vueuse/core'
import { computed, ref } from 'vue'
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
const props = defineProps<{
defineProps<{
breadcrumbSegments: string[]
searchQuery: string
currentFilter: string
@@ -183,44 +140,13 @@ const props = defineProps<{
defineEmits<{
(e: 'navigate', index: number): void
(e: 'create', type: 'file' | 'directory'): void
(e: 'upload' | 'upload-zip'): void
(e: 'upload' | 'upload-zip' | 'prefetch-home'): void
(e: 'unzip-from-url', cf: boolean): void
(e: 'update:searchQuery' | 'filter', value: string): void
}>()
const pyroFilesSentinel = ref<HTMLElement | null>(null)
const isStuck = ref(false)
useIntersectionObserver(
pyroFilesSentinel,
([{ isIntersecting }]) => {
isStuck.value = !isIntersecting
},
{ threshold: [0, 1] },
)
const filterLabel = computed(() => {
switch (props.currentFilter) {
case 'filesOnly':
return 'Files only'
case 'foldersOnly':
return 'Folders only'
default:
return 'Show all'
}
})
</script>
<style scoped>
.sentinel {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
visibility: hidden;
}
.breadcrumb-move,
.breadcrumb-enter-active,
.breadcrumb-leave-active {

View File

@@ -2,10 +2,10 @@
<NewModal ref="modal" 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-[#cb224436] bg-[#f57b7b0e] p-6 shadow-md dark:border-0 dark:bg-[#0e0e0ea4]"
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-[#3f1818a4] p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
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" />

View File

@@ -1,7 +1,7 @@
<template>
<header
data-pyro-files-state="editing"
class="flex h-12 select-none items-center justify-between rounded-t-2xl bg-table-alternateRow p-3"
class="flex select-none items-center justify-between gap-2 sm:flex-row"
aria-label="File editor navigation"
>
<nav
@@ -9,20 +9,16 @@
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="-ml-1 flex-shrink-0">
<ButtonStyled type="transparent">
<li class="mr-4 flex-shrink-0">
<ButtonStyled circular>
<button
v-tooltip="'Back to home'"
type="button"
class="mr-2 grid h-12 w-10 place-content-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
@click="goHome"
>
<span
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<HomeIcon class="h-5 w-5" />
<span class="sr-only">Home</span>
</span>
<HomeIcon />
<span class="sr-only">Home</span>
</button>
</ButtonStyled>
</li>

View File

@@ -0,0 +1,260 @@
<template>
<div class="flex h-full w-full flex-col gap-4">
<FilesRenameItemModal ref="renameModal" :item="file" @rename="handleRenameItem" />
<FilesEditingNavbar
:file-name="file?.name"
:is-image="isEditingImage"
:file-path="file?.path"
class="-mt-2"
:breadcrumb-segments="breadcrumbSegments"
@cancel="handleCancel"
@save="() => saveFileContent(true)"
@save-as="saveFileContentAs"
@save-restart="saveFileContentRestart"
@share="requestShareLink"
@navigate="(index) => emit('navigate', index)"
/>
<div class="flex flex-col 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"
/>
<FilesImageViewer 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 { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import FilesEditingNavbar from '~/components/ui/servers/FilesEditingNavbar.vue'
import FilesImageViewer from '~/components/ui/servers/FilesImageViewer.vue'
import FilesRenameItemModal from '~/components/ui/servers/FilesRenameItemModal.vue'
const props = defineProps<{
file: { name: string; type: string; path: string } | null
breadcrumbSegments: string[]
editorComponent: any
}>()
const emit = defineEmits<{
close: []
navigate: [index: number]
}>()
const notifications = injectNotificationManager()
const { addNotification } = notifications
const client = injectModrinthClient()
const serverContext = injectModrinthServerContext()
const { serverId } = serverContext
const queryClient = useQueryClient()
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
// Internal state
const fileContent = ref('')
const isEditingImage = ref(false)
const imagePreview = ref<Blob | null>(null)
const isLoading = ref(false)
const renameModal = ref()
const closeAfterRename = ref(false)
const editorInstance = ref<any>(null)
const editorLanguage = computed(() => {
const ext = getFileExtension(props.file?.name ?? '')
return getEditorLanguage(ext)
})
// Load file content when file prop changes
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)) {
// Images are not prefetched, fetch directly
const content = await client.kyros.files_v0.downloadFile(file.path)
isEditingImage.value = true
imagePreview.value = content
} else {
isEditingImage.value = false
// Check cache first for text files (may have been prefetched on hover)
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
closeAfterRename.value = false
}
function onEditorInit(editor: any) {
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 saveFileContentRestart() {
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 saveFileContentAs() {
await saveFileContent(false)
closeAfterRename.value = true
renameModal.value?.show(props.file)
}
async function handleRenameItem(newName: string) {
if (!props.file) return
try {
await client.kyros.files_v0.renameFileOrFolder(props.file.path, newName)
addNotification({ title: 'Renamed', text: `Renamed to ${newName}`, type: 'success' })
if (closeAfterRename.value) {
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
closeAfterRename.value = false
emit('close')
}
} catch (err: any) {
addNotification({ title: 'Rename failed', text: err.message, type: 'error' })
}
}
async function requestShareLink() {
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 }),
})) as any
if (response.success) {
await navigator.clipboard.writeText(response.url)
addNotification({
title: 'Log URL copied',
text: 'Your log file URL has been copied to your clipboard.',
type: 'success',
})
} else {
throw new Error(response.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 handleCancel() {
resetState()
emit('close')
}
onMounted(async () => {
await modulesLoaded
})
onUnmounted(() => {
editorInstance.value = null
resetState()
})
</script>

View File

@@ -2,7 +2,7 @@
<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-b-2xl bg-black active:cursor-grabbing"
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-[20px] bg-black active:cursor-grabbing"
@mousedown="startPan"
@mousemove="handlePan"
@mouseup="stopPan"

View File

@@ -1,65 +1,102 @@
<template>
<div
aria-hidden="true"
class="sticky top-12 z-20 flex h-8 w-full select-none flex-row items-center border-0 border-b border-solid border-bg-raised bg-bg px-3 text-xs font-bold uppercase"
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="min-w-[48px]"></div>
<button
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
@click="$emit('sort', 'name')"
>
<span>Name</span>
<ChevronUpIcon v-if="sortField === 'name' && !sortDesc" class="h-3 w-3" aria-hidden="true" />
<ChevronDownIcon v-if="sortField === 'name' && sortDesc" class="h-3 w-3" aria-hidden="true" />
</button>
<div class="flex shrink-0 gap-4 text-right md:gap-12">
<div class="flex flex-1 items-center gap-3">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected && !allSelected"
@update:model-value="$emit('toggle-all')"
/>
<button
class="hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
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>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>Created</span>
<ChevronUpIcon
v-if="sortField === 'created' && !sortDesc"
class="h-3 w-3"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'created' && sortDesc"
class="h-3 w-3"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<button
class="mr-4 hidden min-w-[160px] appearance-none items-center justify-start gap-1 bg-transparent hover:text-brand md:flex"
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>Modified</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="h-3 w-3"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="h-3 w-3"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<div class="min-w-[24px]"></div>
<span class="w-[51px] text-right text-primary">Actions</span>
</div>
</div>
</template>
<script setup lang="ts">
import { Checkbox } from '@modrinth/ui'
import ChevronDownIcon from './icons/ChevronDownIcon.vue'
import ChevronUpIcon from './icons/ChevronUpIcon.vue'
defineProps<{
sortField: string
sortDesc: boolean
allSelected: boolean
someSelected: boolean
isStuck: boolean
}>()
defineEmits<{
(e: 'sort', field: string): void
(e: 'toggle-all'): void
}>()
</script>

View File

@@ -9,12 +9,12 @@
<div
v-if="isDragging"
:class="[
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black bg-opacity-50 text-white',
'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" />
<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>
@@ -41,7 +41,7 @@ const dragCounter = ref(0)
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
if (!event.dataTransfer?.types.includes('application/pyro-file-move')) {
if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
dragCounter.value++
isDragging.value = true
}
@@ -64,7 +64,7 @@ const handleDrop = (event: DragEvent) => {
isDragging.value = false
dragCounter.value = 0
const isInternalMove = event.dataTransfer?.types.includes('application/pyro-file-move')
const isInternalMove = event.dataTransfer?.types.includes('application/modrinth-file-move')
if (isInternalMove) return
const files = event.dataTransfer?.files

View File

@@ -102,14 +102,13 @@
<script setup lang="ts">
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { computed, nextTick, ref, watch } from 'vue'
import type { FSModule } from '~/composables/servers/modules/fs.ts'
import PanelSpinner from './PanelSpinner.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
interface UploadItem {
file: File
@@ -123,7 +122,7 @@ interface UploadItem {
| 'cancelled'
| 'incorrect-type'
size: string
uploader?: any
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
error?: Error
}
@@ -132,7 +131,6 @@ interface Props {
fileType?: string
marginBottom?: number
acceptedTypes?: Array<string>
fs: FSModule
}
defineOptions({
@@ -208,6 +206,7 @@ const cancelUpload = (item: UploadItem) => {
}
const badFileTypeMsg = 'Upload had incorrect file type'
const uploadFile = async (file: File) => {
const uploadItem: UploadItem = {
file,
@@ -229,19 +228,18 @@ const uploadFile = async (file: File) => {
uploadItem.status = 'uploading'
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
const uploader = await props.fs.uploadFile(filePath, file)
uploadItem.uploader = uploader
if (uploader?.onProgress) {
uploader.onProgress(({ progress }: { progress: number }) => {
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
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'

View File

@@ -52,7 +52,7 @@
/>
<div v-if="submitted && error" class="text-red">{{ error }}</div>
</div>
<BackupWarning :backup-link="`/hosting/manage/${props.server.serverId}/backups`" />
<BackupWarning :backup-link="`/hosting/manage/${serverId}/backups`" />
<div class="flex justify-start gap-2">
<ButtonStyled color="brand">
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
@@ -74,21 +74,25 @@
<script setup lang="ts">
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { BackupWarning, ButtonStyled, injectNotificationManager, NewModal } from '@modrinth/ui'
import {
BackupWarning,
ButtonStyled,
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
NewModal,
} from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import { computed, nextTick, ref } from 'vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { handleServersError } from '~/composables/servers/modrinth-servers.ts'
const notifications = injectNotificationManager()
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const cf = ref(false)
const props = defineProps<{
server: ModrinthServer
}>()
const modal = ref<typeof NewModal>()
const urlInput = ref<HTMLInputElement | null>(null)
const url = ref('')
@@ -115,10 +119,10 @@ const handleSubmit = async () => {
if (!error.value) {
// hide();
try {
const dry = await props.server.fs.extractFile(trimmedUrl.value, true, true)
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
if (!cf.value || dry.modpack_name) {
await props.server.fs.extractFile(trimmedUrl.value, true, false, true)
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
hide()
} else {
submitted.value = false