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

@@ -49,7 +49,6 @@
"@vue-email/components": "^0.0.21",
"@vue-email/render": "^0.0.9",
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.36.2",
"ansi-to-html": "^0.7.2",
"dayjs": "^1.11.7",
"dompurify": "^3.1.7",
@@ -61,6 +60,7 @@
"js-yaml": "^4.1.0",
"jszip": "^3.10.1",
"markdown-it": "14.1.0",
"ace-builds": "^1.36.2",
"pathe": "^1.1.2",
"pinia": "^3.0.0",
"pinia-plugin-persistedstate": "^4.4.1",

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

View File

@@ -2,15 +2,7 @@ import type { AbstractWebNotificationManager } from '@modrinth/ui'
import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
import { ModrinthServerError } from '@modrinth/utils'
import {
BackupsModule,
ContentModule,
FSModule,
GeneralModule,
NetworkModule,
StartupModule,
WSModule,
} from './modules/index.ts'
import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
import { useServersFetch } from './servers-fetch.ts'
export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
@@ -36,39 +28,16 @@ export class ModrinthServer {
readonly general: GeneralModule
readonly content: ContentModule
readonly backups: BackupsModule
readonly network: NetworkModule
readonly startup: StartupModule
readonly ws: WSModule
readonly fs: FSModule
constructor(serverId: string) {
this.serverId = serverId
this.general = new GeneralModule(this)
this.content = new ContentModule(this)
this.backups = new BackupsModule(this)
this.network = new NetworkModule(this)
this.startup = new StartupModule(this)
this.ws = new WSModule(this)
this.fs = new FSModule(this)
}
async createMissingFolders(path: string): Promise<void> {
if (path.startsWith('/')) {
path = path.substring(1)
}
const folders = path.split('/')
let currentPath = ''
for (const folder of folders) {
currentPath += '/' + folder
try {
await this.fs.createFileOrFolder(currentPath, 'directory')
} catch {
// Folder might already exist, ignore error
}
}
}
async fetchConfigFile(fileName: string): Promise<any> {
@@ -240,9 +209,7 @@ export class ModrinthServer {
},
): Promise<void> {
const modulesToRefresh =
modules.length > 0
? modules
: (['general', 'content', 'backups', 'network', 'startup', 'ws', 'fs'] as ModuleName[])
modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
for (const module of modulesToRefresh) {
this.errors[module] = undefined
@@ -274,25 +241,16 @@ export class ModrinthServer {
case 'content':
await this.content.fetch()
break
case 'backups':
await this.backups.fetch()
break
case 'network':
await this.network.fetch()
break
case 'startup':
await this.startup.fetch()
break
case 'ws':
await this.ws.fetch()
break
case 'fs':
await this.fs.fetch()
break
}
} catch (error) {
if (error instanceof ModrinthServerError) {
if (error.statusCode === 404 && ['fs', 'content'].includes(module)) {
if (error.statusCode === 404 && module === 'content') {
console.debug(`Optional ${module} resource not found:`, error.message)
continue
}

View File

@@ -1,248 +0,0 @@
import type {
DirectoryResponse,
FilesystemOp,
FileUploadQuery,
FSQueuedOp,
JWTAuth,
} from '@modrinth/utils'
import { ModrinthServerError } from '@modrinth/utils'
import { useServersFetch } from '../servers-fetch.ts'
import { ServerModule } from './base.ts'
export class FSModule extends ServerModule {
auth!: JWTAuth
ops: FilesystemOp[] = []
queuedOps: FSQueuedOp[] = []
opsQueuedForModification: string[] = []
async fetch(): Promise<void> {
this.auth = await useServersFetch<JWTAuth>(`servers/${this.serverId}/fs`, {}, 'fs')
this.ops = []
this.queuedOps = []
this.opsQueuedForModification = []
}
private async retryWithAuth<T>(
requestFn: () => Promise<T>,
ignoreFailure: boolean = false,
): Promise<T> {
try {
return await requestFn()
} catch (error) {
if (error instanceof ModrinthServerError && error.statusCode === 401) {
console.debug('Auth failed, refreshing JWT and retrying')
await this.fetch() // Refresh auth
return await requestFn()
}
const available = await this.server.testNodeReachability()
if (!available && !ignoreFailure) {
this.server.moduleErrors.general = {
error: new ModrinthServerError(
'Unable to reach node. FS operation failed and subsequent ping test failed.',
500,
error as Error,
'fs',
),
timestamp: Date.now(),
}
}
throw error
}
}
listDirContents(
path: string,
page: number,
pageSize: number,
ignoreFailure: boolean = false,
): Promise<DirectoryResponse> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path)
return await useServersFetch(`/list?path=${encodedPath}&page=${page}&page_size=${pageSize}`, {
override: this.auth,
retry: false,
})
}, ignoreFailure)
}
createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path)
await useServersFetch(`/create?path=${encodedPath}&type=${type}`, {
method: 'POST',
contentType: 'application/octet-stream',
override: this.auth,
})
})
}
uploadFile(path: string, file: File): FileUploadQuery {
const encodedPath = encodeURIComponent(path)
const progressSubject = new EventTarget()
const abortController = new AbortController()
const uploadPromise = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress = (e.loaded / e.total) * 100
progressSubject.dispatchEvent(
new CustomEvent('progress', {
detail: { loaded: e.loaded, total: e.total, progress },
}),
)
}
})
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.response)
} else {
reject(new Error(`Upload failed with status ${xhr.status}`))
}
}
xhr.onerror = () => reject(new Error('Upload failed'))
xhr.onabort = () => reject(new Error('Upload cancelled'))
xhr.open('POST', `https://${this.auth.url}/create?path=${encodedPath}&type=file`)
xhr.setRequestHeader('Authorization', `Bearer ${this.auth.token}`)
xhr.setRequestHeader('Content-Type', 'application/octet-stream')
xhr.send(file)
abortController.signal.addEventListener('abort', () => xhr.abort())
})
return {
promise: uploadPromise,
onProgress: (
callback: (progress: { loaded: number; total: number; progress: number }) => void,
) => {
progressSubject.addEventListener('progress', ((e: CustomEvent) => {
callback(e.detail)
}) as EventListener)
},
cancel: () => abortController.abort(),
} as FileUploadQuery
}
renameFileOrFolder(path: string, name: string): Promise<void> {
const pathName = path.split('/').slice(0, -1).join('/') + '/' + name
return this.retryWithAuth(async () => {
await useServersFetch(`/move`, {
method: 'POST',
override: this.auth,
body: { source: path, destination: pathName },
})
})
}
updateFile(path: string, content: string): Promise<void> {
const octetStream = new Blob([content], { type: 'application/octet-stream' })
return this.retryWithAuth(async () => {
await useServersFetch(`/update?path=${path}`, {
method: 'PUT',
contentType: 'application/octet-stream',
body: octetStream,
override: this.auth,
})
})
}
moveFileOrFolder(path: string, newPath: string): Promise<void> {
return this.retryWithAuth(async () => {
await this.server.createMissingFolders(newPath.substring(0, newPath.lastIndexOf('/')))
await useServersFetch(`/move`, {
method: 'POST',
override: this.auth,
body: { source: path, destination: newPath },
})
})
}
deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
const encodedPath = encodeURIComponent(path)
return this.retryWithAuth(async () => {
await useServersFetch(`/delete?path=${encodedPath}&recursive=${recursive}`, {
method: 'DELETE',
override: this.auth,
})
})
}
downloadFile(path: string, raw: boolean = false, ignoreFailure: boolean = false): Promise<any> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path)
const fileData = await useServersFetch(`/download?path=${encodedPath}`, {
override: this.auth,
})
if (fileData instanceof Blob) {
return raw ? fileData : await fileData.text()
}
return fileData
}, ignoreFailure)
}
extractFile(
path: string,
override = true,
dry = false,
silentQueue = false,
): Promise<{ modpack_name: string | null; conflicting_files: string[] }> {
return this.retryWithAuth(async () => {
const encodedPath = encodeURIComponent(path)
if (!silentQueue) {
this.queuedOps.push({ op: 'unarchive', src: path })
setTimeout(() => this.removeQueuedOp('unarchive', path), 4000)
}
try {
return await useServersFetch(
`/unarchive?src=${encodedPath}&trg=/&override=${override}&dry=${dry}`,
{
method: 'POST',
override: this.auth,
version: 1,
},
undefined,
'Error extracting file',
)
} catch (err) {
this.removeQueuedOp('unarchive', path)
throw err
}
})
}
modifyOp(id: string, action: 'dismiss' | 'cancel'): Promise<void> {
return this.retryWithAuth(async () => {
await useServersFetch(
`/ops/${action}?id=${id}`,
{
method: 'POST',
override: this.auth,
version: 1,
},
undefined,
`Error ${action === 'dismiss' ? 'dismissing' : 'cancelling'} filesystem operation`,
)
this.opsQueuedForModification = this.opsQueuedForModification.filter((x: string) => x !== id)
this.ops = this.ops.filter((x: FilesystemOp) => x.id !== id)
})
}
removeQueuedOp(op: FSQueuedOp['op'], src: string): void {
this.queuedOps = this.queuedOps.filter((x: FSQueuedOp) => x.op !== op || x.src !== src)
}
clearQueuedOps(): void {
this.queuedOps = []
}
}

