You've already forked AstralRinth
forked from didirus/AstralRinth
feat: modrinth hosting - files tab refactor (#4912)
* feat: api-client module for content v0 * feat: delete unused components + modules + setting * feat: xhr uploading * feat: fs module -> api-client * feat: migrate files.vue to use tanstack * fix: mem leak + other issues * fix: build * feat: switch to monaco * fix: go back to using ace, but improve preloading + theme * fix: styling + dead attrs * feat: match figma * fix: padding * feat: files-new for ui page structure * feat: finalize files.vue * fix: lint * fix: qa * fix: dep * fix: lint * fix: lockfile merge * feat: icons on navtab * fix: surface alternating on table * fix: hover surface color --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-palette" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 21a9 9 0 0 1 0 -18c4.97 0 9 3.582 9 8c0 1.06 -.474 2.078 -1.318 2.828c-.844 .75 -1.989 1.172 -3.182 1.172h-2.5a2 2 0 0 0 -1 3.75a1.3 1.3 0 0 1 -1 2.25" /><path d="M8.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M12.5 7.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /><path d="M16.5 10.5m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0" /></svg>
|
||||
|
Before Width: | Height: | Size: 619 B |
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
260
apps/frontend/src/components/ui/servers/FilesEditor.vue
Normal file
260
apps/frontend/src/components/ui/servers/FilesEditor.vue
Normal 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>
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 = []
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user