View File

@@ -50,19 +50,6 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
data.image = (await this.server.processImage(data.project?.icon_url)) ?? undefined
}
try {
const motd = await this.getMotd()
if (motd === 'A Minecraft Server') {
await this.setMotd(
`§b${data.project?.title || data.loader + ' ' + data.mc_version} §f♦ §aModrinth Hosting`,
)
}
data.motd = motd
} catch {
console.error('[Modrinth Hosting] [General] Failed to fetch MOTD.')
data.motd = undefined
}
// Copy data to this module
Object.assign(this, data)
}
@@ -189,23 +176,6 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
await this.fetch() // Refresh this module
}
async getMotd(): Promise<string | undefined> {
try {
const props = await this.server.fs.downloadFile('/server.properties', false, true)
if (props) {
const lines = props.split('\n')
for (const line of lines) {
if (line.startsWith('motd=')) {
return line.slice(5)
}
}
}
} catch {
return undefined
}
return undefined
}
async setMotd(motd: string): Promise<void> {
try {
const props = (await this.server.fetchConfigFile('ServerProperties')) as any

View File

@@ -1,7 +1,6 @@
export * from './backups.ts'
export * from './base.ts'
export * from './content.ts'
export * from './fs.ts'
export * from './general.ts'
export * from './network.ts'
export * from './startup.ts'

View File

@@ -3,6 +3,8 @@ import {
type AuthConfig,
AuthFeature,
CircuitBreakerFeature,
NodeAuthFeature,
nodeAuthState,
NuxtCircuitBreakerStorage,
type NuxtClientConfig,
NuxtModrinthClient,
@@ -24,6 +26,16 @@ export function createModrinthClient(
archonBaseUrl: config.archonBaseUrl,
rateLimitKey: config.rateLimitKey,
features: [
// for modrinth hosting
// is skipped for normal reqs
new NodeAuthFeature({
getAuth: () => nodeAuthState.getAuth?.() ?? null,
refreshAuth: async () => {
if (nodeAuthState.refreshAuth) {
await nodeAuthState.refreshAuth()
}
},
}),
new AuthFeature({
token: async () => auth.value.token,
} as AuthConfig),

View File

@@ -374,11 +374,16 @@
<script setup lang="ts">
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
import type { Archon } from '@modrinth/api-client'
import { clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client'
import {
BoxesIcon,
CheckIcon,
CopyIcon,
DatabaseBackupIcon,
FileIcon,
FolderOpenIcon,
IssuesIcon,
LayoutTemplateIcon,
LeftArrowIcon,
LockIcon,
RightArrowIcon,
@@ -451,7 +456,7 @@ const loadModulesPromise = Promise.resolve().then(() => {
if (server.general?.status === 'suspended') {
return
}
return server.refresh(['content', 'backups', 'network', 'startup', 'fs'])
return server.refresh(['content', 'backups', 'network', 'startup'])
})
provide('modulesLoaded', loadModulesPromise)
@@ -497,6 +502,22 @@ const markBackupCancelled = (backupId: string) => {
cancelledBackups.add(backupId)
}
const fsAuth = ref<{ url: string; token: string } | null>(null)
const fsOps = ref<Archon.Websocket.v0.FilesystemOperation[]>([])
const fsQueuedOps = ref<Archon.Websocket.v0.QueuedFilesystemOp[]>([])
const refreshFsAuth = async () => {
try {
const auth = await client.archon.servers_v0.getFilesystemAuth(serverId)
fsAuth.value = auth
} catch (error) {
console.error('Failed to refresh filesystem auth:', error)
throw error
}
}
setNodeAuthState(() => fsAuth.value, refreshFsAuth)
provideModrinthServerContext({
serverId,
server: n_server as Ref<Archon.Servers.v0.Server>,
@@ -505,6 +526,10 @@ provideModrinthServerContext({
isServerRunning,
backupsState,
markBackupCancelled,
fsAuth,
fsOps,
fsQueuedOps,
refreshFsAuth,
})
const uptimeSeconds = ref(0)
@@ -551,17 +576,29 @@ const showGameLabel = computed(() => !!serverData.value?.game)
const showLoaderLabel = computed(() => !!serverData.value?.loader)
const navLinks = [
{ label: 'Overview', href: `/hosting/manage/${serverId}`, subpages: [] },
{
label: 'Overview',
href: `/hosting/manage/${serverId}`,
icon: LayoutTemplateIcon,
subpages: [],
},
{
label: 'Content',
href: `/hosting/manage/${serverId}/content`,
icon: BoxesIcon,
subpages: ['mods', 'datapacks'],
},
{ label: 'Files', href: `/hosting/manage/${serverId}/files`, subpages: [] },
{ label: 'Backups', href: `/hosting/manage/${serverId}/backups`, subpages: [] },
{ label: 'Files', href: `/hosting/manage/${serverId}/files`, icon: FolderOpenIcon, subpages: [] },
{
label: 'Backups',
href: `/hosting/manage/${serverId}/backups`,
icon: DatabaseBackupIcon,
subpages: [],
},
{
label: 'Options',
href: `/hosting/manage/${serverId}/options`,
icon: SettingsIcon,
subpages: ['startup', 'network', 'properties', 'info'],
},
]
@@ -767,24 +804,29 @@ const handleBackupProgress = (data: Archon.Websocket.v0.WSBackupProgressEvent) =
}
}
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
if (!server.fs) {
console.error('FilesystemOps received, but server.fs is not available', data)
return
}
const opsQueuedForModification = ref<string[]>([])
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
const allOps = data.all
if (JSON.stringify(server.fs.ops) !== JSON.stringify(allOps)) {
server.fs.ops = allOps as unknown as ModrinthServer['fs']['ops']
if (JSON.stringify(fsOps.value) !== JSON.stringify(allOps)) {
fsOps.value = allOps
}
server.fs.queuedOps = server.fs.queuedOps.filter(
fsQueuedOps.value = fsQueuedOps.value.filter(
(queuedOp) => !allOps.some((x) => x.src === queuedOp.src),
)
const dismissOp = async (opId: string) => {
try {
await client.kyros.files_v0.modifyOperation(opId, 'dismiss')
} catch (error) {
console.error('Failed to dismiss operation:', error)
}
}
const cancelled = allOps.filter((x) => x.state === 'cancelled')
Promise.all(cancelled.map((x) => server.fs?.modifyOp(x.id, 'dismiss')))
Promise.all(cancelled.map((x) => dismissOp(x.id)))
const completed = allOps.filter((x) => x.state === 'done')
if (completed.length > 0) {
@@ -792,9 +834,9 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
async () =>
await Promise.all(
completed.map((x) => {
if (!server.fs?.opsQueuedForModification.includes(x.id)) {
server.fs?.opsQueuedForModification.push(x.id)
return server.fs?.modifyOp(x.id, 'dismiss')
if (!opsQueuedForModification.value.includes(x.id)) {
opsQueuedForModification.value.push(x.id)
return dismissOp(x.id)
}
return Promise.resolve()
}),
@@ -885,22 +927,27 @@ const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallation
errorTitle.value = 'Installation error'
errorMessage.value = data.reason ?? 'Unknown error'
error.value = new Error(data.reason ?? 'Unknown error')
let files = await server.fs?.listDirContents('/', 1, 100)
if (files) {
if (files.total > 1) {
for (let i = 1; i < files.total; i++) {
const nextFiles = await server.fs?.listDirContents('/', i, 100)
// Fetch installation log if available
try {
let files = await client.kyros.files_v0.listDirectory('/', 1, 100)
if (files && files.total > 1) {
for (let i = 2; i <= files.total; i++) {
const nextFiles = await client.kyros.files_v0.listDirectory('/', i, 100)
if (nextFiles?.items?.length === 0) break
if (nextFiles) files = nextFiles
}
}
}
const fileName = files?.items?.find((file: { name: string }) =>
file.name.startsWith('modrinth-installation'),
)?.name
errorLogFile.value = fileName ?? ''
if (fileName) {
errorLog.value = await server.fs?.downloadFile(fileName)
const fileName = files?.items?.find((file) =>
file.name.startsWith('modrinth-installation'),
)?.name
errorLogFile.value = fileName ?? ''
if (fileName) {
const content = await client.kyros.files_v0.downloadFile(fileName)
errorLog.value = await content.text()
}
} catch (err) {
console.error('Failed to fetch installation log:', err)
}
break
}
@@ -1133,6 +1180,8 @@ const cleanup = () => {
completedBackupTasks.clear()
cancelledBackups.clear()
clearNodeAuthState()
DOMPurify.removeHook('afterSanitizeAttributes')
}

View File

@@ -100,13 +100,11 @@
</div>
</div>
<FilesUploadDropdown
v-if="props.server.fs"
ref="uploadDropdownRef"
class="rounded-xl bg-bg-raised"
:margin-bottom="16"
:file-type="type"
:current-path="`/${type.toLocaleLowerCase()}s`"
:fs="props.server.fs"
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
@upload-complete="() => props.server.refresh(['content'])"
/>
@@ -355,7 +353,7 @@ import {
TrashIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { Avatar, ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import type { Mod } from '@modrinth/utils'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -369,6 +367,8 @@ import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const props = defineProps<{
server: ModrinthServer
}>()
@@ -621,7 +621,7 @@ async function toggleMod(mod: ContentItem) {
mod.disabled = newFilename.endsWith('.disabled')
mod.filename = newFilename
await props.server.fs?.moveFileOrFolder(sourcePath, destinationPath)
await client.kyros.files_v0.moveFileOrFolder(sourcePath, destinationPath)
await props.server.refresh(['general', 'content'])
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -205,6 +205,9 @@ type ServerProps = {
const props = defineProps<ServerProps>()
const client = injectModrinthClient()
const serverId = props.server.serverId
interface ErrorData {
id: string
name: string
@@ -242,7 +245,8 @@ const inspectingError = ref<ErrorData | null>(null)
const inspectError = async () => {
try {
const log = await props.server.fs?.downloadFile('logs/latest.log')
const blob = await client.kyros.files_v0.downloadFile('/logs/latest.log')
const log = await blob.text()
if (!log) return
// @ts-ignore
@@ -287,9 +291,6 @@ if (props.serverPowerState === 'crashed' && !props.powerStateDetails?.oom_killed
inspectError()
}
const client = injectModrinthClient()
const serverId = props.server.serverId
const DYNAMIC_ARG = Symbol('DYNAMIC_ARG')
const commandTree: any = {

View File

@@ -116,13 +116,15 @@
<script setup lang="ts">
import { EditIcon, TransferIcon } from '@modrinth/assets'
import { injectNotificationManager, ServerIcon } from '@modrinth/ui'
import { injectModrinthClient, injectNotificationManager, ServerIcon } from '@modrinth/ui'
import ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const props = defineProps<{
server: ModrinthServer
}>()
@@ -242,12 +244,12 @@ const uploadFile = async (e: Event) => {
try {
if (data.value?.image) {
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
}
await props.server.fs?.uploadFile('/server-icon.png', scaledFile)
await props.server.fs?.uploadFile('/server-icon-original.png', file)
await client.kyros.files_v0.uploadFile('/server-icon.png', scaledFile).promise
await client.kyros.files_v0.uploadFile('/server-icon-original.png', file).promise
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
@@ -284,8 +286,8 @@ const uploadFile = async (e: Event) => {
const resetIcon = async () => {
if (data.value?.image) {
try {
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
useState(`server-icon-${props.server.serverId}`).value = undefined
if (data.value) data.value.image = undefined

View File

@@ -78,11 +78,6 @@ const preferences = {
description: 'When enabled, you will be prompted before stopping and restarting your server.',
implemented: true,
},
backupWhileRunning: {
displayName: 'Create backups while running',
description: 'When enabled, backups will be created even if the server is running.',
implemented: true,
},
} as const
type PreferenceKeys = keyof typeof preferences
@@ -96,7 +91,6 @@ const defaultPreferences: UserPreferences = {
hideSubdomainLabel: false,
autoRestart: false,
powerDontAskAgain: false,
backupWhileRunning: false,
}
const userPreferences = useStorage<UserPreferences>(

View File

@@ -1,32 +1,7 @@
<template>
<div class="relative h-full w-full select-none overflow-y-auto">
<div
v-if="server.moduleErrors.fs"
class="flex w-full flex-col items-center justify-center gap-4 p-4"
>
<div class="flex max-w-lg flex-col items-center rounded-3xl bg-bg-raised p-6 shadow-xl">
<div class="flex flex-col items-center text-center">
<div class="flex flex-col items-center gap-4">
<div class="grid place-content-center rounded-full bg-bg-orange p-4">
<IssuesIcon class="size-12 text-orange" />
</div>
<h1 class="m-0 mb-2 w-fit text-4xl font-bold">Failed to load properties</h1>
</div>
<p class="text-lg text-secondary">
We couldn't access your server's properties. Here's what we know:
<span class="break-all font-mono">{{
JSON.stringify(server.moduleErrors.fs.error)
}}</span>
</p>
<ButtonStyled size="large" color="brand" @click="() => server.refresh(['fs'])">
<button class="mt-6 !w-full">Retry</button>
</ButtonStyled>
</div>
</div>
</div>
<div
v-else-if="propsData && status === 'success'"
v-if="propsData && status === 'success'"
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
>
<div class="card flex flex-col gap-4">
@@ -158,8 +133,8 @@
</template>
<script setup lang="ts">
import { EyeIcon, IssuesIcon, SearchIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectNotificationManager } from '@modrinth/ui'
import { EyeIcon, SearchIcon } from '@modrinth/assets'
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, inject, ref, watch } from 'vue'
@@ -167,6 +142,8 @@ import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const props = defineProps<{
server: ModrinthServer
}>()
@@ -181,33 +158,39 @@ const data = computed(() => props.server.general)
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
await modulesLoaded
const rawProps = await props.server.fs?.downloadFile('server.properties')
if (!rawProps) return null
try {
const blob = await client.kyros.files_v0.downloadFile('/server.properties')
const rawProps = await blob.text()
if (!rawProps) return null
const properties: Record<string, any> = {}
const lines = rawProps.split('\n')
const properties: Record<string, any> = {}
const lines = rawProps.split('\n')
for (const line of lines) {
if (line.startsWith('#') || !line.includes('=')) continue
const [key, ...valueParts] = line.split('=')
let value = valueParts.join('=')
for (const line of lines) {
if (line.startsWith('#') || !line.includes('=')) continue
const [key, ...valueParts] = line.split('=')
const rawValue = valueParts.join('=')
let value: string | boolean | number = rawValue
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
value = value.toLowerCase() === 'true'
} else {
const intLike = /^[-+]?\d+$/.test(value)
if (intLike) {
const n = Number(value)
if (Number.isSafeInteger(n)) {
value = n
if (rawValue.toLowerCase() === 'true' || rawValue.toLowerCase() === 'false') {
value = rawValue.toLowerCase() === 'true'
} else {
const intLike = /^[-+]?\d+$/.test(rawValue)
if (intLike) {
const n = Number(rawValue)
if (Number.isSafeInteger(n)) {
value = n
}
}
}
properties[key.trim()] = value
}
properties[key.trim()] = value
return properties
} catch {
return null
}
return properties
})
const liveProperties = ref<Record<string, any>>({})
@@ -302,7 +285,7 @@ const constructServerProperties = (): string => {
const saveProperties = async () => {
try {
isUpdating.value = true
await props.server.fs?.updateFile('server.properties', constructServerProperties())
await client.kyros.files_v0.updateFile('/server.properties', constructServerProperties())
await new Promise((resolve) => setTimeout(resolve, 500))
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
await props.server.refresh()

View File

@@ -2,15 +2,17 @@ import type { InferredClientModules } from '../modules'
import { buildModuleStructure } from '../modules'
import type { ClientConfig } from '../types/client'
import type { RequestContext, RequestOptions } from '../types/request'
import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload'
import type { AbstractFeature } from './abstract-feature'
import type { AbstractModule } from './abstract-module'
import { AbstractUploadClient } from './abstract-upload-client'
import type { AbstractWebSocketClient } from './abstract-websocket'
import { ModrinthApiError, ModrinthServerError } from './errors'
/**
* Abstract base client for Modrinth APIs
*/
export abstract class AbstractModrinthClient {
export abstract class AbstractModrinthClient extends AbstractUploadClient {
protected config: ClientConfig
protected features: AbstractFeature[]
@@ -30,6 +32,7 @@ export abstract class AbstractModrinthClient {
public readonly iso3166!: InferredClientModules['iso3166']
constructor(config: ClientConfig) {
super()
this.config = {
timeout: 10000,
labrinthBaseUrl: 'https://api.modrinth.com',
@@ -176,6 +179,35 @@ export abstract class AbstractModrinthClient {
return next()
}
/**
* Execute the feature chain for an upload
*
* Similar to executeFeatureChain but calls executeXHRUpload at the end.
* This allows features (auth, retry, etc.) to wrap the upload execution.
*/
protected async executeUploadFeatureChain<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T> {
const applicableFeatures = this.features.filter((feature) => feature.shouldApply(context))
let index = applicableFeatures.length
const next = async (): Promise<T> => {
index--
if (index >= 0) {
return applicableFeatures[index].execute(next, context)
} else {
await this.config.hooks?.onRequest?.(context)
return this.executeXHRUpload<T>(context, progressCallbacks, abortController)
}
}
return next()
}
/**
* Build the full URL for a request
*/
@@ -212,6 +244,36 @@ export abstract class AbstractModrinthClient {
}
}
/**
* Build context for an upload request
*
* Sets metadata.isUpload = true so features can detect uploads.
*/
protected buildUploadContext(
url: string,
path: string,
options: UploadRequestOptions,
): RequestContext {
const metadata: UploadMetadata = {
isUpload: true,
file: options.file,
onProgress: options.onProgress,
}
return {
url,
path,
options: {
...options,
method: 'POST',
body: options.file,
},
attempt: 1,
startTime: Date.now(),
metadata,
}
}
/**
* Build default headers for all requests
*
@@ -243,6 +305,23 @@ export abstract class AbstractModrinthClient {
*/
protected abstract executeRequest<T>(url: string, options: RequestOptions): Promise<T>
/**
* Execute the actual XHR upload
*
* This must be implemented by platform clients that support uploads.
* Called at the end of the upload feature chain.
*
* @param context - Request context with upload metadata
* @param progressCallbacks - Callbacks to invoke on progress events
* @param abortController - Controller for cancellation
* @returns Promise resolving to the response data
*/
protected abstract executeXHRUpload<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T>
/**
* Normalize an error into a ModrinthApiError
*

View File

@@ -0,0 +1,21 @@
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
/**
* Abstract base class defining upload capability
*
* All clients that support file uploads must extend this class.
* Platform-specific implementations should provide the actual upload mechanism
* (e.g., XHR for browser environments).
*
* Upload goes through the feature chain (auth, retry, circuit-breaker, etc.)
* just like regular requests.
*/
export abstract class AbstractUploadClient {
/**
* Upload a file with progress tracking
* @param path - API path (e.g., '/fs/create')
* @param options - Upload options including file, api, version
* @returns UploadHandle with promise, onProgress chain, and cancel method
*/
abstract upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T>
}

View File

@@ -0,0 +1,149 @@
import { AbstractFeature, type FeatureConfig } from '../core/abstract-feature'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
/**
* Node authentication credentials
*/
export interface NodeAuth {
/** Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs") */
url: string
/** JWT token */
token: string
}
export interface NodeAuthConfig extends FeatureConfig {
/**
* Get current node auth. Returns null if not authenticated.
*/
getAuth: () => NodeAuth | null
/**
* Refresh the node authentication token.
*/
refreshAuth: () => Promise<void>
}
/**
* Handles authentication for Kyros node fs requests:
* - Automatically injects Authorization header
* - Builds the correct URL from node instance
* - Handles 401 errors by refreshing and retrying (max 3 times)
*
* Only applies to requests with `useNodeAuth: true` in options.
*
* @example
* ```typescript
* const nodeAuth = new NodeAuthFeature({
* getAuth: () => nodeAuthState.getAuth?.() ?? null,
* refreshAuth: async () => {
* if (nodeAuthState.refreshAuth) {
* await nodeAuthState.refreshAuth()
* }
* },
* })
* client.addFeature(nodeAuth)
* ```
*/
export class NodeAuthFeature extends AbstractFeature {
declare protected config: NodeAuthConfig
private refreshPromise: Promise<void> | null = null
shouldApply(context: RequestContext): boolean {
return context.options.useNodeAuth === true && this.config.enabled !== false
}
private async refreshAuthWithLock(): Promise<void> {
if (this.refreshPromise) {
return this.refreshPromise
}
this.refreshPromise = this.config.refreshAuth().finally(() => {
this.refreshPromise = null
})
return this.refreshPromise
}
async execute<T>(next: () => Promise<T>, context: RequestContext): Promise<T> {
const maxRetries = 3
let retryCount = 0
let auth = this.config.getAuth()
if (!auth || this.isTokenExpired(auth.token)) {
await this.refreshAuthWithLock()
auth = this.config.getAuth()
}
if (!auth) {
throw new Error('Failed to obtain node authentication')
}
this.applyAuth(context, auth)
while (true) {
try {
return await next()
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 401) {
retryCount++
if (retryCount >= maxRetries) {
throw new Error(
`Node authentication failed after ${maxRetries} retries. Please re-authenticate.`,
)
}
await this.refreshAuthWithLock()
auth = this.config.getAuth()
if (!auth) {
throw new Error('Failed to refresh node authentication')
}
this.applyAuth(context, auth)
continue
}
throw error
}
}
}
private applyAuth(context: RequestContext, auth: NodeAuth): void {
const baseUrl = `https://${auth.url.replace('v0/fs', '')}`
context.url = this.buildUrl(context.path, baseUrl, context.options.version)
context.options.headers = {
...context.options.headers,
Authorization: `Bearer ${auth.token}`,
}
context.options.skipAuth = true
}
private buildUrl(path: string, baseUrl: string, version: number | 'internal' | string): string {
const base = baseUrl.replace(/\/$/, '')
let versionPath = ''
if (version === 'internal') {
versionPath = '/_internal'
} else if (typeof version === 'number') {
versionPath = `/v${version}`
} else if (typeof version === 'string') {
versionPath = `/${version}`
}
const cleanPath = path.startsWith('/') ? path : `/${path}`
return `${base}${versionPath}${cleanPath}`
}
/**
* Check if a JWT token is expired or about to expire
* Refreshes proactively if expiring within next 10 seconds
*/
private isTokenExpired(token: string): boolean {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
if (!payload.exp) return false
// refresh if expiring within 10 seconds
const expiresAt = payload.exp * 1000
return Date.now() >= expiresAt - 10000
} catch {
// cant decode, assume valid and let server decide
return false
}
}
}

View File

@@ -1,5 +1,6 @@
export { AbstractModrinthClient } from './core/abstract-client'
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
export { AbstractUploadClient } from './core/abstract-upload-client'
export {
AbstractWebSocketClient,
type WebSocketConnection,
@@ -15,6 +16,7 @@ export {
type CircuitBreakerStorage,
InMemoryCircuitBreakerStorage,
} from './features/circuit-breaker'
export { type NodeAuth, type NodeAuthConfig, NodeAuthFeature } from './features/node-auth'
export { PANEL_VERSION, PanelVersionFeature } from './features/panel-version'
export { type BackoffStrategy, type RetryConfig, RetryFeature } from './features/retry'
export { type VerboseLoggingConfig, VerboseLoggingFeature } from './features/verbose-logging'
@@ -25,4 +27,7 @@ export type { NuxtClientConfig } from './platform/nuxt'
export { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt'
export type { TauriClientConfig } from './platform/tauri'
export { TauriModrinthClient } from './platform/tauri'
export { XHRUploadClient } from './platform/xhr-upload-client'
export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/node-auth'
export * from './types'
export { withJWTRetry } from './utils/jwt-retry'

View File

@@ -0,0 +1,56 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Archon } from '../types'
export class ArchonContentV0Module extends AbstractModule {
public getModuleID(): string {
return 'archon_content_v0'
}
/** GET /modrinth/v0/servers/:server_id/mods */
public async list(serverId: string): Promise<Archon.Content.v0.Mod[]> {
return this.client.request<Archon.Content.v0.Mod[]>(`/servers/${serverId}/mods`, {
api: 'archon',
version: 'modrinth/v0',
method: 'GET',
})
}
/** POST /modrinth/v0/servers/:server_id/mods */
public async install(
serverId: string,
request: Archon.Content.v0.InstallModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/mods`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
/** POST /modrinth/v0/servers/:server_id/deleteMod */
public async delete(
serverId: string,
request: Archon.Content.v0.DeleteModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/deleteMod`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
/** POST /modrinth/v0/servers/:server_id/mods/update */
public async update(
serverId: string,
request: Archon.Content.v0.UpdateModRequest,
): Promise<void> {
await this.client.request<void>(`/servers/${serverId}/mods/update`, {
api: 'archon',
version: 'modrinth/v0',
method: 'POST',
body: request,
})
}
}

View File

@@ -1,5 +1,6 @@
export * from './backups/v0'
export * from './backups/v1'
export * from './content/v0'
export * from './servers/v0'
export * from './servers/v1'
export * from './types'

View File

@@ -78,4 +78,20 @@ export class ArchonServersV0Module extends AbstractModule {
method: 'GET',
})
}
/**
* Send a power action to a server (Start, Stop, Restart, Kill)
* POST /modrinth/v0/servers/:id/power
*/
public async power(
serverId: string,
action: 'Start' | 'Stop' | 'Restart' | 'Kill',
): Promise<void> {
await this.client.request(`/servers/${serverId}/power`, {
api: 'archon',
method: 'POST',
version: 'modrinth/v0',
body: { action },
})
}
}

View File

@@ -1,4 +1,40 @@
export namespace Archon {
export namespace Content {
export namespace v0 {
export type ContentKind = 'mod' | 'plugin'
export type Mod = {
filename: string
project_id: string | undefined
version_id: string | undefined
name: string | undefined
version_number: string | undefined
icon_url: string | undefined
owner: string | undefined
disabled: boolean
installing: boolean
}
export type InstallModRequest = {
rinth_ids: {
project_id: string
version_id: string
}
install_as: ContentKind
}
export type DeleteModRequest = {
path: string
}
export type UpdateModRequest = {
replace: string
project_id: string
version_id: string
}
}
}
export namespace Servers {
export namespace v0 {
export type ServerGetResponse = {
@@ -274,6 +310,11 @@ export namespace Archon {
started: string
}
export type QueuedFilesystemOp = {
op: FilesystemOpKind
src: string
}
export type WSFilesystemOpsEvent = {
event: 'filesystem-ops'
all: FilesystemOperation[]

View File

@@ -2,6 +2,7 @@ import type { AbstractModrinthClient } from '../core/abstract-client'
import type { AbstractModule } from '../core/abstract-module'
import { ArchonBackupsV0Module } from './archon/backups/v0'
import { ArchonBackupsV1Module } from './archon/backups/v1'
import { ArchonContentV0Module } from './archon/content/v0'
import { ArchonServersV0Module } from './archon/servers/v0'
import { ArchonServersV1Module } from './archon/servers/v1'
import { ISO3166Module } from './iso3166'
@@ -28,6 +29,7 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
export const MODULE_REGISTRY = {
archon_backups_v0: ArchonBackupsV0Module,
archon_backups_v1: ArchonBackupsV1Module,
archon_content_v0: ArchonContentV0Module,
archon_servers_v0: ArchonServersV0Module,
archon_servers_v1: ArchonServersV1Module,
iso3166_data: ISO3166Module,

View File

@@ -1,4 +1,6 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { UploadHandle, UploadProgress } from '../../../types/upload'
import type { Kyros } from '../types'
export class KyrosFilesV0Module extends AbstractModule {
public getModuleID(): string {
@@ -6,47 +8,189 @@ export class KyrosFilesV0Module extends AbstractModule {
}
/**
* Download a file from a server's filesystem
* List directory contents with pagination
*
* @param nodeInstance - Node instance URL (e.g., "node-xyz.modrinth.com/modrinth/v0/fs")
* @param nodeToken - JWT token from getFilesystemAuth
* @param path - File path (e.g., "/server-icon-original.png")
* @returns Promise resolving to file Blob
* @param path - Directory path (e.g., "/")
* @param page - Page number (1-indexed)
* @param pageSize - Items per page
* @returns Directory listing with items and pagination info
*/
public async downloadFile(nodeInstance: string, nodeToken: string, path: string): Promise<Blob> {
return this.client.request<Blob>(`/fs/download`, {
api: `https://${nodeInstance.replace('v0/fs', '')}`,
method: 'GET',
public async listDirectory(
path: string,
page: number = 1,
pageSize: number = 100,
): Promise<Kyros.Files.v0.DirectoryResponse> {
return this.client.request<Kyros.Files.v0.DirectoryResponse>('/fs/list', {
api: '',
version: 'v0',
params: { path },
headers: { Authorization: `Bearer ${nodeToken}` },
method: 'GET',
params: { path, page, page_size: pageSize },
useNodeAuth: true,
})
}
/**
* Upload a file to a server's filesystem
* Create a file or directory
*
* @param path - Path for new item (e.g., "/new-folder")
* @param type - Type of item to create
*/
public async createFileOrFolder(path: string, type: 'file' | 'directory'): Promise<void> {
return this.client.request<void>('/fs/create', {
api: '',
version: 'v0',
method: 'POST',
params: { path, type },
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
/**
* Download a file from a server's filesystem
*
* @param path - File path (e.g., "/server-icon-original.png")
* @returns Promise resolving to file Blob
*/
public async downloadFile(path: string): Promise<Blob> {
return this.client.request<Blob>('/fs/download', {
api: '',
version: 'v0',
method: 'GET',
params: { path },
useNodeAuth: true,
})
}
/**
* Upload a file to a server's filesystem with progress tracking
*
* @param nodeInstance - Node instance URL
* @param nodeToken - JWT token from getFilesystemAuth
* @param path - Destination path (e.g., "/server-icon.png")
* @param file - File to upload
* @param options - Optional progress callback and feature overrides
* @returns UploadHandle with promise, onProgress, and cancel
*/
public async uploadFile(
nodeInstance: string,
nodeToken: string,
public uploadFile(
path: string,
file: File,
): Promise<void> {
return this.client.request<void>(`/fs/create`, {
api: `https://${nodeInstance.replace('v0/fs', '')}`,
method: 'POST',
file: File | Blob,
options?: {
onProgress?: (progress: UploadProgress) => void
retry?: boolean | number
},
): UploadHandle<void> {
return this.client.upload<void>('/fs/create', {
api: '',
version: 'v0',
file,
params: { path, type: 'file' },
headers: {
Authorization: `Bearer ${nodeToken}`,
'Content-Type': 'application/octet-stream',
},
body: file,
onProgress: options?.onProgress,
retry: options?.retry,
useNodeAuth: true,
})
}
/**
* Update file contents
*
* @param path - File path to update
* @param content - New file content (string or Blob)
*/
public async updateFile(path: string, content: string | Blob): Promise<void> {
const blob = typeof content === 'string' ? new Blob([content]) : content
return this.client.request<void>('/fs/update', {
api: '',
version: 'v0',
method: 'PUT',
params: { path },
body: blob,
headers: { 'Content-Type': 'application/octet-stream' },
useNodeAuth: true,
})
}
/**
* Move a file or folder to a new location
*
* @param sourcePath - Current path
* @param destPath - New path
*/
public async moveFileOrFolder(sourcePath: string, destPath: string): Promise<void> {
return this.client.request<void>('/fs/move', {
api: '',
version: 'v0',
method: 'POST',
body: { source: sourcePath, destination: destPath },
useNodeAuth: true,
})
}
/**
* Rename a file or folder (convenience wrapper around move)
*
* @param path - Current file/folder path
* @param newName - New name (not full path)
*/
public async renameFileOrFolder(path: string, newName: string): Promise<void> {
const newPath = path.split('/').slice(0, -1).join('/') + '/' + newName
return this.moveFileOrFolder(path, newPath)
}
/**
* Delete a file or folder
*
* @param path - Path to delete
* @param recursive - If true, delete directory contents recursively
*/
public async deleteFileOrFolder(path: string, recursive: boolean): Promise<void> {
return this.client.request<void>('/fs/delete', {
api: '',
version: 'v0',
method: 'DELETE',
params: { path, recursive },
useNodeAuth: true,
})
}
/**
* Extract an archive file (zip, tar, etc.)
*
* Uses v1 API endpoint.
*
* @param path - Path to archive file
* @param override - If true, overwrite existing files
* @param dry - If true, perform dry run (returns conflicts without extracting)
* @returns Extract result with modpack name and conflicting files
*/
public async extractFile(
path: string,
override: boolean = true,
dry: boolean = false,
): Promise<Kyros.Files.v0.ExtractResult> {
return this.client.request<Kyros.Files.v0.ExtractResult>('/fs/unarchive', {
api: '',
version: 'v1',
method: 'POST',
params: { src: path, trg: '/', override, dry },
useNodeAuth: true,
})
}
/**
* Modify a filesystem operation (dismiss or cancel)
*
* Uses v1 API endpoint.
*
* @param opId - Operation ID (UUID)
* @param action - Action to perform
*/
public async modifyOperation(opId: string, action: 'dismiss' | 'cancel'): Promise<void> {
return this.client.request<void>(`/fs/ops/${action}`, {
api: '',
version: 'v1',
method: 'POST',
params: { id: opId },
useNodeAuth: true,
})
}
}

View File

@@ -1,7 +1,27 @@
export namespace Kyros {
export namespace Files {
export namespace v0 {
// Empty for now
export interface DirectoryItem {
name: string
type: 'file' | 'directory' | 'symlink'
path: string
modified: number
created: number
size?: number
count?: number
target?: string
}
export interface DirectoryResponse {
items: DirectoryItem[]
total: number
current: number
}
export interface ExtractResult {
modpack_name: string | null
conflicting_files: string[]
}
}
}
}

View File

@@ -1,10 +1,10 @@
import { $fetch, FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Generic platform client using ofetch
@@ -24,7 +24,7 @@ import { GenericWebSocketClient } from './websocket-generic'
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class GenericModrinthClient extends AbstractModrinthClient {
export class GenericModrinthClient extends XHRUploadClient {
constructor(config: ClientConfig) {
super(config)

View File

@@ -1,11 +1,12 @@
import { FetchError } from 'ofetch'
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import { ModrinthApiError } from '../core/errors'
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Circuit breaker storage using Nuxt's useState
@@ -53,6 +54,8 @@ export interface NuxtClientConfig extends ClientConfig {
*
* This client is optimized for Nuxt applications and handles SSR/CSR automatically.
*
* Note: upload() is only available in browser context (CSR). It will throw during SSR.
*
* @example
* ```typescript
* // In a Nuxt composable
@@ -70,7 +73,7 @@ export interface NuxtClientConfig extends ClientConfig {
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class NuxtModrinthClient extends AbstractModrinthClient {
export class NuxtModrinthClient extends XHRUploadClient {
declare protected config: NuxtClientConfig
constructor(config: NuxtClientConfig) {
@@ -84,6 +87,20 @@ export class NuxtModrinthClient extends AbstractModrinthClient {
})
}
/**
* Upload a file with progress tracking
*
* Note: This method is only available in browser context (CSR).
* Calling during SSR will throw an error.
*/
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
// @ts-expect-error - import.meta is provided by Nuxt
if (import.meta.server) {
throw new ModrinthApiError('upload() is not supported during SSR')
}
return super.upload(path, options)
}
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
try {
// @ts-expect-error - $fetch is provided by Nuxt runtime

View File

@@ -1,8 +1,8 @@
import { AbstractModrinthClient } from '../core/abstract-client'
import type { ModrinthApiError } from '../core/errors'
import type { ClientConfig } from '../types/client'
import type { RequestOptions } from '../types/request'
import { GenericWebSocketClient } from './websocket-generic'
import { XHRUploadClient } from './xhr-upload-client'
/**
* Tauri-specific configuration
@@ -20,7 +20,9 @@ interface HttpError extends Error {
/**
* Tauri platform client using Tauri v2 HTTP plugin
*
* Extends XHRUploadClient to provide upload with progress tracking.
*
* @example
* ```typescript
* import { getVersion } from '@tauri-apps/api/app'
@@ -36,7 +38,7 @@ interface HttpError extends Error {
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
* ```
*/
export class TauriModrinthClient extends AbstractModrinthClient {
export class TauriModrinthClient extends XHRUploadClient {
declare protected config: TauriClientConfig
constructor(config: TauriClientConfig) {

View File

@@ -0,0 +1,142 @@
import { AbstractModrinthClient } from '../core/abstract-client'
import { ModrinthApiError } from '../core/errors'
import type { RequestContext } from '../types/request'
import type {
UploadHandle,
UploadMetadata,
UploadProgress,
UploadRequestOptions,
} from '../types/upload'
/**
* Abstract client with XHR-based upload implementation
*
* Platform-specific clients should extend this instead of AbstractModrinthClient
* to inherit the XHR upload implementation.
*/
export abstract class XHRUploadClient extends AbstractModrinthClient {
upload<T = void>(path: string, options: UploadRequestOptions): UploadHandle<T> {
let baseUrl: string
if (options.api === 'labrinth') {
baseUrl = this.config.labrinthBaseUrl!
} else if (options.api === 'archon') {
baseUrl = this.config.archonBaseUrl!
} else {
baseUrl = options.api
}
const url = this.buildUrl(path, baseUrl, options.version)
const mergedOptions: UploadRequestOptions = {
retry: false, // default: don't retry uploads
...options,
headers: {
...this.buildDefaultHeaders(),
'Content-Type': 'application/octet-stream',
...options.headers,
},
}
const context = this.buildUploadContext(url, path, mergedOptions)
const progressCallbacks: Array<(p: UploadProgress) => void> = []
if (mergedOptions.onProgress) {
progressCallbacks.push(mergedOptions.onProgress)
}
const abortController = new AbortController()
if (mergedOptions.signal) {
mergedOptions.signal.addEventListener('abort', () => abortController.abort())
}
const handle: UploadHandle<T> = {
promise: this.executeUploadFeatureChain<T>(context, progressCallbacks, abortController)
.then(async (result) => {
await this.config.hooks?.onResponse?.(result, context)
return result
})
.catch(async (error) => {
const apiError = this.normalizeError(error, context)
await this.config.hooks?.onError?.(apiError, context)
throw apiError
}),
onProgress: (callback) => {
progressCallbacks.push(callback)
return handle
},
cancel: () => abortController.abort(),
}
return handle
}
protected executeXHRUpload<T>(
context: RequestContext,
progressCallbacks: Array<(p: UploadProgress) => void>,
abortController: AbortController,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
const xhr = new XMLHttpRequest()
const metadata = context.metadata as UploadMetadata
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const progress: UploadProgress = {
loaded: e.loaded,
total: e.total,
progress: e.loaded / e.total,
}
progressCallbacks.forEach((cb) => cb(progress))
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(xhr.response ? JSON.parse(xhr.response) : (undefined as T))
} catch {
resolve(undefined as T)
}
} else {
reject(this.createUploadError(xhr))
}
})
xhr.addEventListener('error', () => reject(new ModrinthApiError('Upload failed')))
xhr.addEventListener('abort', () => reject(new ModrinthApiError('Upload cancelled')))
// build URL with params (unlike $fetch, XHR doesn't handle params automatically)
let url = context.url
if (context.options.params) {
const queryString = new URLSearchParams(
Object.entries(context.options.params).map(([k, v]) => [k, String(v)]),
).toString()
url += (url.includes('?') ? '&' : '?') + queryString
}
xhr.open('POST', url)
// apply headers from context (features may have modified them)
for (const [key, value] of Object.entries(context.options.headers ?? {})) {
xhr.setRequestHeader(key, value)
}
xhr.send(metadata.file)
abortController.signal.addEventListener('abort', () => xhr.abort())
})
}
protected createUploadError(xhr: XMLHttpRequest): ModrinthApiError {
let responseData: unknown
try {
responseData = xhr.response ? JSON.parse(xhr.response) : undefined
} catch {
responseData = xhr.responseText
}
return this.createNormalizedError(
new Error(`Upload failed with status ${xhr.status}`),
xhr.status,
responseData,
)
}
}

View File

@@ -0,0 +1,45 @@
import type { NodeAuth } from '../features/node-auth'
/**
* Global node auth state.
* Set by server management pages, read by NodeAuthFeature.
*/
export const nodeAuthState = {
getAuth: null as (() => NodeAuth | null) | null,
refreshAuth: null as (() => Promise<void>) | null,
}
/**
* Configure the node auth state. Call this when entering server management.
*
* @param getAuth - Function that returns current auth or null
* @param refreshAuth - Function to refresh the auth token
*
* @example
* ```typescript
* // In server management page setup
* setNodeAuthState(
* () => fsAuth.value,
* refreshFsAuth,
* )
* ```
*/
export function setNodeAuthState(getAuth: () => NodeAuth | null, refreshAuth: () => Promise<void>) {
nodeAuthState.getAuth = getAuth
nodeAuthState.refreshAuth = refreshAuth
}
/**
* Clear the node auth state. Call this when leaving server management.
*
* @example
* ```typescript
* onUnmounted(() => {
* clearNodeAuthState()
* })
* ```
*/
export function clearNodeAuthState() {
nodeAuthState.getAuth = null
nodeAuthState.refreshAuth = null
}

View File

@@ -11,3 +11,4 @@ export type { ClientConfig, RequestHooks } from './client'
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
export { isModrinthErrorResponse } from './errors'
export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request'
export type { UploadHandle, UploadMetadata, UploadProgress, UploadRequestOptions } from './upload'

View File

@@ -73,6 +73,13 @@ export type RequestOptions = {
* @default false
*/
skipAuth?: boolean
/**
* Use node authentication for this request.
* When true, NodeAuthFeature will handle auth injection and URL building.
* @default false
*/
useNodeAuth?: boolean
}
/**
@@ -106,6 +113,13 @@ export type RequestContext = {
/**
* Additional metadata that features can attach
*
* For uploads, this contains:
* - isUpload: true
* - file: File | Blob being uploaded
* - onProgress: progress callback (if provided)
*
* Features can check `context.metadata?.isUpload` to detect uploads.
*/
metadata?: Record<string, unknown>
}

View File

@@ -0,0 +1,51 @@
import type { RequestOptions } from './request'
/**
* Progress information for file uploads
*/
export interface UploadProgress {
/** Bytes uploaded so far */
loaded: number
/** Total bytes to upload */
total: number
/** Progress as a decimal (0-1) */
progress: number
}
/**
* Options for upload requests (matches request() style)
*
* Extends RequestOptions but excludes body and method since those
* are determined by the upload itself.
*/
export interface UploadRequestOptions extends Omit<RequestOptions, 'body' | 'method'> {
/** File or Blob to upload */
file: File | Blob
/** Callback for progress updates */
onProgress?: (progress: UploadProgress) => void
}
/**
* Metadata attached to upload contexts
*
* Features can check `context.metadata?.isUpload` to detect uploads.
*/
export interface UploadMetadata extends Record<string, unknown> {
isUpload: true
file: File | Blob
onProgress?: (progress: UploadProgress) => void
}
/**
* Handle returned from upload operations
*
* Provides the upload promise, progress subscription, and cancellation.
*/
export interface UploadHandle<T> {
/** Promise that resolves when upload completes */
promise: Promise<T>
/** Subscribe to progress updates (chainable) */
onProgress: (callback: (progress: UploadProgress) => void) => UploadHandle<T>
/** Cancel the upload */
cancel: () => void
}

View File

@@ -0,0 +1,20 @@
import { ModrinthApiError } from '../core/errors'
/**
* Wrap a function with JWT retry logic.
* On 401, calls refreshToken() and retries once.
*/
export async function withJWTRetry<T>(
fn: () => Promise<T>,
refreshToken: () => Promise<void>,
): Promise<T> {
try {
return await fn()
} catch (error) {
if (error instanceof ModrinthApiError && error.statusCode === 401) {
await refreshToken()
return await fn()
}
throw error
}
}

View File

@@ -26,6 +26,7 @@ import _BookmarkIcon from './icons/bookmark.svg?component'
import _BotIcon from './icons/bot.svg?component'
import _BoxIcon from './icons/box.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component'
import _BoxesIcon from './icons/boxes.svg?component'
import _BracesIcon from './icons/braces.svg?component'
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
import _BugIcon from './icons/bug.svg?component'
@@ -39,6 +40,7 @@ import _CheckCircleIcon from './icons/check-circle.svg?component'
import _ChevronDownIcon from './icons/chevron-down.svg?component'
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
import _ChevronRightIcon from './icons/chevron-right.svg?component'
import _ChevronUpIcon from './icons/chevron-up.svg?component'
import _CircleUserIcon from './icons/circle-user.svg?component'
import _ClearIcon from './icons/clear.svg?component'
import _ClientIcon from './icons/client.svg?component'
@@ -61,6 +63,7 @@ import _CubeIcon from './icons/cube.svg?component'
import _CurrencyIcon from './icons/currency.svg?component'
import _DashboardIcon from './icons/dashboard.svg?component'
import _DatabaseIcon from './icons/database.svg?component'
import _DatabaseBackupIcon from './icons/database-backup.svg?component'
import _DownloadIcon from './icons/download.svg?component'
import _DropdownIcon from './icons/dropdown.svg?component'
import _EditIcon from './icons/edit.svg?component'
@@ -78,6 +81,7 @@ import _FilterIcon from './icons/filter.svg?component'
import _FilterXIcon from './icons/filter-x.svg?component'
import _FolderIcon from './icons/folder.svg?component'
import _FolderArchiveIcon from './icons/folder-archive.svg?component'
import _FolderCogIcon from './icons/folder-cog.svg?component'
import _FolderOpenIcon from './icons/folder-open.svg?component'
import _FolderSearchIcon from './icons/folder-search.svg?component'
import _FolderUpIcon from './icons/folder-up.svg?component'
@@ -112,6 +116,7 @@ import _KeyIcon from './icons/key.svg?component'
import _KeyboardIcon from './icons/keyboard.svg?component'
import _LandmarkIcon from './icons/landmark.svg?component'
import _LanguagesIcon from './icons/languages.svg?component'
import _LayoutTemplateIcon from './icons/layout-template.svg?component'
import _LeftArrowIcon from './icons/left-arrow.svg?component'
import _LibraryIcon from './icons/library.svg?component'
import _LightBulbIcon from './icons/light-bulb.svg?component'
@@ -149,6 +154,7 @@ import _PackageIcon from './icons/package.svg?component'
import _PackageClosedIcon from './icons/package-closed.svg?component'
import _PackageOpenIcon from './icons/package-open.svg?component'
import _PaintbrushIcon from './icons/paintbrush.svg?component'
import _PaletteIcon from './icons/palette.svg?component'
import _PickaxeIcon from './icons/pickaxe.svg?component'
import _PlayIcon from './icons/play.svg?component'
import _PlugIcon from './icons/plug.svg?component'
@@ -252,6 +258,7 @@ export const BookmarkIcon = _BookmarkIcon
export const BotIcon = _BotIcon
export const BoxIcon = _BoxIcon
export const BoxImportIcon = _BoxImportIcon
export const BoxesIcon = _BoxesIcon
export const BracesIcon = _BracesIcon
export const BrushCleaningIcon = _BrushCleaningIcon
export const BugIcon = _BugIcon
@@ -265,6 +272,7 @@ export const CheckCircleIcon = _CheckCircleIcon
export const ChevronDownIcon = _ChevronDownIcon
export const ChevronLeftIcon = _ChevronLeftIcon
export const ChevronRightIcon = _ChevronRightIcon
export const ChevronUpIcon = _ChevronUpIcon
export const CircleUserIcon = _CircleUserIcon
export const ClearIcon = _ClearIcon
export const ClientIcon = _ClientIcon
@@ -287,6 +295,7 @@ export const CubeIcon = _CubeIcon
export const CurrencyIcon = _CurrencyIcon
export const DashboardIcon = _DashboardIcon
export const DatabaseIcon = _DatabaseIcon
export const DatabaseBackupIcon = _DatabaseBackupIcon
export const DownloadIcon = _DownloadIcon
export const DropdownIcon = _DropdownIcon
export const EditIcon = _EditIcon
@@ -304,6 +313,7 @@ export const FilterIcon = _FilterIcon
export const FilterXIcon = _FilterXIcon
export const FolderIcon = _FolderIcon
export const FolderArchiveIcon = _FolderArchiveIcon
export const FolderCogIcon = _FolderCogIcon
export const FolderOpenIcon = _FolderOpenIcon
export const FolderSearchIcon = _FolderSearchIcon
export const FolderUpIcon = _FolderUpIcon
@@ -338,6 +348,7 @@ export const KeyIcon = _KeyIcon
export const KeyboardIcon = _KeyboardIcon
export const LandmarkIcon = _LandmarkIcon
export const LanguagesIcon = _LanguagesIcon
export const LayoutTemplateIcon = _LayoutTemplateIcon
export const LeftArrowIcon = _LeftArrowIcon
export const LibraryIcon = _LibraryIcon
export const LightBulbIcon = _LightBulbIcon
@@ -375,6 +386,7 @@ export const PackageIcon = _PackageIcon
export const PackageClosedIcon = _PackageClosedIcon
export const PackageOpenIcon = _PackageOpenIcon
export const PaintbrushIcon = _PaintbrushIcon
export const PaletteIcon = _PaletteIcon
export const PickaxeIcon = _PickaxeIcon
export const PlayIcon = _PlayIcon
export const PlugIcon = _PlugIcon

View File

@@ -0,0 +1,26 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-boxes"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z" />
<path d="m7 16.5-4.74-2.85" />
<path d="m7 16.5 5-3" />
<path d="M7 16.5v5.17" />
<path d="M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z" />
<path d="m17 16.5-5-3" />
<path d="m17 16.5 4.74-2.85" />
<path d="M17 16.5v5.17" />
<path d="M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z" />
<path d="M12 8 7.26 5.15" />
<path d="m12 8 4.74-2.85" />
<path d="M12 13.5V8" />
</svg>

After

Width:  |  Height:  |  Size: 901 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-chevron-up-icon lucide-chevron-up">
<path d="m18 15-6-6-6 6" />
</svg>

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,20 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-database-backup"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 12a9 3 0 0 0 5 2.69" />
<path d="M21 9.3V5" />
<path d="M3 5v14a9 3 0 0 0 6.47 2.88" />
<path d="M12 12v4h4" />
<path d="M13 20a5 5 0 0 0 9-3 4.5 4.5 0 0 0-4.5-4.5c-1.33 0-2.54.54-3.41 1.41L12 16" />
</svg>

After

Width:  |  Height:  |  Size: 560 B

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-folder-cog-icon lucide-folder-cog">
<path d="M10.3 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.98a2 2 0 0 1 1.69.9l.66 1.2A2 2 0 0 0 12 6h8a2 2 0 0 1 2 2v3.3" />
<path d="m14.305 19.53.923-.382" />
<path d="m15.228 16.852-.923-.383" />
<path d="m16.852 15.228-.383-.923" />
<path d="m16.852 20.772-.383.924" />
<path d="m19.148 15.228.383-.923" />
<path d="m19.53 21.696-.382-.924" />
<path d="m20.772 16.852.924-.383" />
<path d="m20.772 19.148.924.383" />
<circle cx="18" cy="18" r="3" />
</svg>

After

Width:  |  Height:  |  Size: 705 B

View File

@@ -0,0 +1,17 @@
<!-- @license lucide-static v0.562.0 - ISC -->
<svg
class="lucide lucide-layout-template"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect width="18" height="7" x="3" y="3" rx="1" />
<rect width="9" height="7" x="3" y="14" rx="1" />
<rect width="5" height="7" x="16" y="14" rx="1" />
</svg>

After

Width:  |  Height:  |  Size: 452 B

View File

Before

Width:  |  Height:  |  Size: 619 B

After

Width:  |  Height:  |  Size: 619 B

View File

@@ -0,0 +1,211 @@
.ace-modrinth .ace_gutter {
background: var(--surface-2);
color: var(--color-text-tertiary);
}
.ace-modrinth .ace_print-margin {
width: 1px;
background: var(--surface-5);
}
.ace-modrinth {
background-color: var(--surface-3);
color: var(--color-text-default);
}
.ace-modrinth .ace_cursor {
color: var(--color-brand);
}
.ace-modrinth .ace_marker-layer .ace_selection {
background: var(--color-brand-highlight);
}
.ace-modrinth.ace_multiselect .ace_selection.ace_start {
box-shadow: 0 0 3px 0 var(--surface-3);
}
.ace-modrinth .ace_marker-layer .ace_step {
background: var(--color-orange-highlight);
}
.ace-modrinth .ace_marker-layer .ace_bracket {
margin: -1px 0 0 -1px;
border: 1px solid var(--color-brand);
}
.ace-modrinth .ace_marker-layer .ace_active-line {
background: var(--surface-4);
}
.ace-modrinth .ace_gutter-active-line {
background-color: var(--surface-1);
}
.ace-modrinth .ace_marker-layer .ace_selected-word {
border: 1px solid var(--color-brand-highlight);
}
.ace-modrinth .ace_invisible {
color: var(--color-text-tertiary);
opacity: 0.5;
}
.ace-modrinth .ace_fold {
background-color: var(--color-blue);
border-color: var(--color-text-default);
}
/* Comments */
.ace-modrinth .ace_comment {
color: var(--color-gray);
font-style: italic;
}
/* Strings */
.ace-modrinth .ace_string {
color: var(--color-green);
}
.ace-modrinth .ace_string.ace_regexp {
color: var(--color-orange);
}
/* Constants */
.ace-modrinth .ace_constant.ace_numeric {
color: var(--color-purple);
}
.ace-modrinth .ace_constant.ace_language {
color: var(--color-orange);
}
.ace-modrinth .ace_constant.ace_character {
color: var(--color-orange);
}
.ace-modrinth .ace_constant.ace_other {
color: var(--color-purple);
}
/* Keywords */
.ace-modrinth .ace_keyword {
color: var(--color-red);
}
.ace-modrinth .ace_keyword.ace_operator {
color: var(--color-text-default);
}
/* Storage */
.ace-modrinth .ace_storage {
color: var(--color-red);
}
.ace-modrinth .ace_storage.ace_type {
color: var(--color-blue);
}
/* Entity names */
.ace-modrinth .ace_entity.ace_name.ace_function {
color: var(--color-blue);
}
.ace-modrinth .ace_entity.ace_name.ace_class {
color: var(--color-purple);
}
.ace-modrinth .ace_entity.ace_name.ace_tag {
color: var(--color-red);
}
.ace-modrinth .ace_entity.ace_other.ace_attribute-name {
color: var(--color-orange);
}
/* Variables */
.ace-modrinth .ace_variable {
color: var(--color-text-default);
}
.ace-modrinth .ace_variable.ace_parameter {
color: var(--color-text-default);
font-style: italic;
}
.ace-modrinth .ace_variable.ace_language {
color: var(--color-orange);
}
/* Support */
.ace-modrinth .ace_support.ace_function {
color: var(--color-blue);
}
.ace-modrinth .ace_support.ace_class {
color: var(--color-purple);
}
.ace-modrinth .ace_support.ace_type {
color: var(--color-blue);
}
.ace-modrinth .ace_support.ace_constant {
color: var(--color-purple);
}
/* Invalid */
.ace-modrinth .ace_invalid {
color: var(--color-text-primary);
background-color: var(--color-red-bg);
}
.ace-modrinth .ace_invalid.ace_deprecated {
color: var(--color-text-primary);
background-color: var(--color-orange-bg);
}
/* Punctuation */
.ace-modrinth .ace_punctuation {
color: var(--color-text-tertiary);
}
/* Meta */
.ace-modrinth .ace_meta.ace_tag {
color: var(--color-text-default);
}
/* Markup */
.ace-modrinth .ace_markup.ace_heading {
color: var(--color-red);
font-weight: bold;
}
.ace-modrinth .ace_markup.ace_list {
color: var(--color-orange);
}
.ace-modrinth .ace_markup.ace_bold {
font-weight: bold;
}
.ace-modrinth .ace_markup.ace_italic {
font-style: italic;
}
.ace-modrinth .ace_markup.ace_underline {
text-decoration: underline;
}
/* Indent guide */
.ace-modrinth .ace_indent-guide {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHB3d/8PAAOIAdULw8qMAAAAAElFTkSuQmCC)
right repeat-y;
opacity: 0.2;
}
.ace-modrinth .ace_indent-guide-active {
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWNgYGBgYHB3d/8PAAOIAdULw8qMAAAAAElFTkSuQmCC)
right repeat-y;
opacity: 0.4;
}

View File

@@ -70,6 +70,7 @@
"@types/three": "^0.172.0",
"@vintl/how-ago": "^3.0.1",
"@vueuse/core": "^11.1.0",
"ace-builds": "^1.43.5",
"apexcharts": "^4.0.0",
"dayjs": "^1.11.10",
"floating-vue": "^5.2.2",
@@ -82,6 +83,7 @@
"vue-multiselect": "3.0.0",
"vue-select": "4.0.0-beta.6",
"vue-typed-virtual-list": "^1.0.10",
"vue3-ace-editor": "^2.2.4",
"vue3-apexcharts": "^1.4.4",
"xss": "^1.0.14"
},

View 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>

View File

@@ -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>

View File

@@ -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'

View 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>

View 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>

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
export { default as FileEditor } from './FileEditor.vue'
export { default as FileImageViewer } from './FileImageViewer.vue'

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'

View File

@@ -0,0 +1,5 @@
export * from './editor'
export * from './explorer'
export { default as FileNavbar } from './FileNavbar.vue'
export * from './modals'
export * from './upload'

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View 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'

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,2 @@
export { default as FileUploadDragAndDrop } from './FileUploadDragAndDrop.vue'
export { default as FileUploadDropdown } from './FileUploadDropdown.vue'

View File

@@ -1,4 +1,5 @@
export * from './backups'
export * from './files'
export * from './icons'
export * from './labels'
export * from './marketing'

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View File

@@ -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'

View File

@@ -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] =

View 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)
},
)

View 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'
}
}

View File

@@ -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'

View File

@@ -16,4 +16,4 @@ export interface ModuleError {
timestamp: number
}
export type ModuleName = 'general' | 'content' | 'backups' | 'network' | 'startup' | 'ws' | 'fs'
export type ModuleName = 'general' | 'content' | 'network' | 'startup'

6
pnpm-lock.yaml generated
View File

@@ -613,6 +613,9 @@ importers:
'@vueuse/core':
specifier: ^11.1.0
version: 11.3.0(vue@3.5.26(typescript@5.9.3))
ace-builds:
specifier: ^1.43.5
version: 1.43.5
apexcharts:
specifier: ^4.0.0
version: 4.7.0
@@ -655,6 +658,9 @@ importers:
vue-typed-virtual-list:
specifier: ^1.0.10
version: 1.0.10(vue@3.5.26(typescript@5.9.3))
vue3-ace-editor:
specifier: ^2.2.4
version: 2.2.4(ace-builds@1.43.5)(vue@3.5.26(typescript@5.9.3))
vue3-apexcharts:
specifier: ^1.4.4
version: 1.10.0(apexcharts@4.7.0)(vue@3.5.26(typescript@5.9.3))