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>
@@ -49,7 +49,6 @@
|
|||||||
"@vue-email/components": "^0.0.21",
|
"@vue-email/components": "^0.0.21",
|
||||||
"@vue-email/render": "^0.0.9",
|
"@vue-email/render": "^0.0.9",
|
||||||
"@vueuse/core": "^11.1.0",
|
"@vueuse/core": "^11.1.0",
|
||||||
"ace-builds": "^1.36.2",
|
|
||||||
"ansi-to-html": "^0.7.2",
|
"ansi-to-html": "^0.7.2",
|
||||||
"dayjs": "^1.11.7",
|
"dayjs": "^1.11.7",
|
||||||
"dompurify": "^3.1.7",
|
"dompurify": "^3.1.7",
|
||||||
@@ -61,6 +60,7 @@
|
|||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"jszip": "^3.10.1",
|
"jszip": "^3.10.1",
|
||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
|
"ace-builds": "^1.36.2",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"pinia": "^3.0.0",
|
"pinia": "^3.0.0",
|
||||||
"pinia-plugin-persistedstate": "^4.4.1",
|
"pinia-plugin-persistedstate": "^4.4.1",
|
||||||
|
|||||||
@@ -309,14 +309,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { Button, Card, DropdownSelect } from '@modrinth/ui'
|
||||||
import { formatCategoryHeader, formatMoney, formatNumber } from '@modrinth/utils'
|
import { formatCategoryHeader, formatMoney, formatNumber } from '@modrinth/utils'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
|
||||||
import { UiChartsChart as Chart, UiChartsCompactChart as CompactChart } from '#components'
|
import { UiChartsChart as Chart, UiChartsCompactChart as CompactChart } from '#components'
|
||||||
import PaletteIcon from '~/assets/icons/palette.svg?component'
|
|
||||||
import {
|
import {
|
||||||
analyticsSetToCSVString,
|
analyticsSetToCSVString,
|
||||||
countryCodeToFlag,
|
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>
|
<template>
|
||||||
<li
|
<li
|
||||||
role="button"
|
role="button"
|
||||||
data-pyro-file
|
|
||||||
:class="[
|
:class="[
|
||||||
containerClasses,
|
containerClasses,
|
||||||
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
|
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
|
||||||
@@ -12,6 +11,7 @@
|
|||||||
@click="selectItem"
|
@click="selectItem"
|
||||||
@contextmenu="openContextMenu"
|
@contextmenu="openContextMenu"
|
||||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
@dragstart="handleDragStart"
|
@dragstart="handleDragStart"
|
||||||
@dragend="handleDragEnd"
|
@dragend="handleDragEnd"
|
||||||
@dragenter.prevent="handleDragEnter"
|
@dragenter.prevent="handleDragEnter"
|
||||||
@@ -19,35 +19,32 @@
|
|||||||
@dragleave.prevent="handleDragLeave"
|
@dragleave.prevent="handleDragLeave"
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
>
|
>
|
||||||
<div
|
<div class="pointer-events-none flex flex-1 items-center gap-3 truncate">
|
||||||
data-pyro-file-metadata
|
<Checkbox
|
||||||
class="pointer-events-none flex w-full items-center gap-4 truncate"
|
class="pointer-events-auto"
|
||||||
>
|
:model-value="selected"
|
||||||
<div
|
@click.stop
|
||||||
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"
|
@update:model-value="emit('toggle-select')"
|
||||||
:class="isEditableFile ? 'group-active:scale-[0.8]' : ''"
|
/>
|
||||||
>
|
<div class="pointer-events-none flex size-5 items-center justify-center">
|
||||||
<component :is="iconComponent" class="size-6" />
|
<component :is="iconComponent" class="size-5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="pointer-events-none flex w-full flex-col truncate">
|
<div class="pointer-events-none flex flex-col truncate">
|
||||||
<span
|
<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 }}
|
{{ name }}
|
||||||
</span>
|
</span>
|
||||||
<span class="pointer-events-none text-xs text-secondary group-hover:text-primary">
|
|
||||||
{{ subText }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
||||||
data-pyro-file-actions
|
<span class="hidden w-[100px] text-nowrap text-sm text-secondary md:block">
|
||||||
class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12"
|
{{ formattedSize }}
|
||||||
>
|
</span>
|
||||||
<span class="hidden w-[160px] text-nowrap font-mono text-sm text-secondary md:flex">
|
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||||
{{ formattedCreationDate }}
|
{{ formattedCreationDate }}
|
||||||
</span>
|
</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 }}
|
{{ formattedModifiedDate }}
|
||||||
</span>
|
</span>
|
||||||
<ButtonStyled circular type="transparent">
|
<ButtonStyled circular type="transparent">
|
||||||
@@ -71,22 +68,23 @@ import {
|
|||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
PackageOpenIcon,
|
PackageOpenIcon,
|
||||||
|
PaletteIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import {
|
import {
|
||||||
ButtonStyled,
|
ButtonStyled,
|
||||||
CODE_EXTENSIONS,
|
Checkbox,
|
||||||
|
getFileExtension,
|
||||||
getFileExtensionIcon,
|
getFileExtensionIcon,
|
||||||
IMAGE_EXTENSIONS,
|
isEditableFile as isEditableFileExt,
|
||||||
TEXT_EXTENSIONS,
|
isImageFile,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { computed, h, ref, shallowRef } from 'vue'
|
import { computed, h, ref, shallowRef } from 'vue'
|
||||||
import { renderToString } from 'vue/server-renderer'
|
import { renderToString } from 'vue/server-renderer'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
|
import { UiServersIconsCogFolderIcon, UiServersIconsEarthIcon } from '#components'
|
||||||
import PaletteIcon from '~/assets/icons/palette.svg?component'
|
|
||||||
|
|
||||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||||
|
|
||||||
@@ -98,17 +96,21 @@ interface FileItemProps {
|
|||||||
modified: number
|
modified: number
|
||||||
created: number
|
created: number
|
||||||
path: string
|
path: string
|
||||||
|
index: number
|
||||||
|
isLast: boolean
|
||||||
|
selected: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<FileItemProps>()
|
const props = defineProps<FileItemProps>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 },
|
item: { name: string; type: string; path: string },
|
||||||
): void
|
): void
|
||||||
(e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void
|
(e: 'moveDirectTo', item: { name: string; type: string; path: string; destination: string }): void
|
||||||
(e: 'contextmenu', x: number, y: number): void
|
(e: 'contextmenu', x: number, y: number): void
|
||||||
|
(e: 'toggle-select'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isDragOver = ref(false)
|
const isDragOver = ref(false)
|
||||||
@@ -120,12 +122,15 @@ const route = shallowRef(useRoute())
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const containerClasses = computed(() => [
|
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' : '',
|
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')
|
const isZip = computed(() => fileExtension.value === 'zip')
|
||||||
|
|
||||||
@@ -170,13 +175,6 @@ const iconComponent = computed(() => {
|
|||||||
return getFileExtensionIcon(fileExtension.value)
|
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 formattedModifiedDate = computed(() => {
|
||||||
const date = new Date(props.modified * 1000)
|
const date = new Date(props.modified * 1000)
|
||||||
return `${date.toLocaleDateString('en-US', {
|
return `${date.toLocaleDateString('en-US', {
|
||||||
@@ -206,17 +204,16 @@ const formattedCreationDate = computed(() => {
|
|||||||
const isEditableFile = computed(() => {
|
const isEditableFile = computed(() => {
|
||||||
if (props.type === 'file') {
|
if (props.type === 'file') {
|
||||||
const ext = fileExtension.value
|
const ext = fileExtension.value
|
||||||
return (
|
return !props.name.includes('.') || isEditableFileExt(ext) || isImageFile(ext)
|
||||||
!props.name.includes('.') ||
|
|
||||||
TEXT_EXTENSIONS.includes(ext) ||
|
|
||||||
CODE_EXTENSIONS.includes(ext) ||
|
|
||||||
IMAGE_EXTENSIONS.includes(ext)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
|
|
||||||
const formattedSize = computed(() => {
|
const formattedSize = computed(() => {
|
||||||
|
if (props.type === 'directory') {
|
||||||
|
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
|
||||||
|
}
|
||||||
|
|
||||||
if (props.size === undefined) return ''
|
if (props.size === undefined) return ''
|
||||||
const bytes = props.size
|
const bytes = props.size
|
||||||
if (bytes === 0) return '0 B'
|
if (bytes === 0) return '0 B'
|
||||||
@@ -226,12 +223,16 @@ const formattedSize = computed(() => {
|
|||||||
return `${size} ${units[exponent]}`
|
return `${size} ${units[exponent]}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const openContextMenu = (event: MouseEvent) => {
|
function openContextMenu(event: MouseEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
emit('contextmenu', event.clientX, event.clientY)
|
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 currentPath = route.value.query.path?.toString() || ''
|
||||||
const newPath = currentPath.endsWith('/')
|
const newPath = currentPath.endsWith('/')
|
||||||
? `${currentPath}${props.name}`
|
? `${currentPath}${props.name}`
|
||||||
@@ -241,7 +242,7 @@ const navigateToFolder = () => {
|
|||||||
|
|
||||||
const isNavigating = ref(false)
|
const isNavigating = ref(false)
|
||||||
|
|
||||||
const selectItem = () => {
|
function selectItem() {
|
||||||
if (isNavigating.value) return
|
if (isNavigating.value) return
|
||||||
isNavigating.value = true
|
isNavigating.value = true
|
||||||
|
|
||||||
@@ -256,11 +257,12 @@ const selectItem = () => {
|
|||||||
}, 500)
|
}, 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getDragIcon = async () => {
|
async function getDragIcon() {
|
||||||
|
// Reuse iconComponent computed for consistency
|
||||||
return await renderToString(h(iconComponent.value))
|
return await renderToString(h(iconComponent.value))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragStart = async (event: DragEvent) => {
|
async function handleDragStart(event: DragEvent) {
|
||||||
if (!event.dataTransfer) return
|
if (!event.dataTransfer) return
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
|
|
||||||
@@ -291,7 +293,7 @@ const handleDragStart = async (event: DragEvent) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
event.dataTransfer.setData(
|
event.dataTransfer.setData(
|
||||||
'application/pyro-file-move',
|
'application/modrinth-file-move',
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
name: props.name,
|
name: props.name,
|
||||||
type: props.type,
|
type: props.type,
|
||||||
@@ -301,34 +303,34 @@ const handleDragStart = async (event: DragEvent) => {
|
|||||||
event.dataTransfer.effectAllowed = 'move'
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
}
|
}
|
||||||
|
|
||||||
const isChildPath = (parentPath: string, childPath: string) => {
|
function isChildPath(parentPath: string, childPath: string) {
|
||||||
return childPath.startsWith(parentPath + '/')
|
return childPath.startsWith(parentPath + '/')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnd = () => {
|
function handleDragEnd() {
|
||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragEnter = () => {
|
function handleDragEnter() {
|
||||||
if (props.type !== 'directory') return
|
if (props.type !== 'directory') return
|
||||||
isDragOver.value = true
|
isDragOver.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragOver = (event: DragEvent) => {
|
function handleDragOver(event: DragEvent) {
|
||||||
if (props.type !== 'directory' || !event.dataTransfer) return
|
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||||
event.dataTransfer.dropEffect = 'move'
|
event.dataTransfer.dropEffect = 'move'
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
function handleDragLeave() {
|
||||||
isDragOver.value = false
|
isDragOver.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDrop = (event: DragEvent) => {
|
function handleDrop(event: DragEvent) {
|
||||||
isDragOver.value = false
|
isDragOver.value = false
|
||||||
if (props.type !== 'directory' || !event.dataTransfer) return
|
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||||
|
|
||||||
try {
|
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
|
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">
|
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
|
||||||
<FileIcon class="size-28" />
|
<FileIcon class="size-28" />
|
||||||
<div class="flex flex-col gap-2">
|
<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">
|
<p class="m-0 text-sm text-secondary">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="listContainer" data-pyro-files-virtual-list-root class="relative w-full">
|
<div ref="listContainer" class="relative w-full">
|
||||||
<div
|
<div
|
||||||
:style="{
|
:style="{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
minHeight: `${totalHeight}px`,
|
minHeight: `${totalHeight}px`,
|
||||||
}"
|
}"
|
||||||
data-pyro-files-virtual-height-watcher
|
|
||||||
>
|
>
|
||||||
<ul
|
<ul
|
||||||
class="list-none"
|
class="list-none"
|
||||||
@@ -16,10 +15,9 @@
|
|||||||
margin: 0,
|
margin: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
}"
|
}"
|
||||||
data-pyro-files-virtual-list
|
|
||||||
>
|
>
|
||||||
<FileItem
|
<FileItem
|
||||||
v-for="item in visibleItems"
|
v-for="(item, idx) in visibleItems"
|
||||||
:key="item.path"
|
:key="item.path"
|
||||||
:count="item.count"
|
:count="item.count"
|
||||||
:created="item.created"
|
:created="item.created"
|
||||||
@@ -28,6 +26,9 @@
|
|||||||
:path="item.path"
|
:path="item.path"
|
||||||
:type="item.type"
|
:type="item.type"
|
||||||
:size="item.size"
|
: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)"
|
@delete="$emit('delete', item)"
|
||||||
@rename="$emit('rename', item)"
|
@rename="$emit('rename', item)"
|
||||||
@extract="$emit('extract', item)"
|
@extract="$emit('extract', item)"
|
||||||
@@ -35,7 +36,9 @@
|
|||||||
@move="$emit('move', item)"
|
@move="$emit('move', item)"
|
||||||
@move-direct-to="$emit('moveDirectTo', $event)"
|
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||||
@edit="$emit('edit', item)"
|
@edit="$emit('edit', item)"
|
||||||
|
@hover="$emit('hover', item)"
|
||||||
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||||
|
@toggle-select="$emit('toggle-select', item.path)"
|
||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,15 +52,17 @@ import FileItem from './FileItem.vue'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
items: any[]
|
items: any[]
|
||||||
|
selectedItems: Set<string>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(
|
(
|
||||||
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract',
|
e: 'delete' | 'rename' | 'download' | 'move' | 'edit' | 'moveDirectTo' | 'extract' | 'hover',
|
||||||
item: any,
|
item: any,
|
||||||
): void
|
): void
|
||||||
(e: 'contextmenu', item: any, x: number, y: number): void
|
(e: 'contextmenu', item: any, x: number, y: number): void
|
||||||
(e: 'loadMore'): void
|
(e: 'loadMore'): void
|
||||||
|
(e: 'toggle-select', path: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const ITEM_HEIGHT = 61
|
const ITEM_HEIGHT = 61
|
||||||
@@ -92,7 +97,7 @@ const visibleItems = computed(() => {
|
|||||||
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
|
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleScroll = () => {
|
function handleScroll() {
|
||||||
windowScrollY.value = window.scrollY
|
windowScrollY.value = window.scrollY
|
||||||
|
|
||||||
if (!listContainer.value) return
|
if (!listContainer.value) return
|
||||||
@@ -105,7 +110,7 @@ const handleScroll = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleResize = () => {
|
function handleResize() {
|
||||||
windowHeight.value = window.innerHeight
|
windowHeight.value = window.innerHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="pyroFilesSentinel" class="sentinel" data-pyro-files-sentinel />
|
|
||||||
<header
|
<header
|
||||||
:class="[
|
class="flex select-none flex-col justify-between gap-2 sm:flex-row sm:items-center"
|
||||||
'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"
|
|
||||||
aria-label="File navigation"
|
aria-label="File navigation"
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
@@ -13,20 +8,17 @@
|
|||||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
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">
|
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||||
<li class="-ml-1 flex-shrink-0">
|
<li class="mr-4 flex-shrink-0">
|
||||||
<ButtonStyled type="transparent">
|
<ButtonStyled circular>
|
||||||
<button
|
<button
|
||||||
v-tooltip="'Back to home'"
|
v-tooltip="'Back to home'"
|
||||||
type="button"
|
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)"
|
@click="$emit('navigate', -1)"
|
||||||
|
@mouseenter="$emit('prefetch-home')"
|
||||||
>
|
>
|
||||||
<span
|
<HomeIcon />
|
||||||
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
<span class="sr-only">Home</span>
|
||||||
>
|
|
||||||
<HomeIcon class="h-5 w-5" />
|
|
||||||
<span class="sr-only">Home</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</li>
|
</li>
|
||||||
@@ -70,58 +62,28 @@
|
|||||||
</ol>
|
</ol>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="flex flex-shrink-0 items-center gap-1">
|
<div class="flex flex-shrink-0 items-center gap-2">
|
||||||
<div class="flex w-full flex-row-reverse sm:flex-row">
|
<div class="iconified-input w-full sm:w-[280px]">
|
||||||
<ButtonStyled type="transparent">
|
<SearchIcon aria-hidden="true" class="!text-secondary" />
|
||||||
<TeleportOverflowMenu
|
<input
|
||||||
position="bottom"
|
id="search-folder"
|
||||||
direction="left"
|
:value="searchQuery"
|
||||||
aria-label="Filter view"
|
type="search"
|
||||||
:options="[
|
name="search"
|
||||||
{ id: 'all', action: () => $emit('filter', 'all') },
|
autocomplete="off"
|
||||||
{ id: 'filesOnly', action: () => $emit('filter', 'filesOnly') },
|
class="h-10 w-full rounded-[14px] border-0 bg-surface-4 text-sm"
|
||||||
{ id: 'foldersOnly', action: () => $emit('filter', 'foldersOnly') },
|
placeholder="Search files"
|
||||||
]"
|
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||||
>
|
/>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<ButtonStyled type="transparent">
|
<ButtonStyled type="outlined">
|
||||||
<OverflowMenu
|
<OverflowMenu
|
||||||
:dropdown-id="`create-new-${baseId}`"
|
:dropdown-id="`create-new-${baseId}`"
|
||||||
position="bottom"
|
position="bottom"
|
||||||
direction="left"
|
direction="left"
|
||||||
aria-label="Create new..."
|
aria-label="Create new..."
|
||||||
|
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
|
||||||
:options="[
|
:options="[
|
||||||
{ id: 'file', action: () => $emit('create', 'file') },
|
{ id: 'file', action: () => $emit('create', 'file') },
|
||||||
{ id: 'directory', action: () => $emit('create', 'directory') },
|
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||||
@@ -132,8 +94,8 @@
|
|||||||
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
|
{ id: 'install-cf-pack', action: () => $emit('unzip-from-url', true) },
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden="true" />
|
<PlusIcon aria-hidden="true" class="h-5 w-5" />
|
||||||
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
|
||||||
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||||
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||||
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||||
@@ -159,7 +121,6 @@ import {
|
|||||||
CurseForgeIcon,
|
CurseForgeIcon,
|
||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
FileArchiveIcon,
|
FileArchiveIcon,
|
||||||
FilterIcon,
|
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
HomeIcon,
|
HomeIcon,
|
||||||
LinkIcon,
|
LinkIcon,
|
||||||
@@ -168,12 +129,8 @@ import {
|
|||||||
UploadIcon,
|
UploadIcon,
|
||||||
} from '@modrinth/assets'
|
} from '@modrinth/assets'
|
||||||
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
import { ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
||||||
import { useIntersectionObserver } from '@vueuse/core'
|
|
||||||
import { computed, ref } from 'vue'
|
|
||||||
|
|
||||||
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
defineProps<{
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
breadcrumbSegments: string[]
|
breadcrumbSegments: string[]
|
||||||
searchQuery: string
|
searchQuery: string
|
||||||
currentFilter: string
|
currentFilter: string
|
||||||
@@ -183,44 +140,13 @@ const props = defineProps<{
|
|||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: 'navigate', index: number): void
|
(e: 'navigate', index: number): void
|
||||||
(e: 'create', type: 'file' | 'directory'): 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: 'unzip-from-url', cf: boolean): void
|
||||||
(e: 'update:searchQuery' | 'filter', value: string): 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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sentinel {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 1px;
|
|
||||||
visibility: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb-move,
|
.breadcrumb-move,
|
||||||
.breadcrumb-enter-active,
|
.breadcrumb-enter-active,
|
||||||
.breadcrumb-leave-active {
|
.breadcrumb-leave-active {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@
|
|||||||
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
|
<NewModal ref="modal" danger :header="`Deleting ${item?.type}`">
|
||||||
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||||
<div
|
<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
|
<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" />
|
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
|
||||||
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
|
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
data-pyro-files-state="editing"
|
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"
|
aria-label="File editor navigation"
|
||||||
>
|
>
|
||||||
<nav
|
<nav
|
||||||
@@ -9,20 +9,16 @@
|
|||||||
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
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">
|
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||||
<li class="-ml-1 flex-shrink-0">
|
<li class="mr-4 flex-shrink-0">
|
||||||
<ButtonStyled type="transparent">
|
<ButtonStyled circular>
|
||||||
<button
|
<button
|
||||||
v-tooltip="'Back to home'"
|
v-tooltip="'Back to home'"
|
||||||
type="button"
|
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"
|
@click="goHome"
|
||||||
>
|
>
|
||||||
<span
|
<HomeIcon />
|
||||||
class="grid size-8 place-content-center rounded-full bg-button-bg p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
<span class="sr-only">Home</span>
|
||||||
>
|
|
||||||
<HomeIcon class="h-5 w-5" />
|
|
||||||
<span class="sr-only">Home</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
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 class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||||
<div
|
<div
|
||||||
ref="container"
|
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"
|
@mousedown="startPan"
|
||||||
@mousemove="handlePan"
|
@mousemove="handlePan"
|
||||||
@mouseup="stopPan"
|
@mouseup="stopPan"
|
||||||
|
|||||||
@@ -1,65 +1,102 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
aria-hidden="true"
|
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>
|
<div class="flex flex-1 items-center gap-3">
|
||||||
<button
|
<Checkbox
|
||||||
class="flex h-full w-full appearance-none items-center gap-1 bg-transparent text-left hover:text-brand"
|
:model-value="allSelected"
|
||||||
@click="$emit('sort', 'name')"
|
:indeterminate="someSelected && !allSelected"
|
||||||
>
|
@update:model-value="$emit('toggle-all')"
|
||||||
<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">
|
|
||||||
<button
|
<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')"
|
@click="$emit('sort', 'created')"
|
||||||
>
|
>
|
||||||
<span>Created</span>
|
<span>Created</span>
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon
|
||||||
v-if="sortField === 'created' && !sortDesc"
|
v-if="sortField === 'created' && !sortDesc"
|
||||||
class="h-3 w-3"
|
class="h-4 w-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
v-if="sortField === 'created' && sortDesc"
|
v-if="sortField === 'created' && sortDesc"
|
||||||
class="h-3 w-3"
|
class="h-4 w-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<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')"
|
@click="$emit('sort', 'modified')"
|
||||||
>
|
>
|
||||||
<span>Modified</span>
|
<span>Modified</span>
|
||||||
<ChevronUpIcon
|
<ChevronUpIcon
|
||||||
v-if="sortField === 'modified' && !sortDesc"
|
v-if="sortField === 'modified' && !sortDesc"
|
||||||
class="h-3 w-3"
|
class="h-4 w-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
v-if="sortField === 'modified' && sortDesc"
|
v-if="sortField === 'modified' && sortDesc"
|
||||||
class="h-3 w-3"
|
class="h-4 w-4"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<div class="min-w-[24px]"></div>
|
<span class="w-[51px] text-right text-primary">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { Checkbox } from '@modrinth/ui'
|
||||||
|
|
||||||
import ChevronDownIcon from './icons/ChevronDownIcon.vue'
|
import ChevronDownIcon from './icons/ChevronDownIcon.vue'
|
||||||
import ChevronUpIcon from './icons/ChevronUpIcon.vue'
|
import ChevronUpIcon from './icons/ChevronUpIcon.vue'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
sortField: string
|
sortField: string
|
||||||
sortDesc: boolean
|
sortDesc: boolean
|
||||||
|
allSelected: boolean
|
||||||
|
someSelected: boolean
|
||||||
|
isStuck: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: 'sort', field: string): void
|
(e: 'sort', field: string): void
|
||||||
|
(e: 'toggle-all'): void
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -9,12 +9,12 @@
|
|||||||
<div
|
<div
|
||||||
v-if="isDragging"
|
v-if="isDragging"
|
||||||
:class="[
|
: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,
|
overlayClass,
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="text-center">
|
<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">
|
<p class="mt-2 text-xl">
|
||||||
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
|
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
|
||||||
</p>
|
</p>
|
||||||
@@ -41,7 +41,7 @@ const dragCounter = ref(0)
|
|||||||
|
|
||||||
const handleDragEnter = (event: DragEvent) => {
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (!event.dataTransfer?.types.includes('application/pyro-file-move')) {
|
if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
|
||||||
dragCounter.value++
|
dragCounter.value++
|
||||||
isDragging.value = true
|
isDragging.value = true
|
||||||
}
|
}
|
||||||
@@ -64,7 +64,7 @@ const handleDrop = (event: DragEvent) => {
|
|||||||
isDragging.value = false
|
isDragging.value = false
|
||||||
dragCounter.value = 0
|
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
|
if (isInternalMove) return
|
||||||
|
|
||||||
const files = event.dataTransfer?.files
|
const files = event.dataTransfer?.files
|
||||||
|
|||||||
@@ -102,14 +102,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { CheckCircleIcon, FolderOpenIcon, XCircleIcon } from '@modrinth/assets'
|
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 { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
import type { FSModule } from '~/composables/servers/modules/fs.ts'
|
|
||||||
|
|
||||||
import PanelSpinner from './PanelSpinner.vue'
|
import PanelSpinner from './PanelSpinner.vue'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
|
||||||
interface UploadItem {
|
interface UploadItem {
|
||||||
file: File
|
file: File
|
||||||
@@ -123,7 +122,7 @@ interface UploadItem {
|
|||||||
| 'cancelled'
|
| 'cancelled'
|
||||||
| 'incorrect-type'
|
| 'incorrect-type'
|
||||||
size: string
|
size: string
|
||||||
uploader?: any
|
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
|
||||||
error?: Error
|
error?: Error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +131,6 @@ interface Props {
|
|||||||
fileType?: string
|
fileType?: string
|
||||||
marginBottom?: number
|
marginBottom?: number
|
||||||
acceptedTypes?: Array<string>
|
acceptedTypes?: Array<string>
|
||||||
fs: FSModule
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -208,6 +206,7 @@ const cancelUpload = (item: UploadItem) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const badFileTypeMsg = 'Upload had incorrect file type'
|
const badFileTypeMsg = 'Upload had incorrect file type'
|
||||||
|
|
||||||
const uploadFile = async (file: File) => {
|
const uploadFile = async (file: File) => {
|
||||||
const uploadItem: UploadItem = {
|
const uploadItem: UploadItem = {
|
||||||
file,
|
file,
|
||||||
@@ -229,19 +228,18 @@ const uploadFile = async (file: File) => {
|
|||||||
|
|
||||||
uploadItem.status = 'uploading'
|
uploadItem.status = 'uploading'
|
||||||
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
|
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
|
||||||
const uploader = await props.fs.uploadFile(filePath, file)
|
|
||||||
uploadItem.uploader = uploader
|
|
||||||
|
|
||||||
if (uploader?.onProgress) {
|
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
|
||||||
uploader.onProgress(({ progress }: { progress: number }) => {
|
onProgress: ({ progress }) => {
|
||||||
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
uploadQueue.value[index].progress = Math.round(progress)
|
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)
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||||
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
||||||
uploadQueue.value[index].status = 'completed'
|
uploadQueue.value[index].status = 'completed'
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
/>
|
/>
|
||||||
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||||
</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">
|
<div class="flex justify-start gap-2">
|
||||||
<ButtonStyled color="brand">
|
<ButtonStyled color="brand">
|
||||||
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
|
<button v-tooltip="error" :disabled="submitted || !!error" type="submit">
|
||||||
@@ -74,21 +74,25 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DownloadIcon, ExternalIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
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 { ModrinthServersFetchError } from '@modrinth/utils'
|
||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
|
||||||
import { handleServersError } from '~/composables/servers/modrinth-servers.ts'
|
import { handleServersError } from '~/composables/servers/modrinth-servers.ts'
|
||||||
|
|
||||||
const notifications = injectNotificationManager()
|
const notifications = injectNotificationManager()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const { serverId } = injectModrinthServerContext()
|
||||||
|
|
||||||
const cf = ref(false)
|
const cf = ref(false)
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
server: ModrinthServer
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const modal = ref<typeof NewModal>()
|
const modal = ref<typeof NewModal>()
|
||||||
const urlInput = ref<HTMLInputElement | null>(null)
|
const urlInput = ref<HTMLInputElement | null>(null)
|
||||||
const url = ref('')
|
const url = ref('')
|
||||||
@@ -115,10 +119,10 @@ const handleSubmit = async () => {
|
|||||||
if (!error.value) {
|
if (!error.value) {
|
||||||
// hide();
|
// hide();
|
||||||
try {
|
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) {
|
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()
|
hide()
|
||||||
} else {
|
} else {
|
||||||
submitted.value = false
|
submitted.value = false
|
||||||
|
|||||||
@@ -2,15 +2,7 @@ import type { AbstractWebNotificationManager } from '@modrinth/ui'
|
|||||||
import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
|
import type { JWTAuth, ModuleError, ModuleName } from '@modrinth/utils'
|
||||||
import { ModrinthServerError } from '@modrinth/utils'
|
import { ModrinthServerError } from '@modrinth/utils'
|
||||||
|
|
||||||
import {
|
import { ContentModule, GeneralModule, NetworkModule, StartupModule } from './modules/index.ts'
|
||||||
BackupsModule,
|
|
||||||
ContentModule,
|
|
||||||
FSModule,
|
|
||||||
GeneralModule,
|
|
||||||
NetworkModule,
|
|
||||||
StartupModule,
|
|
||||||
WSModule,
|
|
||||||
} from './modules/index.ts'
|
|
||||||
import { useServersFetch } from './servers-fetch.ts'
|
import { useServersFetch } from './servers-fetch.ts'
|
||||||
|
|
||||||
export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
|
export function handleServersError(err: any, notifications: AbstractWebNotificationManager) {
|
||||||
@@ -36,39 +28,16 @@ export class ModrinthServer {
|
|||||||
|
|
||||||
readonly general: GeneralModule
|
readonly general: GeneralModule
|
||||||
readonly content: ContentModule
|
readonly content: ContentModule
|
||||||
readonly backups: BackupsModule
|
|
||||||
readonly network: NetworkModule
|
readonly network: NetworkModule
|
||||||
readonly startup: StartupModule
|
readonly startup: StartupModule
|
||||||
readonly ws: WSModule
|
|
||||||
readonly fs: FSModule
|
|
||||||
|
|
||||||
constructor(serverId: string) {
|
constructor(serverId: string) {
|
||||||
this.serverId = serverId
|
this.serverId = serverId
|
||||||
|
|
||||||
this.general = new GeneralModule(this)
|
this.general = new GeneralModule(this)
|
||||||
this.content = new ContentModule(this)
|
this.content = new ContentModule(this)
|
||||||
this.backups = new BackupsModule(this)
|
|
||||||
this.network = new NetworkModule(this)
|
this.network = new NetworkModule(this)
|
||||||
this.startup = new StartupModule(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> {
|
async fetchConfigFile(fileName: string): Promise<any> {
|
||||||
@@ -240,9 +209,7 @@ export class ModrinthServer {
|
|||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const modulesToRefresh =
|
const modulesToRefresh =
|
||||||
modules.length > 0
|
modules.length > 0 ? modules : (['general', 'content', 'network', 'startup'] as ModuleName[])
|
||||||
? modules
|
|
||||||
: (['general', 'content', 'backups', 'network', 'startup', 'ws', 'fs'] as ModuleName[])
|
|
||||||
|
|
||||||
for (const module of modulesToRefresh) {
|
for (const module of modulesToRefresh) {
|
||||||
this.errors[module] = undefined
|
this.errors[module] = undefined
|
||||||
@@ -274,25 +241,16 @@ export class ModrinthServer {
|
|||||||
case 'content':
|
case 'content':
|
||||||
await this.content.fetch()
|
await this.content.fetch()
|
||||||
break
|
break
|
||||||
case 'backups':
|
|
||||||
await this.backups.fetch()
|
|
||||||
break
|
|
||||||
case 'network':
|
case 'network':
|
||||||
await this.network.fetch()
|
await this.network.fetch()
|
||||||
break
|
break
|
||||||
case 'startup':
|
case 'startup':
|
||||||
await this.startup.fetch()
|
await this.startup.fetch()
|
||||||
break
|
break
|
||||||
case 'ws':
|
|
||||||
await this.ws.fetch()
|
|
||||||
break
|
|
||||||
case 'fs':
|
|
||||||
await this.fs.fetch()
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ModrinthServerError) {
|
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)
|
console.debug(`Optional ${module} resource not found:`, error.message)
|
||||||
continue
|
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
|
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
|
// Copy data to this module
|
||||||
Object.assign(this, data)
|
Object.assign(this, data)
|
||||||
}
|
}
|
||||||
@@ -189,23 +176,6 @@ export class GeneralModule extends ServerModule implements ServerGeneral {
|
|||||||
await this.fetch() // Refresh this module
|
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> {
|
async setMotd(motd: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const props = (await this.server.fetchConfigFile('ServerProperties')) as any
|
const props = (await this.server.fetchConfigFile('ServerProperties')) as any
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export * from './backups.ts'
|
export * from './backups.ts'
|
||||||
export * from './base.ts'
|
export * from './base.ts'
|
||||||
export * from './content.ts'
|
export * from './content.ts'
|
||||||
export * from './fs.ts'
|
|
||||||
export * from './general.ts'
|
export * from './general.ts'
|
||||||
export * from './network.ts'
|
export * from './network.ts'
|
||||||
export * from './startup.ts'
|
export * from './startup.ts'
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
type AuthConfig,
|
type AuthConfig,
|
||||||
AuthFeature,
|
AuthFeature,
|
||||||
CircuitBreakerFeature,
|
CircuitBreakerFeature,
|
||||||
|
NodeAuthFeature,
|
||||||
|
nodeAuthState,
|
||||||
NuxtCircuitBreakerStorage,
|
NuxtCircuitBreakerStorage,
|
||||||
type NuxtClientConfig,
|
type NuxtClientConfig,
|
||||||
NuxtModrinthClient,
|
NuxtModrinthClient,
|
||||||
@@ -24,6 +26,16 @@ export function createModrinthClient(
|
|||||||
archonBaseUrl: config.archonBaseUrl,
|
archonBaseUrl: config.archonBaseUrl,
|
||||||
rateLimitKey: config.rateLimitKey,
|
rateLimitKey: config.rateLimitKey,
|
||||||
features: [
|
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({
|
new AuthFeature({
|
||||||
token: async () => auth.value.token,
|
token: async () => auth.value.token,
|
||||||
} as AuthConfig),
|
} as AuthConfig),
|
||||||
|
|||||||
@@ -374,11 +374,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
|
import { Intercom, shutdown } from '@intercom/messenger-js-sdk'
|
||||||
import type { Archon } from '@modrinth/api-client'
|
import type { Archon } from '@modrinth/api-client'
|
||||||
|
import { clearNodeAuthState, setNodeAuthState } from '@modrinth/api-client'
|
||||||
import {
|
import {
|
||||||
|
BoxesIcon,
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
|
DatabaseBackupIcon,
|
||||||
FileIcon,
|
FileIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
IssuesIcon,
|
IssuesIcon,
|
||||||
|
LayoutTemplateIcon,
|
||||||
LeftArrowIcon,
|
LeftArrowIcon,
|
||||||
LockIcon,
|
LockIcon,
|
||||||
RightArrowIcon,
|
RightArrowIcon,
|
||||||
@@ -451,7 +456,7 @@ const loadModulesPromise = Promise.resolve().then(() => {
|
|||||||
if (server.general?.status === 'suspended') {
|
if (server.general?.status === 'suspended') {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
return server.refresh(['content', 'backups', 'network', 'startup', 'fs'])
|
return server.refresh(['content', 'backups', 'network', 'startup'])
|
||||||
})
|
})
|
||||||
|
|
||||||
provide('modulesLoaded', loadModulesPromise)
|
provide('modulesLoaded', loadModulesPromise)
|
||||||
@@ -497,6 +502,22 @@ const markBackupCancelled = (backupId: string) => {
|
|||||||
cancelledBackups.add(backupId)
|
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({
|
provideModrinthServerContext({
|
||||||
serverId,
|
serverId,
|
||||||
server: n_server as Ref<Archon.Servers.v0.Server>,
|
server: n_server as Ref<Archon.Servers.v0.Server>,
|
||||||
@@ -505,6 +526,10 @@ provideModrinthServerContext({
|
|||||||
isServerRunning,
|
isServerRunning,
|
||||||
backupsState,
|
backupsState,
|
||||||
markBackupCancelled,
|
markBackupCancelled,
|
||||||
|
fsAuth,
|
||||||
|
fsOps,
|
||||||
|
fsQueuedOps,
|
||||||
|
refreshFsAuth,
|
||||||
})
|
})
|
||||||
|
|
||||||
const uptimeSeconds = ref(0)
|
const uptimeSeconds = ref(0)
|
||||||
@@ -551,17 +576,29 @@ const showGameLabel = computed(() => !!serverData.value?.game)
|
|||||||
const showLoaderLabel = computed(() => !!serverData.value?.loader)
|
const showLoaderLabel = computed(() => !!serverData.value?.loader)
|
||||||
|
|
||||||
const navLinks = [
|
const navLinks = [
|
||||||
{ label: 'Overview', href: `/hosting/manage/${serverId}`, subpages: [] },
|
{
|
||||||
|
label: 'Overview',
|
||||||
|
href: `/hosting/manage/${serverId}`,
|
||||||
|
icon: LayoutTemplateIcon,
|
||||||
|
subpages: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Content',
|
label: 'Content',
|
||||||
href: `/hosting/manage/${serverId}/content`,
|
href: `/hosting/manage/${serverId}/content`,
|
||||||
|
icon: BoxesIcon,
|
||||||
subpages: ['mods', 'datapacks'],
|
subpages: ['mods', 'datapacks'],
|
||||||
},
|
},
|
||||||
{ label: 'Files', href: `/hosting/manage/${serverId}/files`, subpages: [] },
|
{ label: 'Files', href: `/hosting/manage/${serverId}/files`, icon: FolderOpenIcon, subpages: [] },
|
||||||
{ label: 'Backups', href: `/hosting/manage/${serverId}/backups`, subpages: [] },
|
{
|
||||||
|
label: 'Backups',
|
||||||
|
href: `/hosting/manage/${serverId}/backups`,
|
||||||
|
icon: DatabaseBackupIcon,
|
||||||
|
subpages: [],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Options',
|
label: 'Options',
|
||||||
href: `/hosting/manage/${serverId}/options`,
|
href: `/hosting/manage/${serverId}/options`,
|
||||||
|
icon: SettingsIcon,
|
||||||
subpages: ['startup', 'network', 'properties', 'info'],
|
subpages: ['startup', 'network', 'properties', 'info'],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -767,24 +804,29 @@ const handleBackupProgress = (data: Archon.Websocket.v0.WSBackupProgressEvent) =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
|
const opsQueuedForModification = ref<string[]>([])
|
||||||
if (!server.fs) {
|
|
||||||
console.error('FilesystemOps received, but server.fs is not available', data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) => {
|
||||||
const allOps = data.all
|
const allOps = data.all
|
||||||
|
|
||||||
if (JSON.stringify(server.fs.ops) !== JSON.stringify(allOps)) {
|
if (JSON.stringify(fsOps.value) !== JSON.stringify(allOps)) {
|
||||||
server.fs.ops = allOps as unknown as ModrinthServer['fs']['ops']
|
fsOps.value = allOps
|
||||||
}
|
}
|
||||||
|
|
||||||
server.fs.queuedOps = server.fs.queuedOps.filter(
|
fsQueuedOps.value = fsQueuedOps.value.filter(
|
||||||
(queuedOp) => !allOps.some((x) => x.src === queuedOp.src),
|
(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')
|
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')
|
const completed = allOps.filter((x) => x.state === 'done')
|
||||||
if (completed.length > 0) {
|
if (completed.length > 0) {
|
||||||
@@ -792,9 +834,9 @@ const handleFilesystemOps = (data: Archon.Websocket.v0.WSFilesystemOpsEvent) =>
|
|||||||
async () =>
|
async () =>
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
completed.map((x) => {
|
completed.map((x) => {
|
||||||
if (!server.fs?.opsQueuedForModification.includes(x.id)) {
|
if (!opsQueuedForModification.value.includes(x.id)) {
|
||||||
server.fs?.opsQueuedForModification.push(x.id)
|
opsQueuedForModification.value.push(x.id)
|
||||||
return server.fs?.modifyOp(x.id, 'dismiss')
|
return dismissOp(x.id)
|
||||||
}
|
}
|
||||||
return Promise.resolve()
|
return Promise.resolve()
|
||||||
}),
|
}),
|
||||||
@@ -885,22 +927,27 @@ const handleInstallationResult = async (data: Archon.Websocket.v0.WSInstallation
|
|||||||
errorTitle.value = 'Installation error'
|
errorTitle.value = 'Installation error'
|
||||||
errorMessage.value = data.reason ?? 'Unknown error'
|
errorMessage.value = data.reason ?? 'Unknown error'
|
||||||
error.value = new Error(data.reason ?? 'Unknown error')
|
error.value = new Error(data.reason ?? 'Unknown error')
|
||||||
let files = await server.fs?.listDirContents('/', 1, 100)
|
|
||||||
if (files) {
|
// Fetch installation log if available
|
||||||
if (files.total > 1) {
|
try {
|
||||||
for (let i = 1; i < files.total; i++) {
|
let files = await client.kyros.files_v0.listDirectory('/', 1, 100)
|
||||||
const nextFiles = await server.fs?.listDirContents('/', i, 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?.items?.length === 0) break
|
||||||
if (nextFiles) files = nextFiles
|
if (nextFiles) files = nextFiles
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
const fileName = files?.items?.find((file) =>
|
||||||
const fileName = files?.items?.find((file: { name: string }) =>
|
file.name.startsWith('modrinth-installation'),
|
||||||
file.name.startsWith('modrinth-installation'),
|
)?.name
|
||||||
)?.name
|
errorLogFile.value = fileName ?? ''
|
||||||
errorLogFile.value = fileName ?? ''
|
if (fileName) {
|
||||||
if (fileName) {
|
const content = await client.kyros.files_v0.downloadFile(fileName)
|
||||||
errorLog.value = await server.fs?.downloadFile(fileName)
|
errorLog.value = await content.text()
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch installation log:', err)
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -1133,6 +1180,8 @@ const cleanup = () => {
|
|||||||
completedBackupTasks.clear()
|
completedBackupTasks.clear()
|
||||||
cancelledBackups.clear()
|
cancelledBackups.clear()
|
||||||
|
|
||||||
|
clearNodeAuthState()
|
||||||
|
|
||||||
DOMPurify.removeHook('afterSanitizeAttributes')
|
DOMPurify.removeHook('afterSanitizeAttributes')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,13 +100,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<FilesUploadDropdown
|
<FilesUploadDropdown
|
||||||
v-if="props.server.fs"
|
|
||||||
ref="uploadDropdownRef"
|
ref="uploadDropdownRef"
|
||||||
class="rounded-xl bg-bg-raised"
|
class="rounded-xl bg-bg-raised"
|
||||||
:margin-bottom="16"
|
:margin-bottom="16"
|
||||||
:file-type="type"
|
:file-type="type"
|
||||||
:current-path="`/${type.toLocaleLowerCase()}s`"
|
:current-path="`/${type.toLocaleLowerCase()}s`"
|
||||||
:fs="props.server.fs"
|
|
||||||
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
:accepted-types="acceptFileFromProjectType(type.toLocaleLowerCase()).split(',')"
|
||||||
@upload-complete="() => props.server.refresh(['content'])"
|
@upload-complete="() => props.server.refresh(['content'])"
|
||||||
/>
|
/>
|
||||||
@@ -355,7 +353,7 @@ import {
|
|||||||
TrashIcon,
|
TrashIcon,
|
||||||
WrenchIcon,
|
WrenchIcon,
|
||||||
} from '@modrinth/assets'
|
} 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 type { Mod } from '@modrinth/utils'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
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'
|
import { acceptFileFromProjectType } from '~/helpers/fileUtils.js'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: ModrinthServer
|
server: ModrinthServer
|
||||||
}>()
|
}>()
|
||||||
@@ -621,7 +621,7 @@ async function toggleMod(mod: ContentItem) {
|
|||||||
mod.disabled = newFilename.endsWith('.disabled')
|
mod.disabled = newFilename.endsWith('.disabled')
|
||||||
mod.filename = newFilename
|
mod.filename = newFilename
|
||||||
|
|
||||||
await props.server.fs?.moveFileOrFolder(sourcePath, destinationPath)
|
await client.kyros.files_v0.moveFileOrFolder(sourcePath, destinationPath)
|
||||||
|
|
||||||
await props.server.refresh(['general', 'content'])
|
await props.server.refresh(['general', 'content'])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -205,6 +205,9 @@ type ServerProps = {
|
|||||||
|
|
||||||
const props = defineProps<ServerProps>()
|
const props = defineProps<ServerProps>()
|
||||||
|
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const serverId = props.server.serverId
|
||||||
|
|
||||||
interface ErrorData {
|
interface ErrorData {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@@ -242,7 +245,8 @@ const inspectingError = ref<ErrorData | null>(null)
|
|||||||
|
|
||||||
const inspectError = async () => {
|
const inspectError = async () => {
|
||||||
try {
|
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
|
if (!log) return
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -287,9 +291,6 @@ if (props.serverPowerState === 'crashed' && !props.powerStateDetails?.oom_killed
|
|||||||
inspectError()
|
inspectError()
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = injectModrinthClient()
|
|
||||||
const serverId = props.server.serverId
|
|
||||||
|
|
||||||
const DYNAMIC_ARG = Symbol('DYNAMIC_ARG')
|
const DYNAMIC_ARG = Symbol('DYNAMIC_ARG')
|
||||||
|
|
||||||
const commandTree: any = {
|
const commandTree: any = {
|
||||||
|
|||||||
@@ -116,13 +116,15 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EditIcon, TransferIcon } from '@modrinth/assets'
|
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 ButtonStyled from '@modrinth/ui/src/components/base/ButtonStyled.vue'
|
||||||
|
|
||||||
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
|
||||||
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: ModrinthServer
|
server: ModrinthServer
|
||||||
}>()
|
}>()
|
||||||
@@ -242,12 +244,12 @@ const uploadFile = async (e: Event) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (data.value?.image) {
|
if (data.value?.image) {
|
||||||
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
|
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
|
||||||
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
|
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
|
||||||
}
|
}
|
||||||
|
|
||||||
await props.server.fs?.uploadFile('/server-icon.png', scaledFile)
|
await client.kyros.files_v0.uploadFile('/server-icon.png', scaledFile).promise
|
||||||
await props.server.fs?.uploadFile('/server-icon-original.png', file)
|
await client.kyros.files_v0.uploadFile('/server-icon-original.png', file).promise
|
||||||
|
|
||||||
const canvas = document.createElement('canvas')
|
const canvas = document.createElement('canvas')
|
||||||
const ctx = canvas.getContext('2d')
|
const ctx = canvas.getContext('2d')
|
||||||
@@ -284,8 +286,8 @@ const uploadFile = async (e: Event) => {
|
|||||||
const resetIcon = async () => {
|
const resetIcon = async () => {
|
||||||
if (data.value?.image) {
|
if (data.value?.image) {
|
||||||
try {
|
try {
|
||||||
await props.server.fs?.deleteFileOrFolder('/server-icon.png', false)
|
await client.kyros.files_v0.deleteFileOrFolder('/server-icon.png', false)
|
||||||
await props.server.fs?.deleteFileOrFolder('/server-icon-original.png', false)
|
await client.kyros.files_v0.deleteFileOrFolder('/server-icon-original.png', false)
|
||||||
|
|
||||||
useState(`server-icon-${props.server.serverId}`).value = undefined
|
useState(`server-icon-${props.server.serverId}`).value = undefined
|
||||||
if (data.value) data.value.image = 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.',
|
description: 'When enabled, you will be prompted before stopping and restarting your server.',
|
||||||
implemented: true,
|
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
|
} as const
|
||||||
|
|
||||||
type PreferenceKeys = keyof typeof preferences
|
type PreferenceKeys = keyof typeof preferences
|
||||||
@@ -96,7 +91,6 @@ const defaultPreferences: UserPreferences = {
|
|||||||
hideSubdomainLabel: false,
|
hideSubdomainLabel: false,
|
||||||
autoRestart: false,
|
autoRestart: false,
|
||||||
powerDontAskAgain: false,
|
powerDontAskAgain: false,
|
||||||
backupWhileRunning: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPreferences = useStorage<UserPreferences>(
|
const userPreferences = useStorage<UserPreferences>(
|
||||||
|
|||||||
@@ -1,32 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative h-full w-full select-none overflow-y-auto">
|
<div class="relative h-full w-full select-none overflow-y-auto">
|
||||||
<div
|
<div
|
||||||
v-if="server.moduleErrors.fs"
|
v-if="propsData && status === 'success'"
|
||||||
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'"
|
|
||||||
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
|
class="flex h-full w-full flex-col justify-between gap-6 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<div class="card flex flex-col gap-4">
|
<div class="card flex flex-col gap-4">
|
||||||
@@ -158,8 +133,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { EyeIcon, IssuesIcon, SearchIcon } from '@modrinth/assets'
|
import { EyeIcon, SearchIcon } from '@modrinth/assets'
|
||||||
import { ButtonStyled, Combobox, injectNotificationManager } from '@modrinth/ui'
|
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
import { computed, inject, ref, watch } from 'vue'
|
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'
|
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
|
||||||
|
|
||||||
const { addNotification } = injectNotificationManager()
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
server: ModrinthServer
|
server: ModrinthServer
|
||||||
}>()
|
}>()
|
||||||
@@ -181,33 +158,39 @@ const data = computed(() => props.server.general)
|
|||||||
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
||||||
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
|
const { data: propsData, status } = await useAsyncData('ServerProperties', async () => {
|
||||||
await modulesLoaded
|
await modulesLoaded
|
||||||
const rawProps = await props.server.fs?.downloadFile('server.properties')
|
try {
|
||||||
if (!rawProps) return null
|
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 properties: Record<string, any> = {}
|
||||||
const lines = rawProps.split('\n')
|
const lines = rawProps.split('\n')
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (line.startsWith('#') || !line.includes('=')) continue
|
if (line.startsWith('#') || !line.includes('=')) continue
|
||||||
const [key, ...valueParts] = line.split('=')
|
const [key, ...valueParts] = line.split('=')
|
||||||
let value = valueParts.join('=')
|
const rawValue = valueParts.join('=')
|
||||||
|
let value: string | boolean | number = rawValue
|
||||||
|
|
||||||
if (value.toLowerCase() === 'true' || value.toLowerCase() === 'false') {
|
if (rawValue.toLowerCase() === 'true' || rawValue.toLowerCase() === 'false') {
|
||||||
value = value.toLowerCase() === 'true'
|
value = rawValue.toLowerCase() === 'true'
|
||||||
} else {
|
} else {
|
||||||
const intLike = /^[-+]?\d+$/.test(value)
|
const intLike = /^[-+]?\d+$/.test(rawValue)
|
||||||
if (intLike) {
|
if (intLike) {
|
||||||
const n = Number(value)
|
const n = Number(rawValue)
|
||||||
if (Number.isSafeInteger(n)) {
|
if (Number.isSafeInteger(n)) {
|
||||||
value = n
|
value = n
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
properties[key.trim()] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
properties[key.trim()] = value
|
return properties
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return properties
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const liveProperties = ref<Record<string, any>>({})
|
const liveProperties = ref<Record<string, any>>({})
|
||||||
@@ -302,7 +285,7 @@ const constructServerProperties = (): string => {
|
|||||||
const saveProperties = async () => {
|
const saveProperties = async () => {
|
||||||
try {
|
try {
|
||||||
isUpdating.value = true
|
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))
|
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||||
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
|
originalProperties.value = JSON.parse(JSON.stringify(liveProperties.value))
|
||||||
await props.server.refresh()
|
await props.server.refresh()
|
||||||
|
|||||||
@@ -2,15 +2,17 @@ import type { InferredClientModules } from '../modules'
|
|||||||
import { buildModuleStructure } from '../modules'
|
import { buildModuleStructure } from '../modules'
|
||||||
import type { ClientConfig } from '../types/client'
|
import type { ClientConfig } from '../types/client'
|
||||||
import type { RequestContext, RequestOptions } from '../types/request'
|
import type { RequestContext, RequestOptions } from '../types/request'
|
||||||
|
import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload'
|
||||||
import type { AbstractFeature } from './abstract-feature'
|
import type { AbstractFeature } from './abstract-feature'
|
||||||
import type { AbstractModule } from './abstract-module'
|
import type { AbstractModule } from './abstract-module'
|
||||||
|
import { AbstractUploadClient } from './abstract-upload-client'
|
||||||
import type { AbstractWebSocketClient } from './abstract-websocket'
|
import type { AbstractWebSocketClient } from './abstract-websocket'
|
||||||
import { ModrinthApiError, ModrinthServerError } from './errors'
|
import { ModrinthApiError, ModrinthServerError } from './errors'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Abstract base client for Modrinth APIs
|
* Abstract base client for Modrinth APIs
|
||||||
*/
|
*/
|
||||||
export abstract class AbstractModrinthClient {
|
export abstract class AbstractModrinthClient extends AbstractUploadClient {
|
||||||
protected config: ClientConfig
|
protected config: ClientConfig
|
||||||
protected features: AbstractFeature[]
|
protected features: AbstractFeature[]
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ export abstract class AbstractModrinthClient {
|
|||||||
public readonly iso3166!: InferredClientModules['iso3166']
|
public readonly iso3166!: InferredClientModules['iso3166']
|
||||||
|
|
||||||
constructor(config: ClientConfig) {
|
constructor(config: ClientConfig) {
|
||||||
|
super()
|
||||||
this.config = {
|
this.config = {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
labrinthBaseUrl: 'https://api.modrinth.com',
|
labrinthBaseUrl: 'https://api.modrinth.com',
|
||||||
@@ -176,6 +179,35 @@ export abstract class AbstractModrinthClient {
|
|||||||
return next()
|
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
|
* 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
|
* Build default headers for all requests
|
||||||
*
|
*
|
||||||
@@ -243,6 +305,23 @@ export abstract class AbstractModrinthClient {
|
|||||||
*/
|
*/
|
||||||
protected abstract executeRequest<T>(url: string, options: RequestOptions): Promise<T>
|
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
|
* Normalize an error into a ModrinthApiError
|
||||||
*
|
*
|
||||||
|
|||||||
21
packages/api-client/src/core/abstract-upload-client.ts
Normal 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>
|
||||||
|
}
|
||||||
149
packages/api-client/src/features/node-auth.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export { AbstractModrinthClient } from './core/abstract-client'
|
export { AbstractModrinthClient } from './core/abstract-client'
|
||||||
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
|
export { AbstractFeature, type FeatureConfig } from './core/abstract-feature'
|
||||||
|
export { AbstractUploadClient } from './core/abstract-upload-client'
|
||||||
export {
|
export {
|
||||||
AbstractWebSocketClient,
|
AbstractWebSocketClient,
|
||||||
type WebSocketConnection,
|
type WebSocketConnection,
|
||||||
@@ -15,6 +16,7 @@ export {
|
|||||||
type CircuitBreakerStorage,
|
type CircuitBreakerStorage,
|
||||||
InMemoryCircuitBreakerStorage,
|
InMemoryCircuitBreakerStorage,
|
||||||
} from './features/circuit-breaker'
|
} from './features/circuit-breaker'
|
||||||
|
export { type NodeAuth, type NodeAuthConfig, NodeAuthFeature } from './features/node-auth'
|
||||||
export { PANEL_VERSION, PanelVersionFeature } from './features/panel-version'
|
export { PANEL_VERSION, PanelVersionFeature } from './features/panel-version'
|
||||||
export { type BackoffStrategy, type RetryConfig, RetryFeature } from './features/retry'
|
export { type BackoffStrategy, type RetryConfig, RetryFeature } from './features/retry'
|
||||||
export { type VerboseLoggingConfig, VerboseLoggingFeature } from './features/verbose-logging'
|
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 { NuxtCircuitBreakerStorage, NuxtModrinthClient } from './platform/nuxt'
|
||||||
export type { TauriClientConfig } from './platform/tauri'
|
export type { TauriClientConfig } from './platform/tauri'
|
||||||
export { TauriModrinthClient } 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 * from './types'
|
||||||
|
export { withJWTRetry } from './utils/jwt-retry'
|
||||||
|
|||||||
56
packages/api-client/src/modules/archon/content/v0.ts
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from './backups/v0'
|
export * from './backups/v0'
|
||||||
export * from './backups/v1'
|
export * from './backups/v1'
|
||||||
|
export * from './content/v0'
|
||||||
export * from './servers/v0'
|
export * from './servers/v0'
|
||||||
export * from './servers/v1'
|
export * from './servers/v1'
|
||||||
export * from './types'
|
export * from './types'
|
||||||
|
|||||||
@@ -78,4 +78,20 @@ export class ArchonServersV0Module extends AbstractModule {
|
|||||||
method: 'GET',
|
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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,40 @@
|
|||||||
export namespace Archon {
|
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 Servers {
|
||||||
export namespace v0 {
|
export namespace v0 {
|
||||||
export type ServerGetResponse = {
|
export type ServerGetResponse = {
|
||||||
@@ -274,6 +310,11 @@ export namespace Archon {
|
|||||||
started: string
|
started: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type QueuedFilesystemOp = {
|
||||||
|
op: FilesystemOpKind
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
|
||||||
export type WSFilesystemOpsEvent = {
|
export type WSFilesystemOpsEvent = {
|
||||||
event: 'filesystem-ops'
|
event: 'filesystem-ops'
|
||||||
all: FilesystemOperation[]
|
all: FilesystemOperation[]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { AbstractModrinthClient } from '../core/abstract-client'
|
|||||||
import type { AbstractModule } from '../core/abstract-module'
|
import type { AbstractModule } from '../core/abstract-module'
|
||||||
import { ArchonBackupsV0Module } from './archon/backups/v0'
|
import { ArchonBackupsV0Module } from './archon/backups/v0'
|
||||||
import { ArchonBackupsV1Module } from './archon/backups/v1'
|
import { ArchonBackupsV1Module } from './archon/backups/v1'
|
||||||
|
import { ArchonContentV0Module } from './archon/content/v0'
|
||||||
import { ArchonServersV0Module } from './archon/servers/v0'
|
import { ArchonServersV0Module } from './archon/servers/v0'
|
||||||
import { ArchonServersV1Module } from './archon/servers/v1'
|
import { ArchonServersV1Module } from './archon/servers/v1'
|
||||||
import { ISO3166Module } from './iso3166'
|
import { ISO3166Module } from './iso3166'
|
||||||
@@ -28,6 +29,7 @@ type ModuleConstructor = new (client: AbstractModrinthClient) => AbstractModule
|
|||||||
export const MODULE_REGISTRY = {
|
export const MODULE_REGISTRY = {
|
||||||
archon_backups_v0: ArchonBackupsV0Module,
|
archon_backups_v0: ArchonBackupsV0Module,
|
||||||
archon_backups_v1: ArchonBackupsV1Module,
|
archon_backups_v1: ArchonBackupsV1Module,
|
||||||
|
archon_content_v0: ArchonContentV0Module,
|
||||||
archon_servers_v0: ArchonServersV0Module,
|
archon_servers_v0: ArchonServersV0Module,
|
||||||
archon_servers_v1: ArchonServersV1Module,
|
archon_servers_v1: ArchonServersV1Module,
|
||||||
iso3166_data: ISO3166Module,
|
iso3166_data: ISO3166Module,
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { AbstractModule } from '../../../core/abstract-module'
|
import { AbstractModule } from '../../../core/abstract-module'
|
||||||
|
import type { UploadHandle, UploadProgress } from '../../../types/upload'
|
||||||
|
import type { Kyros } from '../types'
|
||||||
|
|
||||||
export class KyrosFilesV0Module extends AbstractModule {
|
export class KyrosFilesV0Module extends AbstractModule {
|
||||||
public getModuleID(): string {
|
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 path - Directory path (e.g., "/")
|
||||||
* @param nodeToken - JWT token from getFilesystemAuth
|
* @param page - Page number (1-indexed)
|
||||||
* @param path - File path (e.g., "/server-icon-original.png")
|
* @param pageSize - Items per page
|
||||||
* @returns Promise resolving to file Blob
|
* @returns Directory listing with items and pagination info
|
||||||
*/
|
*/
|
||||||
public async downloadFile(nodeInstance: string, nodeToken: string, path: string): Promise<Blob> {
|
public async listDirectory(
|
||||||
return this.client.request<Blob>(`/fs/download`, {
|
path: string,
|
||||||
api: `https://${nodeInstance.replace('v0/fs', '')}`,
|
page: number = 1,
|
||||||
method: 'GET',
|
pageSize: number = 100,
|
||||||
|
): Promise<Kyros.Files.v0.DirectoryResponse> {
|
||||||
|
return this.client.request<Kyros.Files.v0.DirectoryResponse>('/fs/list', {
|
||||||
|
api: '',
|
||||||
version: 'v0',
|
version: 'v0',
|
||||||
params: { path },
|
method: 'GET',
|
||||||
headers: { Authorization: `Bearer ${nodeToken}` },
|
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 path - Destination path (e.g., "/server-icon.png")
|
||||||
* @param file - File to upload
|
* @param file - File to upload
|
||||||
|
* @param options - Optional progress callback and feature overrides
|
||||||
|
* @returns UploadHandle with promise, onProgress, and cancel
|
||||||
*/
|
*/
|
||||||
public async uploadFile(
|
public uploadFile(
|
||||||
nodeInstance: string,
|
|
||||||
nodeToken: string,
|
|
||||||
path: string,
|
path: string,
|
||||||
file: File,
|
file: File | Blob,
|
||||||
): Promise<void> {
|
options?: {
|
||||||
return this.client.request<void>(`/fs/create`, {
|
onProgress?: (progress: UploadProgress) => void
|
||||||
api: `https://${nodeInstance.replace('v0/fs', '')}`,
|
retry?: boolean | number
|
||||||
method: 'POST',
|
},
|
||||||
|
): UploadHandle<void> {
|
||||||
|
return this.client.upload<void>('/fs/create', {
|
||||||
|
api: '',
|
||||||
version: 'v0',
|
version: 'v0',
|
||||||
|
file,
|
||||||
params: { path, type: 'file' },
|
params: { path, type: 'file' },
|
||||||
headers: {
|
onProgress: options?.onProgress,
|
||||||
Authorization: `Bearer ${nodeToken}`,
|
retry: options?.retry,
|
||||||
'Content-Type': 'application/octet-stream',
|
useNodeAuth: true,
|
||||||
},
|
})
|
||||||
body: file,
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
export namespace Kyros {
|
export namespace Kyros {
|
||||||
export namespace Files {
|
export namespace Files {
|
||||||
export namespace v0 {
|
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[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { $fetch, FetchError } from 'ofetch'
|
import { $fetch, FetchError } from 'ofetch'
|
||||||
|
|
||||||
import { AbstractModrinthClient } from '../core/abstract-client'
|
|
||||||
import type { ModrinthApiError } from '../core/errors'
|
import type { ModrinthApiError } from '../core/errors'
|
||||||
import type { ClientConfig } from '../types/client'
|
import type { ClientConfig } from '../types/client'
|
||||||
import type { RequestOptions } from '../types/request'
|
import type { RequestOptions } from '../types/request'
|
||||||
import { GenericWebSocketClient } from './websocket-generic'
|
import { GenericWebSocketClient } from './websocket-generic'
|
||||||
|
import { XHRUploadClient } from './xhr-upload-client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generic platform client using ofetch
|
* 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 })
|
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export class GenericModrinthClient extends AbstractModrinthClient {
|
export class GenericModrinthClient extends XHRUploadClient {
|
||||||
constructor(config: ClientConfig) {
|
constructor(config: ClientConfig) {
|
||||||
super(config)
|
super(config)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import { FetchError } from 'ofetch'
|
import { FetchError } from 'ofetch'
|
||||||
|
|
||||||
import { AbstractModrinthClient } from '../core/abstract-client'
|
import { ModrinthApiError } from '../core/errors'
|
||||||
import type { ModrinthApiError } from '../core/errors'
|
|
||||||
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
|
import type { CircuitBreakerState, CircuitBreakerStorage } from '../features/circuit-breaker'
|
||||||
import type { ClientConfig } from '../types/client'
|
import type { ClientConfig } from '../types/client'
|
||||||
import type { RequestOptions } from '../types/request'
|
import type { RequestOptions } from '../types/request'
|
||||||
|
import type { UploadHandle, UploadRequestOptions } from '../types/upload'
|
||||||
import { GenericWebSocketClient } from './websocket-generic'
|
import { GenericWebSocketClient } from './websocket-generic'
|
||||||
|
import { XHRUploadClient } from './xhr-upload-client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Circuit breaker storage using Nuxt's useState
|
* 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.
|
* 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
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* // In a Nuxt composable
|
* // In a Nuxt composable
|
||||||
@@ -70,7 +73,7 @@ export interface NuxtClientConfig extends ClientConfig {
|
|||||||
* const project = await client.request('/project/sodium', { api: 'labrinth', version: 2 })
|
* 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
|
declare protected config: NuxtClientConfig
|
||||||
|
|
||||||
constructor(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> {
|
protected async executeRequest<T>(url: string, options: RequestOptions): Promise<T> {
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error - $fetch is provided by Nuxt runtime
|
// @ts-expect-error - $fetch is provided by Nuxt runtime
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { AbstractModrinthClient } from '../core/abstract-client'
|
|
||||||
import type { ModrinthApiError } from '../core/errors'
|
import type { ModrinthApiError } from '../core/errors'
|
||||||
import type { ClientConfig } from '../types/client'
|
import type { ClientConfig } from '../types/client'
|
||||||
import type { RequestOptions } from '../types/request'
|
import type { RequestOptions } from '../types/request'
|
||||||
import { GenericWebSocketClient } from './websocket-generic'
|
import { GenericWebSocketClient } from './websocket-generic'
|
||||||
|
import { XHRUploadClient } from './xhr-upload-client'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tauri-specific configuration
|
* Tauri-specific configuration
|
||||||
@@ -20,7 +20,9 @@ interface HttpError extends Error {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tauri platform client using Tauri v2 HTTP plugin
|
* Tauri platform client using Tauri v2 HTTP plugin
|
||||||
|
*
|
||||||
|
* Extends XHRUploadClient to provide upload with progress tracking.
|
||||||
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```typescript
|
* ```typescript
|
||||||
* import { getVersion } from '@tauri-apps/api/app'
|
* 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 })
|
* 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
|
declare protected config: TauriClientConfig
|
||||||
|
|
||||||
constructor(config: TauriClientConfig) {
|
constructor(config: TauriClientConfig) {
|
||||||
|
|||||||
142
packages/api-client/src/platform/xhr-upload-client.ts
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
45
packages/api-client/src/state/node-auth.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -11,3 +11,4 @@ export type { ClientConfig, RequestHooks } from './client'
|
|||||||
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
|
export type { ApiErrorData, ModrinthErrorResponse } from './errors'
|
||||||
export { isModrinthErrorResponse } from './errors'
|
export { isModrinthErrorResponse } from './errors'
|
||||||
export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request'
|
export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request'
|
||||||
|
export type { UploadHandle, UploadMetadata, UploadProgress, UploadRequestOptions } from './upload'
|
||||||
|
|||||||
@@ -73,6 +73,13 @@ export type RequestOptions = {
|
|||||||
* @default false
|
* @default false
|
||||||
*/
|
*/
|
||||||
skipAuth?: boolean
|
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
|
* 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>
|
metadata?: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|||||||
51
packages/api-client/src/types/upload.ts
Normal 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
|
||||||
|
}
|
||||||
20
packages/api-client/src/utils/jwt-retry.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import _BookmarkIcon from './icons/bookmark.svg?component'
|
|||||||
import _BotIcon from './icons/bot.svg?component'
|
import _BotIcon from './icons/bot.svg?component'
|
||||||
import _BoxIcon from './icons/box.svg?component'
|
import _BoxIcon from './icons/box.svg?component'
|
||||||
import _BoxImportIcon from './icons/box-import.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 _BracesIcon from './icons/braces.svg?component'
|
||||||
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
|
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
|
||||||
import _BugIcon from './icons/bug.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 _ChevronDownIcon from './icons/chevron-down.svg?component'
|
||||||
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
|
import _ChevronLeftIcon from './icons/chevron-left.svg?component'
|
||||||
import _ChevronRightIcon from './icons/chevron-right.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 _CircleUserIcon from './icons/circle-user.svg?component'
|
||||||
import _ClearIcon from './icons/clear.svg?component'
|
import _ClearIcon from './icons/clear.svg?component'
|
||||||
import _ClientIcon from './icons/client.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 _CurrencyIcon from './icons/currency.svg?component'
|
||||||
import _DashboardIcon from './icons/dashboard.svg?component'
|
import _DashboardIcon from './icons/dashboard.svg?component'
|
||||||
import _DatabaseIcon from './icons/database.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 _DownloadIcon from './icons/download.svg?component'
|
||||||
import _DropdownIcon from './icons/dropdown.svg?component'
|
import _DropdownIcon from './icons/dropdown.svg?component'
|
||||||
import _EditIcon from './icons/edit.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 _FilterXIcon from './icons/filter-x.svg?component'
|
||||||
import _FolderIcon from './icons/folder.svg?component'
|
import _FolderIcon from './icons/folder.svg?component'
|
||||||
import _FolderArchiveIcon from './icons/folder-archive.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 _FolderOpenIcon from './icons/folder-open.svg?component'
|
||||||
import _FolderSearchIcon from './icons/folder-search.svg?component'
|
import _FolderSearchIcon from './icons/folder-search.svg?component'
|
||||||
import _FolderUpIcon from './icons/folder-up.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 _KeyboardIcon from './icons/keyboard.svg?component'
|
||||||
import _LandmarkIcon from './icons/landmark.svg?component'
|
import _LandmarkIcon from './icons/landmark.svg?component'
|
||||||
import _LanguagesIcon from './icons/languages.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 _LeftArrowIcon from './icons/left-arrow.svg?component'
|
||||||
import _LibraryIcon from './icons/library.svg?component'
|
import _LibraryIcon from './icons/library.svg?component'
|
||||||
import _LightBulbIcon from './icons/light-bulb.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 _PackageClosedIcon from './icons/package-closed.svg?component'
|
||||||
import _PackageOpenIcon from './icons/package-open.svg?component'
|
import _PackageOpenIcon from './icons/package-open.svg?component'
|
||||||
import _PaintbrushIcon from './icons/paintbrush.svg?component'
|
import _PaintbrushIcon from './icons/paintbrush.svg?component'
|
||||||
|
import _PaletteIcon from './icons/palette.svg?component'
|
||||||
import _PickaxeIcon from './icons/pickaxe.svg?component'
|
import _PickaxeIcon from './icons/pickaxe.svg?component'
|
||||||
import _PlayIcon from './icons/play.svg?component'
|
import _PlayIcon from './icons/play.svg?component'
|
||||||
import _PlugIcon from './icons/plug.svg?component'
|
import _PlugIcon from './icons/plug.svg?component'
|
||||||
@@ -252,6 +258,7 @@ export const BookmarkIcon = _BookmarkIcon
|
|||||||
export const BotIcon = _BotIcon
|
export const BotIcon = _BotIcon
|
||||||
export const BoxIcon = _BoxIcon
|
export const BoxIcon = _BoxIcon
|
||||||
export const BoxImportIcon = _BoxImportIcon
|
export const BoxImportIcon = _BoxImportIcon
|
||||||
|
export const BoxesIcon = _BoxesIcon
|
||||||
export const BracesIcon = _BracesIcon
|
export const BracesIcon = _BracesIcon
|
||||||
export const BrushCleaningIcon = _BrushCleaningIcon
|
export const BrushCleaningIcon = _BrushCleaningIcon
|
||||||
export const BugIcon = _BugIcon
|
export const BugIcon = _BugIcon
|
||||||
@@ -265,6 +272,7 @@ export const CheckCircleIcon = _CheckCircleIcon
|
|||||||
export const ChevronDownIcon = _ChevronDownIcon
|
export const ChevronDownIcon = _ChevronDownIcon
|
||||||
export const ChevronLeftIcon = _ChevronLeftIcon
|
export const ChevronLeftIcon = _ChevronLeftIcon
|
||||||
export const ChevronRightIcon = _ChevronRightIcon
|
export const ChevronRightIcon = _ChevronRightIcon
|
||||||
|
export const ChevronUpIcon = _ChevronUpIcon
|
||||||
export const CircleUserIcon = _CircleUserIcon
|
export const CircleUserIcon = _CircleUserIcon
|
||||||
export const ClearIcon = _ClearIcon
|
export const ClearIcon = _ClearIcon
|
||||||
export const ClientIcon = _ClientIcon
|
export const ClientIcon = _ClientIcon
|
||||||
@@ -287,6 +295,7 @@ export const CubeIcon = _CubeIcon
|
|||||||
export const CurrencyIcon = _CurrencyIcon
|
export const CurrencyIcon = _CurrencyIcon
|
||||||
export const DashboardIcon = _DashboardIcon
|
export const DashboardIcon = _DashboardIcon
|
||||||
export const DatabaseIcon = _DatabaseIcon
|
export const DatabaseIcon = _DatabaseIcon
|
||||||
|
export const DatabaseBackupIcon = _DatabaseBackupIcon
|
||||||
export const DownloadIcon = _DownloadIcon
|
export const DownloadIcon = _DownloadIcon
|
||||||
export const DropdownIcon = _DropdownIcon
|
export const DropdownIcon = _DropdownIcon
|
||||||
export const EditIcon = _EditIcon
|
export const EditIcon = _EditIcon
|
||||||
@@ -304,6 +313,7 @@ export const FilterIcon = _FilterIcon
|
|||||||
export const FilterXIcon = _FilterXIcon
|
export const FilterXIcon = _FilterXIcon
|
||||||
export const FolderIcon = _FolderIcon
|
export const FolderIcon = _FolderIcon
|
||||||
export const FolderArchiveIcon = _FolderArchiveIcon
|
export const FolderArchiveIcon = _FolderArchiveIcon
|
||||||
|
export const FolderCogIcon = _FolderCogIcon
|
||||||
export const FolderOpenIcon = _FolderOpenIcon
|
export const FolderOpenIcon = _FolderOpenIcon
|
||||||
export const FolderSearchIcon = _FolderSearchIcon
|
export const FolderSearchIcon = _FolderSearchIcon
|
||||||
export const FolderUpIcon = _FolderUpIcon
|
export const FolderUpIcon = _FolderUpIcon
|
||||||
@@ -338,6 +348,7 @@ export const KeyIcon = _KeyIcon
|
|||||||
export const KeyboardIcon = _KeyboardIcon
|
export const KeyboardIcon = _KeyboardIcon
|
||||||
export const LandmarkIcon = _LandmarkIcon
|
export const LandmarkIcon = _LandmarkIcon
|
||||||
export const LanguagesIcon = _LanguagesIcon
|
export const LanguagesIcon = _LanguagesIcon
|
||||||
|
export const LayoutTemplateIcon = _LayoutTemplateIcon
|
||||||
export const LeftArrowIcon = _LeftArrowIcon
|
export const LeftArrowIcon = _LeftArrowIcon
|
||||||
export const LibraryIcon = _LibraryIcon
|
export const LibraryIcon = _LibraryIcon
|
||||||
export const LightBulbIcon = _LightBulbIcon
|
export const LightBulbIcon = _LightBulbIcon
|
||||||
@@ -375,6 +386,7 @@ export const PackageIcon = _PackageIcon
|
|||||||
export const PackageClosedIcon = _PackageClosedIcon
|
export const PackageClosedIcon = _PackageClosedIcon
|
||||||
export const PackageOpenIcon = _PackageOpenIcon
|
export const PackageOpenIcon = _PackageOpenIcon
|
||||||
export const PaintbrushIcon = _PaintbrushIcon
|
export const PaintbrushIcon = _PaintbrushIcon
|
||||||
|
export const PaletteIcon = _PaletteIcon
|
||||||
export const PickaxeIcon = _PickaxeIcon
|
export const PickaxeIcon = _PickaxeIcon
|
||||||
export const PlayIcon = _PlayIcon
|
export const PlayIcon = _PlayIcon
|
||||||
export const PlugIcon = _PlugIcon
|
export const PlugIcon = _PlugIcon
|
||||||
|
|||||||
26
packages/assets/icons/boxes.svg
Normal 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 |
5
packages/assets/icons/chevron-up.svg
Normal 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 |
20
packages/assets/icons/database-backup.svg
Normal 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 |
14
packages/assets/icons/folder-cog.svg
Normal 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 |
17
packages/assets/icons/layout-template.svg
Normal 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 |
|
Before Width: | Height: | Size: 619 B After Width: | Height: | Size: 619 B |
211
packages/assets/styles/ace.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
"@types/three": "^0.172.0",
|
"@types/three": "^0.172.0",
|
||||||
"@vintl/how-ago": "^3.0.1",
|
"@vintl/how-ago": "^3.0.1",
|
||||||
"@vueuse/core": "^11.1.0",
|
"@vueuse/core": "^11.1.0",
|
||||||
|
"ace-builds": "^1.43.5",
|
||||||
"apexcharts": "^4.0.0",
|
"apexcharts": "^4.0.0",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"floating-vue": "^5.2.2",
|
"floating-vue": "^5.2.2",
|
||||||
@@ -82,6 +83,7 @@
|
|||||||
"vue-multiselect": "3.0.0",
|
"vue-multiselect": "3.0.0",
|
||||||
"vue-select": "4.0.0-beta.6",
|
"vue-select": "4.0.0-beta.6",
|
||||||
"vue-typed-virtual-list": "^1.0.10",
|
"vue-typed-virtual-list": "^1.0.10",
|
||||||
|
"vue3-ace-editor": "^2.2.4",
|
||||||
"vue3-apexcharts": "^1.4.4",
|
"vue3-apexcharts": "^1.4.4",
|
||||||
"xss": "^1.0.14"
|
"xss": "^1.0.14"
|
||||||
},
|
},
|
||||||
|
|||||||
55
packages/ui/src/components/base/FloatingActionBar.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
shown: boolean
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Transition name="floating-action-bar" appear>
|
||||||
|
<div v-if="shown" class="floating-action-bar fixed w-full z-10 left-0 p-4 bottom-0">
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[77rem] p-4"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.floating-action-bar {
|
||||||
|
transition: bottom 0.25s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-action-bar-enter-active {
|
||||||
|
transition:
|
||||||
|
transform 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96),
|
||||||
|
opacity 0.25s cubic-bezier(0.15, 1.4, 0.64, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-action-bar-leave-active {
|
||||||
|
transition:
|
||||||
|
transform 0.25s ease,
|
||||||
|
opacity 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-action-bar-enter-from {
|
||||||
|
transform: scale(0.5) translateY(10rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.floating-action-bar-leave-to {
|
||||||
|
transform: scale(0.96) translateY(0.25rem);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (any-hover: none) and (max-width: 640px) {
|
||||||
|
.floating-action-bar {
|
||||||
|
bottom: var(--size-mobile-navbar-height);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-mobile-nav .floating-action-bar {
|
||||||
|
bottom: var(--size-mobile-navbar-height-expanded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,6 +5,7 @@ import { type Component, computed } from 'vue'
|
|||||||
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
import { defineMessage, type MessageDescriptor, useVIntl } from '../../composables/i18n'
|
||||||
import { commonMessages } from '../../utils'
|
import { commonMessages } from '../../utils'
|
||||||
import ButtonStyled from './ButtonStyled.vue'
|
import ButtonStyled from './ButtonStyled.vue'
|
||||||
|
import FloatingActionBar from './FloatingActionBar.vue'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
@@ -53,64 +54,21 @@ function localizeIfPossible(message: MessageDescriptor | string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition name="pop-in">
|
<FloatingActionBar :shown="shown">
|
||||||
<div v-if="shown" class="fixed w-full z-10 left-0 p-4 unsaved-changes-popup">
|
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
|
||||||
<div
|
<div class="ml-auto flex gap-2">
|
||||||
class="flex items-center gap-2 rounded-2xl bg-bg-raised border-2 border-divider border-solid mx-auto max-w-[77rem] p-4"
|
<ButtonStyled v-if="canReset" type="transparent">
|
||||||
>
|
<button :disabled="saving" @click="(e) => emit('reset', e)">
|
||||||
<p class="m-0 font-semibold text-sm md:text-base">{{ localizeIfPossible(text) }}</p>
|
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||||
<div class="ml-auto flex gap-2">
|
</button>
|
||||||
<ButtonStyled v-if="canReset" type="transparent">
|
</ButtonStyled>
|
||||||
<button :disabled="saving" @click="(e) => emit('reset', e)">
|
<ButtonStyled color="brand">
|
||||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
<button :disabled="saving" @click="(e) => emit('save', e)">
|
||||||
</button>
|
<SpinnerIcon v-if="saving" class="animate-spin" />
|
||||||
</ButtonStyled>
|
<component :is="saveIcon" v-else />
|
||||||
<ButtonStyled color="brand">
|
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
|
||||||
<button :disabled="saving" @click="(e) => emit('save', e)">
|
</button>
|
||||||
<SpinnerIcon v-if="saving" class="animate-spin" />
|
</ButtonStyled>
|
||||||
<component :is="saveIcon" v-else />
|
|
||||||
{{ localizeIfPossible(saving ? savingLabel : saveLabel) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</FloatingActionBar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.pop-in-enter-active {
|
|
||||||
transition: all 0.5s cubic-bezier(0.15, 1.4, 0.64, 0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.pop-in-leave-active {
|
|
||||||
transition: all 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pop-in-enter-from {
|
|
||||||
scale: 0.5;
|
|
||||||
translate: 0 10rem;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pop-in-leave-to {
|
|
||||||
scale: 0.96;
|
|
||||||
translate: 0 0.25rem;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.unsaved-changes-popup {
|
|
||||||
transition: bottom 0.25s ease-in-out;
|
|
||||||
bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (any-hover: none) and (max-width: 640px) {
|
|
||||||
.unsaved-changes-popup {
|
|
||||||
bottom: var(--size-mobile-navbar-height);
|
|
||||||
}
|
|
||||||
|
|
||||||
.expanded-mobile-nav .unsaved-changes-popup {
|
|
||||||
bottom: var(--size-mobile-navbar-height-expanded);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export { default as ErrorInformationCard } from './ErrorInformationCard.vue'
|
|||||||
export { default as FileInput } from './FileInput.vue'
|
export { default as FileInput } from './FileInput.vue'
|
||||||
export type { FilterBarOption } from './FilterBar.vue'
|
export type { FilterBarOption } from './FilterBar.vue'
|
||||||
export { default as FilterBar } 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 HeadingLink } from './HeadingLink.vue'
|
||||||
export { default as HorizontalRule } from './HorizontalRule.vue'
|
export { default as HorizontalRule } from './HorizontalRule.vue'
|
||||||
export { default as IconSelect } from './IconSelect.vue'
|
export { default as IconSelect } from './IconSelect.vue'
|
||||||
|
|||||||
232
packages/ui/src/components/servers/files/FileNavbar.vue
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="flex select-none flex-col justify-between gap-2 sm:flex-row sm:items-center"
|
||||||
|
aria-label="File navigation"
|
||||||
|
>
|
||||||
|
<nav
|
||||||
|
aria-label="Breadcrumb navigation"
|
||||||
|
class="m-0 flex min-w-0 flex-shrink items-center p-0 text-contrast"
|
||||||
|
>
|
||||||
|
<ol class="m-0 flex min-w-0 flex-shrink list-none items-center p-0">
|
||||||
|
<li class="mr-4 flex-shrink-0">
|
||||||
|
<ButtonStyled circular>
|
||||||
|
<button
|
||||||
|
v-tooltip="'Back to home'"
|
||||||
|
type="button"
|
||||||
|
class="!size-10 bg-surface-4 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||||
|
@click="$emit('navigateHome')"
|
||||||
|
@mouseenter="$emit('prefetchHome')"
|
||||||
|
>
|
||||||
|
<HomeIcon />
|
||||||
|
<span class="sr-only">Home</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</li>
|
||||||
|
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
|
||||||
|
<ol class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0">
|
||||||
|
<TransitionGroup
|
||||||
|
name="breadcrumb"
|
||||||
|
tag="span"
|
||||||
|
class="relative flex min-w-0 flex-shrink items-center"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
v-for="(segment, index) in breadcrumbs"
|
||||||
|
:key="`${segment || index}-group`"
|
||||||
|
class="relative flex min-w-0 flex-shrink items-center text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex min-w-0 flex-shrink items-center">
|
||||||
|
<ButtonStyled type="transparent">
|
||||||
|
<button
|
||||||
|
class="cursor-pointer truncate focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
|
||||||
|
:aria-current="
|
||||||
|
!isEditing && index === breadcrumbs.length - 1 ? 'location' : undefined
|
||||||
|
"
|
||||||
|
:class="{
|
||||||
|
'!text-contrast': !isEditing && index === breadcrumbs.length - 1,
|
||||||
|
}"
|
||||||
|
@click="$emit('navigate', index)"
|
||||||
|
>
|
||||||
|
{{ segment || '' }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ChevronRightIcon
|
||||||
|
v-if="index < breadcrumbs.length - 1 || isEditing"
|
||||||
|
class="size-4 flex-shrink-0 text-secondary"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</TransitionGroup>
|
||||||
|
<li v-if="isEditing && editingFileName" class="flex items-center px-3 text-sm">
|
||||||
|
<span class="font-semibold !text-contrast" aria-current="location">
|
||||||
|
{{ editingFileName }}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div v-if="!isEditing" class="flex flex-shrink-0 items-center gap-2">
|
||||||
|
<div class="iconified-input w-full sm:w-[280px]">
|
||||||
|
<SearchIcon aria-hidden="true" class="!text-secondary" />
|
||||||
|
<input
|
||||||
|
id="search-folder"
|
||||||
|
:value="searchQuery"
|
||||||
|
type="search"
|
||||||
|
name="search"
|
||||||
|
autocomplete="off"
|
||||||
|
class="h-10 w-full rounded-[14px] border-0 bg-surface-4 text-sm"
|
||||||
|
placeholder="Search files"
|
||||||
|
@input="$emit('update:searchQuery', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ButtonStyled type="outlined">
|
||||||
|
<OverflowMenu
|
||||||
|
:dropdown-id="`create-new-${baseId}`"
|
||||||
|
position="bottom"
|
||||||
|
direction="left"
|
||||||
|
aria-label="Create new..."
|
||||||
|
class="!h-10 justify-center gap-2 !border-[1px] !border-surface-5"
|
||||||
|
:options="[
|
||||||
|
{ id: 'file', action: () => $emit('create', 'file') },
|
||||||
|
{ id: 'directory', action: () => $emit('create', 'directory') },
|
||||||
|
{ id: 'upload', action: () => $emit('upload') },
|
||||||
|
{ divider: true },
|
||||||
|
{ id: 'upload-zip', shown: false, action: () => $emit('uploadZip') },
|
||||||
|
{ id: 'install-from-url', action: () => $emit('unzipFromUrl', false) },
|
||||||
|
{ id: 'install-cf-pack', action: () => $emit('unzipFromUrl', true) },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<PlusIcon aria-hidden="true" class="h-5 w-5" />
|
||||||
|
<DropdownIcon aria-hidden="true" class="h-5 w-5" />
|
||||||
|
<template #file> <BoxIcon aria-hidden="true" /> New file </template>
|
||||||
|
<template #directory> <FolderOpenIcon aria-hidden="true" /> New folder </template>
|
||||||
|
<template #upload> <UploadIcon aria-hidden="true" /> Upload file </template>
|
||||||
|
<template #upload-zip>
|
||||||
|
<FileArchiveIcon aria-hidden="true" /> Upload from .zip file
|
||||||
|
</template>
|
||||||
|
<template #install-from-url>
|
||||||
|
<LinkIcon aria-hidden="true" /> Upload from .zip URL
|
||||||
|
</template>
|
||||||
|
<template #install-cf-pack>
|
||||||
|
<CurseForgeIcon aria-hidden="true" /> Install CurseForge pack
|
||||||
|
</template>
|
||||||
|
</OverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!isEditingImage" class="flex gap-2">
|
||||||
|
<Button
|
||||||
|
v-if="isLogFile"
|
||||||
|
v-tooltip="'Share to mclo.gs'"
|
||||||
|
icon-only
|
||||||
|
transparent
|
||||||
|
aria-label="Share to mclo.gs"
|
||||||
|
@click="$emit('share')"
|
||||||
|
>
|
||||||
|
<ShareIcon />
|
||||||
|
</Button>
|
||||||
|
<ButtonStyled type="transparent">
|
||||||
|
<TeleportOverflowMenu
|
||||||
|
aria-label="Save file"
|
||||||
|
:options="[
|
||||||
|
{ id: 'save', action: () => $emit('save') },
|
||||||
|
{ id: 'save-as', action: () => $emit('saveAs') },
|
||||||
|
{ id: 'save-restart', action: () => $emit('saveRestart') },
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<SaveIcon aria-hidden="true" />
|
||||||
|
<DropdownIcon aria-hidden="true" class="h-5 w-5 text-secondary" />
|
||||||
|
<template #save> <SaveIcon aria-hidden="true" /> Save </template>
|
||||||
|
<template #save-as> <SaveIcon aria-hidden="true" /> Save as... </template>
|
||||||
|
<template #save-restart>
|
||||||
|
<RefreshCwIcon aria-hidden="true" />
|
||||||
|
Save & restart
|
||||||
|
</template>
|
||||||
|
</TeleportOverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
BoxIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
CurseForgeIcon,
|
||||||
|
DropdownIcon,
|
||||||
|
FileArchiveIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
HomeIcon,
|
||||||
|
LinkIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RefreshCwIcon,
|
||||||
|
SaveIcon,
|
||||||
|
SearchIcon,
|
||||||
|
ShareIcon,
|
||||||
|
UploadIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import { Button, ButtonStyled, OverflowMenu } from '@modrinth/ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import TeleportOverflowMenu from './explorer/TeleportOverflowMenu.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
breadcrumbs: string[]
|
||||||
|
isEditing: boolean
|
||||||
|
editingFileName?: string
|
||||||
|
editingFilePath?: string
|
||||||
|
isEditingImage?: boolean
|
||||||
|
searchQuery: string
|
||||||
|
baseId: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
navigate: [index: number]
|
||||||
|
navigateHome: []
|
||||||
|
prefetchHome: []
|
||||||
|
'update:searchQuery': [value: string]
|
||||||
|
create: [type: 'file' | 'directory']
|
||||||
|
upload: []
|
||||||
|
uploadZip: []
|
||||||
|
unzipFromUrl: [cf: boolean]
|
||||||
|
save: []
|
||||||
|
saveAs: []
|
||||||
|
saveRestart: []
|
||||||
|
share: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isLogFile = computed(() => {
|
||||||
|
return props.editingFilePath?.startsWith('logs') || props.editingFilePath?.endsWith('.log')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.breadcrumb-move,
|
||||||
|
.breadcrumb-enter-active,
|
||||||
|
.breadcrumb-leave-active {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px) scale(0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-10px) scale(0.8);
|
||||||
|
filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-leave-active {
|
||||||
|
position: relative;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-move {
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
236
packages/ui/src/components/servers/files/editor/FileEditor.vue
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full w-full flex-col gap-4">
|
||||||
|
<div class="flex flex-col overflow-hidden rounded-[20px] shadow-md">
|
||||||
|
<div class="h-full w-full flex-grow">
|
||||||
|
<component
|
||||||
|
:is="props.editorComponent"
|
||||||
|
v-if="!isEditingImage && props.editorComponent"
|
||||||
|
v-model:value="fileContent"
|
||||||
|
:lang="editorLanguage"
|
||||||
|
theme="modrinth"
|
||||||
|
:print-margin="false"
|
||||||
|
style="height: 750px; font-size: 1rem"
|
||||||
|
class="ace-modrinth rounded-[20px]"
|
||||||
|
@init="onEditorInit"
|
||||||
|
/>
|
||||||
|
<FileImageViewer v-else-if="isEditingImage && imagePreview" :image-blob="imagePreview" />
|
||||||
|
<div
|
||||||
|
v-else-if="isLoading || !props.editorComponent"
|
||||||
|
class="flex h-[750px] items-center justify-center rounded-[20px] bg-bg-raised"
|
||||||
|
>
|
||||||
|
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { SpinnerIcon } from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
getEditorLanguage,
|
||||||
|
getFileExtension,
|
||||||
|
injectModrinthClient,
|
||||||
|
injectModrinthServerContext,
|
||||||
|
injectNotificationManager,
|
||||||
|
isImageFile,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { useQueryClient } from '@tanstack/vue-query'
|
||||||
|
import { type Component, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import FileImageViewer from './FileImageViewer.vue'
|
||||||
|
|
||||||
|
interface MclogsResponse {
|
||||||
|
success: boolean
|
||||||
|
url?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
file: { name: string; type: string; path: string } | null
|
||||||
|
editorComponent: Component | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const notifications = injectNotificationManager()
|
||||||
|
const { addNotification } = notifications
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
const serverContext = injectModrinthServerContext()
|
||||||
|
const { serverId } = serverContext
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const modulesLoaded = inject<Promise<void>>('modulesLoaded')
|
||||||
|
|
||||||
|
const fileContent = ref('')
|
||||||
|
const isEditingImage = ref(false)
|
||||||
|
const imagePreview = ref<Blob | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const editorInstance = ref<unknown>(null)
|
||||||
|
|
||||||
|
const editorLanguage = computed(() => {
|
||||||
|
const ext = getFileExtension(props.file?.name ?? '')
|
||||||
|
return getEditorLanguage(ext)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.file,
|
||||||
|
async (newFile) => {
|
||||||
|
if (newFile) {
|
||||||
|
await loadFileContent(newFile)
|
||||||
|
} else {
|
||||||
|
resetState()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadFileContent(file: { name: string; type: string; path: string }) {
|
||||||
|
isLoading.value = true
|
||||||
|
try {
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
const extension = getFileExtension(file.name)
|
||||||
|
|
||||||
|
if (file.type === 'file' && isImageFile(extension)) {
|
||||||
|
const content = await client.kyros.files_v0.downloadFile(file.path)
|
||||||
|
isEditingImage.value = true
|
||||||
|
imagePreview.value = content
|
||||||
|
} else {
|
||||||
|
isEditingImage.value = false
|
||||||
|
const cachedContent = queryClient.getQueryData<string>(['file-content', serverId, file.path])
|
||||||
|
if (cachedContent) {
|
||||||
|
fileContent.value = cachedContent
|
||||||
|
} else {
|
||||||
|
const content = await client.kyros.files_v0.downloadFile(file.path)
|
||||||
|
fileContent.value = await content.text()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching file content:', error)
|
||||||
|
addNotification({
|
||||||
|
title: 'Failed to open file',
|
||||||
|
text: 'Could not load file contents.',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
emit('close')
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetState() {
|
||||||
|
fileContent.value = ''
|
||||||
|
isEditingImage.value = false
|
||||||
|
imagePreview.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEditorInit(editor: {
|
||||||
|
commands: {
|
||||||
|
addCommand: (cmd: {
|
||||||
|
name: string
|
||||||
|
bindKey: { win: string; mac: string }
|
||||||
|
exec: () => void
|
||||||
|
}) => void
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
editorInstance.value = editor
|
||||||
|
|
||||||
|
editor.commands.addCommand({
|
||||||
|
name: 'save',
|
||||||
|
bindKey: { win: 'Ctrl-S', mac: 'Command-S' },
|
||||||
|
exec: () => saveFileContent(false),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveFileContent(exit: boolean = true) {
|
||||||
|
if (!props.file) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.kyros.files_v0.updateFile(props.file.path, fileContent.value)
|
||||||
|
|
||||||
|
if (exit) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
title: 'File saved',
|
||||||
|
text: 'Your file has been saved.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving file content:', error)
|
||||||
|
addNotification({ title: 'Save failed', text: 'Could not save the file.', type: 'error' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAndRestart() {
|
||||||
|
await saveFileContent(false)
|
||||||
|
await client.archon.servers_v0.power(serverId, 'Restart')
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
title: 'Server restarted',
|
||||||
|
text: 'Your server has been restarted.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareToMclogs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://api.mclo.gs/1/log', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({ content: fileContent.value }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = (await response.json()) as MclogsResponse
|
||||||
|
|
||||||
|
if (data.success && data.url) {
|
||||||
|
await navigator.clipboard.writeText(data.url)
|
||||||
|
addNotification({
|
||||||
|
title: 'Log URL copied',
|
||||||
|
text: 'Your log file URL has been copied to your clipboard.',
|
||||||
|
type: 'success',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error sharing file:', error)
|
||||||
|
addNotification({
|
||||||
|
title: 'Failed to share file',
|
||||||
|
text: 'Could not upload to mclo.gs.',
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
resetState()
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (modulesLoaded) {
|
||||||
|
await modulesLoaded
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
editorInstance.value = null
|
||||||
|
resetState()
|
||||||
|
})
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
saveFileContent,
|
||||||
|
saveAndRestart,
|
||||||
|
shareToMclogs,
|
||||||
|
close,
|
||||||
|
isEditingImage,
|
||||||
|
fileContent,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
|
||||||
|
<div
|
||||||
|
ref="container"
|
||||||
|
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-[20px] bg-black active:cursor-grabbing"
|
||||||
|
@mousedown="startPan"
|
||||||
|
@mousemove="handlePan"
|
||||||
|
@mouseup="stopPan"
|
||||||
|
@mouseleave="stopPan"
|
||||||
|
@wheel.prevent="handleWheel"
|
||||||
|
>
|
||||||
|
<div v-if="state.isLoading" />
|
||||||
|
<div
|
||||||
|
v-if="state.hasError"
|
||||||
|
class="flex h-full w-full flex-col items-center justify-center gap-8"
|
||||||
|
>
|
||||||
|
<TriangleAlertIcon class="size-8 text-red" />
|
||||||
|
<p class="m-0">{{ state.errorMessage || 'Invalid or empty image file.' }}</p>
|
||||||
|
</div>
|
||||||
|
<img
|
||||||
|
v-show="isReady"
|
||||||
|
ref="imageRef"
|
||||||
|
:src="imageObjectUrl"
|
||||||
|
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
|
||||||
|
:style="imageStyle"
|
||||||
|
alt="Viewed image"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
@error="handleImageError"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="!state.hasError"
|
||||||
|
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
|
||||||
|
>
|
||||||
|
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
|
||||||
|
<button v-tooltip="'Zoom in'">
|
||||||
|
<ZoomInIcon />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
|
||||||
|
<button v-tooltip="'Zoom out'">
|
||||||
|
<ZoomOutIcon />
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled type="transparent" @click="reset">
|
||||||
|
<button>
|
||||||
|
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
|
||||||
|
<span class="ml-4 text-sm text-blue">Reset</span>
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { TriangleAlertIcon, ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled } from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const ZOOM_MIN = 0.1
|
||||||
|
const ZOOM_MAX = 5
|
||||||
|
const ZOOM_IN_FACTOR = 1.2
|
||||||
|
const ZOOM_OUT_FACTOR = 0.8
|
||||||
|
const INITIAL_SCALE = 0.5
|
||||||
|
const MAX_IMAGE_DIMENSION = 4096
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
imageBlob: Blob
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const state = ref({
|
||||||
|
scale: INITIAL_SCALE,
|
||||||
|
translateX: 0,
|
||||||
|
translateY: 0,
|
||||||
|
isPanning: false,
|
||||||
|
startX: 0,
|
||||||
|
startY: 0,
|
||||||
|
isLoading: false,
|
||||||
|
hasError: false,
|
||||||
|
errorMessage: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageRef = ref<HTMLImageElement | null>(null)
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
|
const imageObjectUrl = ref('')
|
||||||
|
const rafId = ref(0)
|
||||||
|
|
||||||
|
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
|
||||||
|
|
||||||
|
const imageStyle = computed(() => ({
|
||||||
|
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
|
||||||
|
transition: state.value.isPanning ? 'none' : 'transform 0.3s ease-out',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const validateImageDimensions = (img: HTMLImageElement): boolean => {
|
||||||
|
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
|
||||||
|
state.value.hasError = true
|
||||||
|
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateImageUrl = (blob: Blob) => {
|
||||||
|
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||||
|
imageObjectUrl.value = URL.createObjectURL(blob)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
|
||||||
|
state.value.isLoading = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.value.isLoading = false
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImageError = () => {
|
||||||
|
state.value.isLoading = false
|
||||||
|
state.value.hasError = true
|
||||||
|
state.value.errorMessage = 'Failed to load image'
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoom = (factor: number) => {
|
||||||
|
const newScale = state.value.scale * factor
|
||||||
|
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
state.value.scale = INITIAL_SCALE
|
||||||
|
state.value.translateX = 0
|
||||||
|
state.value.translateY = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPan = (e: MouseEvent) => {
|
||||||
|
state.value.isPanning = true
|
||||||
|
state.value.startX = e.clientX - state.value.translateX
|
||||||
|
state.value.startY = e.clientY - state.value.translateY
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePan = (e: MouseEvent) => {
|
||||||
|
if (!state.value.isPanning) return
|
||||||
|
cancelAnimationFrame(rafId.value)
|
||||||
|
rafId.value = requestAnimationFrame(() => {
|
||||||
|
state.value.translateX = e.clientX - state.value.startX
|
||||||
|
state.value.translateY = e.clientY - state.value.startY
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopPan = () => {
|
||||||
|
state.value.isPanning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWheel = (e: WheelEvent) => {
|
||||||
|
const delta = e.deltaY * -0.001
|
||||||
|
const factor = 1 + delta
|
||||||
|
zoom(factor)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.imageBlob,
|
||||||
|
(newBlob) => {
|
||||||
|
if (!newBlob) return
|
||||||
|
state.value.isLoading = true
|
||||||
|
state.value.hasError = false
|
||||||
|
updateImageUrl(newBlob)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.imageBlob) updateImageUrl(props.imageBlob)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||||
|
cancelAnimationFrame(rafId.value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
2
packages/ui/src/components/servers/files/editor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as FileEditor } from './FileEditor.vue'
|
||||||
|
export { default as FileImageViewer } from './FileImageViewer.vue'
|
||||||
346
packages/ui/src/components/servers/files/explorer/FileItem.vue
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
<template>
|
||||||
|
<li
|
||||||
|
role="button"
|
||||||
|
:class="[
|
||||||
|
containerClasses,
|
||||||
|
isDragOver && type === 'directory' ? 'bg-brand-highlight' : '',
|
||||||
|
isDragging ? 'opacity-50' : '',
|
||||||
|
]"
|
||||||
|
tabindex="0"
|
||||||
|
draggable="true"
|
||||||
|
@click="selectItem"
|
||||||
|
@contextmenu="openContextMenu"
|
||||||
|
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@dragstart="handleDragStart"
|
||||||
|
@dragend="handleDragEnd"
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<div class="pointer-events-none flex flex-1 items-center gap-3 truncate">
|
||||||
|
<Checkbox
|
||||||
|
class="pointer-events-auto"
|
||||||
|
:model-value="selected"
|
||||||
|
@click.stop
|
||||||
|
@update:model-value="emit('toggle-select')"
|
||||||
|
/>
|
||||||
|
<div class="pointer-events-none flex size-5 items-center justify-center">
|
||||||
|
<component :is="iconComponent" class="size-5" />
|
||||||
|
</div>
|
||||||
|
<div class="pointer-events-none flex flex-col truncate">
|
||||||
|
<span
|
||||||
|
class="pointer-events-none truncate group-hover:text-contrast group-focus:text-contrast"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="pointer-events-auto flex w-fit flex-shrink-0 items-center gap-4 md:gap-12">
|
||||||
|
<span class="hidden w-[100px] text-nowrap text-sm text-secondary md:block">
|
||||||
|
{{ formattedSize }}
|
||||||
|
</span>
|
||||||
|
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||||
|
{{ formattedCreationDate }}
|
||||||
|
</span>
|
||||||
|
<span class="hidden w-[160px] text-nowrap text-sm text-secondary md:block">
|
||||||
|
{{ formattedModifiedDate }}
|
||||||
|
</span>
|
||||||
|
<ButtonStyled circular type="transparent">
|
||||||
|
<TeleportOverflowMenu :options="menuOptions">
|
||||||
|
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||||
|
<template #extract><PackageOpenIcon /> Extract</template>
|
||||||
|
<template #rename><EditIcon /> Rename</template>
|
||||||
|
<template #move><RightArrowIcon /> Move</template>
|
||||||
|
<template #download><DownloadIcon /> Download</template>
|
||||||
|
<template #delete><TrashIcon /> Delete</template>
|
||||||
|
</TeleportOverflowMenu>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
DownloadIcon,
|
||||||
|
EditIcon,
|
||||||
|
FolderCogIcon,
|
||||||
|
FolderOpenIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
MoreHorizontalIcon,
|
||||||
|
PackageOpenIcon,
|
||||||
|
PaletteIcon,
|
||||||
|
RightArrowIcon,
|
||||||
|
TrashIcon,
|
||||||
|
} from '@modrinth/assets'
|
||||||
|
import {
|
||||||
|
ButtonStyled,
|
||||||
|
Checkbox,
|
||||||
|
getFileExtension,
|
||||||
|
getFileExtensionIcon,
|
||||||
|
isEditableFile as isEditableFileExt,
|
||||||
|
isImageFile,
|
||||||
|
} from '@modrinth/ui'
|
||||||
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import TeleportOverflowMenu from './TeleportOverflowMenu.vue'
|
||||||
|
|
||||||
|
interface FileItemProps {
|
||||||
|
name: string
|
||||||
|
type: 'directory' | 'file'
|
||||||
|
size?: number
|
||||||
|
count?: number
|
||||||
|
modified: number
|
||||||
|
created: number
|
||||||
|
path: string
|
||||||
|
index: number
|
||||||
|
isLast: boolean
|
||||||
|
selected: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<FileItemProps>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
rename: [item: { name: string; type: string; path: string }]
|
||||||
|
move: [item: { name: string; type: string; path: string }]
|
||||||
|
download: [item: { name: string; type: string; path: string }]
|
||||||
|
delete: [item: { name: string; type: string; path: string }]
|
||||||
|
edit: [item: { name: string; type: string; path: string }]
|
||||||
|
extract: [item: { name: string; type: string; path: string }]
|
||||||
|
hover: [item: { name: string; type: string; path: string }]
|
||||||
|
moveDirectTo: [item: { name: string; type: string; path: string; destination: string }]
|
||||||
|
contextmenu: [x: number, y: number]
|
||||||
|
'toggle-select': []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isDragOver = ref(false)
|
||||||
|
const isDragging = ref(false)
|
||||||
|
|
||||||
|
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
|
||||||
|
|
||||||
|
const route = shallowRef(useRoute())
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const containerClasses = computed(() => [
|
||||||
|
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-3 px-4 py-3 focus:!outline-none',
|
||||||
|
props.selected ? 'bg-surface-3' : props.index % 2 === 0 ? 'bg-surface-2' : 'file-row-alt',
|
||||||
|
props.isLast ? 'rounded-b-[20px] border-b' : '',
|
||||||
|
isEditableFile.value ? 'cursor-pointer' : props.type === 'directory' ? 'cursor-pointer' : '',
|
||||||
|
isDragOver.value ? '!bg-brand-highlight' : '',
|
||||||
|
'transition-colors duration-100 hover:!bg-surface-4 hover:!brightness-100 focus:!bg-surface-4 focus:!brightness-100',
|
||||||
|
])
|
||||||
|
|
||||||
|
const fileExtension = computed(() => getFileExtension(props.name))
|
||||||
|
|
||||||
|
const isZip = computed(() => fileExtension.value === 'zip')
|
||||||
|
|
||||||
|
const menuOptions = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'extract',
|
||||||
|
shown: isZip.value,
|
||||||
|
action: () => emit('extract', { name: props.name, type: props.type, path: props.path }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
divider: true,
|
||||||
|
shown: isZip.value,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rename',
|
||||||
|
action: () => emit('rename', { name: props.name, type: props.type, path: props.path }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'move',
|
||||||
|
action: () => emit('move', { name: props.name, type: props.type, path: props.path }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'download',
|
||||||
|
action: () => emit('download', { name: props.name, type: props.type, path: props.path }),
|
||||||
|
shown: props.type !== 'directory',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delete',
|
||||||
|
action: () => emit('delete', { name: props.name, type: props.type, path: props.path }),
|
||||||
|
color: 'red' as const,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const iconComponent = computed(() => {
|
||||||
|
if (props.type === 'directory') {
|
||||||
|
if (props.name === 'config') return FolderCogIcon
|
||||||
|
if (props.name === 'world') return GlobeIcon
|
||||||
|
if (props.name === 'resourcepacks') return PaletteIcon
|
||||||
|
return FolderOpenIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
return getFileExtensionIcon(fileExtension.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedModifiedDate = computed(() => {
|
||||||
|
const date = new Date(props.modified * 1000)
|
||||||
|
return `${date.toLocaleDateString('en-US', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
})}, ${date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
hour12: true,
|
||||||
|
})}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedCreationDate = computed(() => {
|
||||||
|
const date = new Date(props.created * 1000)
|
||||||
|
return `${date.toLocaleDateString('en-US', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
year: '2-digit',
|
||||||
|
})}, ${date.toLocaleTimeString('en-US', {
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: 'numeric',
|
||||||
|
hour12: true,
|
||||||
|
})}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const isEditableFile = computed(() => {
|
||||||
|
if (props.type === 'file') {
|
||||||
|
const ext = fileExtension.value
|
||||||
|
return !props.name.includes('.') || isEditableFileExt(ext) || isImageFile(ext)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
|
||||||
|
const formattedSize = computed(() => {
|
||||||
|
if (props.type === 'directory') {
|
||||||
|
return `${props.count} ${props.count === 1 ? 'item' : 'items'}`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.size === undefined) return ''
|
||||||
|
const bytes = props.size
|
||||||
|
if (bytes === 0) return '0 B'
|
||||||
|
|
||||||
|
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1)
|
||||||
|
const size = (bytes / Math.pow(1024, exponent)).toFixed(2)
|
||||||
|
return `${size} ${units[exponent]}`
|
||||||
|
})
|
||||||
|
|
||||||
|
function openContextMenu(event: MouseEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
emit('contextmenu', event.clientX, event.clientY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseEnter() {
|
||||||
|
emit('hover', { name: props.name, type: props.type, path: props.path })
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToFolder() {
|
||||||
|
const currentPath = route.value.query.path?.toString() || ''
|
||||||
|
const newPath = currentPath.endsWith('/')
|
||||||
|
? `${currentPath}${props.name}`
|
||||||
|
: `${currentPath}/${props.name}`
|
||||||
|
router.push({ query: { path: newPath, page: 1 } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const isNavigating = ref(false)
|
||||||
|
|
||||||
|
function selectItem() {
|
||||||
|
if (isNavigating.value) return
|
||||||
|
isNavigating.value = true
|
||||||
|
|
||||||
|
if (props.type === 'directory') {
|
||||||
|
navigateToFolder()
|
||||||
|
} else if (props.type === 'file' && isEditableFile.value) {
|
||||||
|
emit('edit', { name: props.name, type: props.type, path: props.path })
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
isNavigating.value = false
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragStart(event: DragEvent) {
|
||||||
|
if (!event.dataTransfer) return
|
||||||
|
isDragging.value = true
|
||||||
|
|
||||||
|
const dragGhost = document.createElement('div')
|
||||||
|
dragGhost.className =
|
||||||
|
'fixed left-0 top-0 flex items-center max-w-[500px] flex-row gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none'
|
||||||
|
|
||||||
|
const nameSpan = document.createElement('span')
|
||||||
|
nameSpan.className = 'font-bold truncate text-contrast'
|
||||||
|
nameSpan.textContent = props.name
|
||||||
|
|
||||||
|
dragGhost.appendChild(nameSpan)
|
||||||
|
document.body.appendChild(dragGhost)
|
||||||
|
|
||||||
|
event.dataTransfer.setDragImage(dragGhost, 0, 0)
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
document.body.removeChild(dragGhost)
|
||||||
|
})
|
||||||
|
|
||||||
|
event.dataTransfer.setData(
|
||||||
|
'application/modrinth-file-move',
|
||||||
|
JSON.stringify({
|
||||||
|
name: props.name,
|
||||||
|
type: props.type,
|
||||||
|
path: props.path,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
event.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChildPath(parentPath: string, childPath: string) {
|
||||||
|
return childPath.startsWith(parentPath + '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnd() {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragEnter() {
|
||||||
|
if (props.type !== 'directory') return
|
||||||
|
isDragOver.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||||
|
event.dataTransfer.dropEffect = 'move'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
isDragOver.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent) {
|
||||||
|
isDragOver.value = false
|
||||||
|
if (props.type !== 'directory' || !event.dataTransfer) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dragData = JSON.parse(event.dataTransfer.getData('application/modrinth-file-move'))
|
||||||
|
|
||||||
|
if (dragData.path === props.path) return
|
||||||
|
|
||||||
|
if (dragData.type === 'directory' && isChildPath(dragData.path, props.path)) {
|
||||||
|
console.error('Cannot move a folder into its own subfolder')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('moveDirectTo', {
|
||||||
|
name: dragData.name,
|
||||||
|
type: dragData.type,
|
||||||
|
path: dragData.path,
|
||||||
|
destination: props.path,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling file drop:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.file-row-alt {
|
||||||
|
background: color-mix(in srgb, var(--surface-2), black 10%);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
class="sticky top-0 z-20 flex w-full select-none flex-row items-center justify-between border border-b-0 border-solid border-surface-3 bg-surface-3 p-4 text-sm font-medium transition-[border-radius] duration-100 before:pointer-events-none before:absolute before:inset-x-0 before:-top-5 before:h-5 before:bg-surface-3"
|
||||||
|
:class="isStuck ? 'rounded-none' : 'rounded-t-[20px]'"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-center gap-3">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="allSelected"
|
||||||
|
:indeterminate="someSelected && !allSelected"
|
||||||
|
@update:model-value="$emit('toggle-all')"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="flex appearance-none items-center gap-1.5 bg-transparent text-contrast hover:text-brand"
|
||||||
|
@click="$emit('sort', 'name')"
|
||||||
|
>
|
||||||
|
<span>Name</span>
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-if="sortField === 'name' && !sortDesc"
|
||||||
|
class="h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="sortField === 'name' && sortDesc"
|
||||||
|
class="h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 items-center gap-4 md:gap-12">
|
||||||
|
<button
|
||||||
|
class="hidden w-[100px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||||
|
@click="$emit('sort', 'size')"
|
||||||
|
>
|
||||||
|
<span class="ml-2">Size</span>
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-if="sortField === 'size' && !sortDesc"
|
||||||
|
class="h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="sortField === 'size' && sortDesc"
|
||||||
|
class="h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||||
|
@click="$emit('sort', 'created')"
|
||||||
|
>
|
||||||
|
<span class="ml-2">Created</span>
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-if="sortField === 'created' && !sortDesc"
|
||||||
|
class="h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="sortField === 'created' && sortDesc"
|
||||||
|
class="h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
|
||||||
|
@click="$emit('sort', 'modified')"
|
||||||
|
>
|
||||||
|
<span class="ml-2">Modified</span>
|
||||||
|
<ChevronUpIcon
|
||||||
|
v-if="sortField === 'modified' && !sortDesc"
|
||||||
|
class="h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<ChevronDownIcon
|
||||||
|
v-if="sortField === 'modified' && sortDesc"
|
||||||
|
class="h-4 w-4"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<span class="w-[51px] text-right text-primary">Actions</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||||
|
import { Checkbox } from '@modrinth/ui'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
sortField: string
|
||||||
|
sortDesc: boolean
|
||||||
|
allSelected: boolean
|
||||||
|
someSelected: boolean
|
||||||
|
isStuck: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
sort: [field: string]
|
||||||
|
'toggle-all': []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
|
||||||
|
<FileIcon class="size-28" />
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
|
||||||
|
<p class="m-0 text-sm text-secondary">
|
||||||
|
{{ message }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<ButtonStyled>
|
||||||
|
<button size="sm" @click="$emit('refetch')">
|
||||||
|
<RefreshCwIcon class="h-5 w-5" />
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button size="sm" @click="$emit('home')">
|
||||||
|
<HomeIcon class="h-5 w-5" />
|
||||||
|
Go to home folder
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FileIcon, HomeIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled } from '@modrinth/ui'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
refetch: []
|
||||||
|
home: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="listContainer" class="relative w-full">
|
||||||
|
<div
|
||||||
|
:style="{
|
||||||
|
position: 'relative',
|
||||||
|
minHeight: `${totalHeight}px`,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="list-none"
|
||||||
|
:style="{
|
||||||
|
position: 'absolute',
|
||||||
|
top: `${visibleTop}px`,
|
||||||
|
width: '100%',
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<FileItem
|
||||||
|
v-for="(item, idx) in visibleItems"
|
||||||
|
:key="item.path"
|
||||||
|
:count="item.count"
|
||||||
|
:created="item.created"
|
||||||
|
:modified="item.modified"
|
||||||
|
:name="item.name"
|
||||||
|
:path="item.path"
|
||||||
|
:type="item.type"
|
||||||
|
:size="item.size"
|
||||||
|
:index="visibleRange.start + idx"
|
||||||
|
:is-last="visibleRange.start + idx === props.items.length - 1"
|
||||||
|
:selected="selectedItems.has(item.path)"
|
||||||
|
@delete="$emit('delete', item)"
|
||||||
|
@rename="$emit('rename', item)"
|
||||||
|
@extract="$emit('extract', item)"
|
||||||
|
@download="$emit('download', item)"
|
||||||
|
@move="$emit('move', item)"
|
||||||
|
@move-direct-to="$emit('moveDirectTo', $event)"
|
||||||
|
@edit="$emit('edit', item)"
|
||||||
|
@hover="$emit('hover', item)"
|
||||||
|
@contextmenu="(x, y) => $emit('contextmenu', item, x, y)"
|
||||||
|
@toggle-select="$emit('toggle-select', item.path)"
|
||||||
|
/>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Kyros } from '@modrinth/api-client'
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import FileItem from './FileItem.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
items: Kyros.Files.v0.DirectoryItem[]
|
||||||
|
selectedItems: Set<string>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: [item: Kyros.Files.v0.DirectoryItem]
|
||||||
|
rename: [item: Kyros.Files.v0.DirectoryItem]
|
||||||
|
download: [item: Kyros.Files.v0.DirectoryItem]
|
||||||
|
move: [item: Kyros.Files.v0.DirectoryItem]
|
||||||
|
edit: [item: Kyros.Files.v0.DirectoryItem]
|
||||||
|
moveDirectTo: [item: { name: string; type: string; path: string; destination: string }]
|
||||||
|
extract: [item: Kyros.Files.v0.DirectoryItem]
|
||||||
|
hover: [item: Kyros.Files.v0.DirectoryItem]
|
||||||
|
contextmenu: [item: Kyros.Files.v0.DirectoryItem, x: number, y: number]
|
||||||
|
loadMore: []
|
||||||
|
'toggle-select': [path: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const ITEM_HEIGHT = 61
|
||||||
|
const BUFFER_SIZE = 5
|
||||||
|
|
||||||
|
const listContainer = ref<HTMLElement | null>(null)
|
||||||
|
const windowScrollY = ref(0)
|
||||||
|
const windowHeight = ref(0)
|
||||||
|
|
||||||
|
const totalHeight = computed(() => props.items.length * ITEM_HEIGHT)
|
||||||
|
|
||||||
|
const visibleRange = computed(() => {
|
||||||
|
if (!listContainer.value) return { start: 0, end: 0 }
|
||||||
|
|
||||||
|
const containerTop = listContainer.value.getBoundingClientRect().top + window.scrollY
|
||||||
|
const relativeScrollTop = Math.max(0, windowScrollY.value - containerTop)
|
||||||
|
|
||||||
|
const start = Math.floor(relativeScrollTop / ITEM_HEIGHT)
|
||||||
|
const visibleCount = Math.ceil(windowHeight.value / ITEM_HEIGHT)
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: Math.max(0, start - BUFFER_SIZE),
|
||||||
|
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleTop = computed(() => {
|
||||||
|
return visibleRange.value.start * ITEM_HEIGHT
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleItems = computed(() => {
|
||||||
|
return props.items.slice(visibleRange.value.start, visibleRange.value.end)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleScroll() {
|
||||||
|
windowScrollY.value = window.scrollY
|
||||||
|
|
||||||
|
if (!listContainer.value) return
|
||||||
|
|
||||||
|
const containerBottom = listContainer.value.getBoundingClientRect().bottom
|
||||||
|
const remainingScroll = containerBottom - window.innerHeight
|
||||||
|
|
||||||
|
if (remainingScroll < windowHeight.value * 0.2) {
|
||||||
|
emit('loadMore')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
windowHeight.value = window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
windowHeight.value = window.innerHeight
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
|
window.addEventListener('resize', handleResize, { passive: true })
|
||||||
|
handleScroll()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
<template>
|
||||||
|
<div data-pyro-telepopover-wrapper class="relative">
|
||||||
|
<button
|
||||||
|
ref="triggerRef"
|
||||||
|
class="teleport-overflow-menu-trigger"
|
||||||
|
:aria-expanded="isOpen"
|
||||||
|
:aria-haspopup="true"
|
||||||
|
@mousedown="handleMouseDown"
|
||||||
|
@mouseenter="handleMouseEnter"
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
@click="toggleMenu"
|
||||||
|
>
|
||||||
|
<slot></slot>
|
||||||
|
</button>
|
||||||
|
<Teleport to="#teleports">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition duration-125 ease-out"
|
||||||
|
enter-from-class="transform scale-75 opacity-0"
|
||||||
|
enter-to-class="transform scale-100 opacity-100"
|
||||||
|
leave-active-class="transition duration-125 ease-in"
|
||||||
|
leave-from-class="transform scale-100 opacity-100"
|
||||||
|
leave-to-class="transform scale-75 opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="isOpen"
|
||||||
|
ref="menuRef"
|
||||||
|
data-pyro-telepopover-root
|
||||||
|
class="experimental-styles-within fixed isolate z-[9999] flex w-fit flex-col gap-2 overflow-hidden rounded-2xl border-[1px] border-solid border-divider bg-bg-raised p-2 shadow-lg"
|
||||||
|
:style="menuStyle"
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
@mousedown.stop
|
||||||
|
@mouseleave="handleMouseLeave"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
v-for="(option, index) in filteredOptions"
|
||||||
|
:key="isDivider(option) ? `divider-${index}` : option.id"
|
||||||
|
>
|
||||||
|
<div v-if="isDivider(option)" class="h-px w-full bg-surface-5"></div>
|
||||||
|
<ButtonStyled v-else type="transparent" role="menuitem" :color="option.color">
|
||||||
|
<button
|
||||||
|
v-if="typeof option.action === 'function'"
|
||||||
|
:ref="
|
||||||
|
(el) => {
|
||||||
|
if (el) menuItemsRef[index] = el as HTMLElement
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||||
|
:aria-selected="index === selectedIndex"
|
||||||
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||||
|
@click="handleItemClick(option, index)"
|
||||||
|
@focus="selectedIndex = index"
|
||||||
|
@mouseover="handleMouseOver(index)"
|
||||||
|
>
|
||||||
|
<slot :name="option.id">{{ option.id }}</slot>
|
||||||
|
</button>
|
||||||
|
<AutoLink
|
||||||
|
v-else-if="typeof option.action === 'string'"
|
||||||
|
:ref="
|
||||||
|
(el) => {
|
||||||
|
if (el) menuItemsRef[index] = el as HTMLElement
|
||||||
|
}
|
||||||
|
"
|
||||||
|
:to="option.action"
|
||||||
|
class="w-full !justify-start !whitespace-nowrap focus-visible:!outline-none"
|
||||||
|
:aria-selected="index === selectedIndex"
|
||||||
|
:style="index === selectedIndex ? { background: 'var(--color-button-bg)' } : {}"
|
||||||
|
@click="handleItemClick(option, index)"
|
||||||
|
@focus="selectedIndex = index"
|
||||||
|
@mouseover="handleMouseOver(index)"
|
||||||
|
>
|
||||||
|
<slot :name="option.id">{{ option.id }}</slot>
|
||||||
|
</AutoLink>
|
||||||
|
<span v-else>
|
||||||
|
<slot :name="option.id">{{ option.id }}</slot>
|
||||||
|
</span>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { AutoLink, ButtonStyled } from '@modrinth/ui'
|
||||||
|
import { onClickOutside, useElementHover } from '@vueuse/core'
|
||||||
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
id: string
|
||||||
|
action?: (() => void) | string
|
||||||
|
shown?: boolean
|
||||||
|
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||||
|
}
|
||||||
|
|
||||||
|
type Divider = {
|
||||||
|
divider: true
|
||||||
|
shown?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Item = Option | Divider
|
||||||
|
|
||||||
|
function isDivider(item: Item): item is Divider {
|
||||||
|
return (item as Divider).divider
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
options: Item[]
|
||||||
|
hoverable?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
hoverable: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [option: Option]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const selectedIndex = ref(-1)
|
||||||
|
const menuRef = ref<HTMLElement | null>(null)
|
||||||
|
const triggerRef = ref<HTMLElement | null>(null)
|
||||||
|
const isMouseDown = ref(false)
|
||||||
|
const typeAheadBuffer = ref('')
|
||||||
|
const typeAheadTimeout = ref<number | null>(null)
|
||||||
|
const menuItemsRef = ref<HTMLElement[]>([])
|
||||||
|
|
||||||
|
const hoveringTrigger = useElementHover(triggerRef)
|
||||||
|
const hoveringMenu = useElementHover(menuRef)
|
||||||
|
|
||||||
|
const hovering = computed(() => hoveringTrigger.value || hoveringMenu.value)
|
||||||
|
|
||||||
|
const menuStyle = ref({
|
||||||
|
top: '0px',
|
||||||
|
left: '0px',
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => props.options.filter((option) => option.shown !== false))
|
||||||
|
|
||||||
|
const calculateMenuPosition = () => {
|
||||||
|
if (!triggerRef.value || !menuRef.value) return { top: '0px', left: '0px' }
|
||||||
|
|
||||||
|
const triggerRect = triggerRef.value.getBoundingClientRect()
|
||||||
|
const menuRect = menuRef.value.getBoundingClientRect()
|
||||||
|
const menuWidth = menuRect.width
|
||||||
|
const menuHeight = menuRect.height
|
||||||
|
const margin = 8
|
||||||
|
|
||||||
|
let top: number
|
||||||
|
let left: number
|
||||||
|
|
||||||
|
if (triggerRect.bottom + menuHeight + margin <= window.innerHeight) {
|
||||||
|
top = triggerRect.bottom + margin
|
||||||
|
} else if (triggerRect.top - menuHeight - margin >= 0) {
|
||||||
|
top = triggerRect.top - menuHeight - margin
|
||||||
|
} else {
|
||||||
|
top = Math.max(margin, window.innerHeight - menuHeight - margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (triggerRect.left + menuWidth + margin <= window.innerWidth) {
|
||||||
|
left = triggerRect.left
|
||||||
|
} else if (triggerRect.right - menuWidth - margin >= 0) {
|
||||||
|
left = triggerRect.right - menuWidth
|
||||||
|
} else {
|
||||||
|
left = Math.max(margin, window.innerWidth - menuWidth - margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: `${top}px`,
|
||||||
|
left: `${left}px`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMenu = (event: MouseEvent) => {
|
||||||
|
event.stopPropagation()
|
||||||
|
if (!props.hoverable) {
|
||||||
|
if (isOpen.value) {
|
||||||
|
closeMenu()
|
||||||
|
} else {
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openMenu = () => {
|
||||||
|
isOpen.value = true
|
||||||
|
disableBodyScroll()
|
||||||
|
nextTick(() => {
|
||||||
|
menuStyle.value = calculateMenuPosition()
|
||||||
|
document.addEventListener('mousemove', handleMouseMove)
|
||||||
|
focusFirstMenuItem()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMenu = () => {
|
||||||
|
isOpen.value = false
|
||||||
|
selectedIndex.value = -1
|
||||||
|
enableBodyScroll()
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectOption = (option: Option) => {
|
||||||
|
emit('select', option)
|
||||||
|
if (typeof option.action === 'function') {
|
||||||
|
option.action()
|
||||||
|
}
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseDown = (event: MouseEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
isMouseDown.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (props.hoverable) {
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
if (props.hoverable) {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!hovering.value) {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (!isOpen.value || !isMouseDown.value) return
|
||||||
|
|
||||||
|
const menuRect = menuRef.value?.getBoundingClientRect()
|
||||||
|
if (!menuRect) return
|
||||||
|
|
||||||
|
const menuItems = menuRef.value?.querySelectorAll('[role="menuitem"]')
|
||||||
|
if (!menuItems) return
|
||||||
|
|
||||||
|
for (let i = 0; i < menuItems.length; i++) {
|
||||||
|
const itemRect = (menuItems[i] as HTMLElement).getBoundingClientRect()
|
||||||
|
if (
|
||||||
|
event.clientX >= itemRect.left &&
|
||||||
|
event.clientX <= itemRect.right &&
|
||||||
|
event.clientY >= itemRect.top &&
|
||||||
|
event.clientY <= itemRect.bottom
|
||||||
|
) {
|
||||||
|
selectedIndex.value = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleItemClick = (option: Option, index: number) => {
|
||||||
|
selectedIndex.value = index
|
||||||
|
selectOption(option)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseOver = (index: number) => {
|
||||||
|
selectedIndex.value = index
|
||||||
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
const disableBodyScroll = () => {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
const enableBodyScroll = () => {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const focusFirstMenuItem = () => {
|
||||||
|
if (menuItemsRef.value.length > 0) {
|
||||||
|
menuItemsRef.value[0]?.focus?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (!isOpen.value) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
openMenu()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowDown':
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||||
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||||
|
break
|
||||||
|
case 'ArrowUp':
|
||||||
|
event.preventDefault()
|
||||||
|
selectedIndex.value =
|
||||||
|
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||||
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||||
|
break
|
||||||
|
case 'Home':
|
||||||
|
event.preventDefault()
|
||||||
|
if (menuItemsRef.value.length > 0) {
|
||||||
|
selectedIndex.value = 0
|
||||||
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'End':
|
||||||
|
event.preventDefault()
|
||||||
|
if (menuItemsRef.value.length > 0) {
|
||||||
|
selectedIndex.value = filteredOptions.value.length - 1
|
||||||
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Enter':
|
||||||
|
case ' ':
|
||||||
|
event.preventDefault()
|
||||||
|
if (selectedIndex.value >= 0) {
|
||||||
|
const option = filteredOptions.value[selectedIndex.value]
|
||||||
|
if (isDivider(option)) break
|
||||||
|
selectOption(option)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'Escape':
|
||||||
|
event.preventDefault()
|
||||||
|
closeMenu()
|
||||||
|
triggerRef.value?.focus?.()
|
||||||
|
break
|
||||||
|
case 'Tab':
|
||||||
|
event.preventDefault()
|
||||||
|
if (menuItemsRef.value.length > 0) {
|
||||||
|
if (event.shiftKey) {
|
||||||
|
selectedIndex.value =
|
||||||
|
(selectedIndex.value - 1 + filteredOptions.value.length) % filteredOptions.value.length
|
||||||
|
} else {
|
||||||
|
selectedIndex.value = (selectedIndex.value + 1) % filteredOptions.value.length
|
||||||
|
}
|
||||||
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
if (event.key.length === 1) {
|
||||||
|
typeAheadBuffer.value += event.key.toLowerCase()
|
||||||
|
const matchIndex = filteredOptions.value.findIndex(
|
||||||
|
(option) =>
|
||||||
|
!isDivider(option) && option.id.toLowerCase().startsWith(typeAheadBuffer.value),
|
||||||
|
)
|
||||||
|
if (matchIndex !== -1) {
|
||||||
|
selectedIndex.value = matchIndex
|
||||||
|
menuItemsRef.value[selectedIndex.value]?.focus?.()
|
||||||
|
}
|
||||||
|
if (typeAheadTimeout.value) {
|
||||||
|
clearTimeout(typeAheadTimeout.value)
|
||||||
|
}
|
||||||
|
typeAheadTimeout.value = setTimeout(() => {
|
||||||
|
typeAheadBuffer.value = ''
|
||||||
|
}, 1000) as unknown as number
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResizeOrScroll = () => {
|
||||||
|
if (isOpen.value) {
|
||||||
|
menuStyle.value = calculateMenuPosition()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const throttle = <T extends unknown[]>(
|
||||||
|
func: (...args: T) => void,
|
||||||
|
limit: number,
|
||||||
|
): ((...args: T) => void) => {
|
||||||
|
let inThrottle: boolean
|
||||||
|
return function (...args: T) {
|
||||||
|
if (!inThrottle) {
|
||||||
|
func(...args)
|
||||||
|
inThrottle = true
|
||||||
|
setTimeout(() => (inThrottle = false), limit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const throttledHandleResizeOrScroll = throttle(handleResizeOrScroll, 100)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
triggerRef.value?.addEventListener('keydown', handleKeydown)
|
||||||
|
window.addEventListener('resize', throttledHandleResizeOrScroll)
|
||||||
|
window.addEventListener('scroll', throttledHandleResizeOrScroll)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
triggerRef.value?.removeEventListener('keydown', handleKeydown)
|
||||||
|
window.removeEventListener('resize', throttledHandleResizeOrScroll)
|
||||||
|
window.removeEventListener('scroll', throttledHandleResizeOrScroll)
|
||||||
|
document.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
if (typeAheadTimeout.value) {
|
||||||
|
clearTimeout(typeAheadTimeout.value)
|
||||||
|
}
|
||||||
|
enableBodyScroll()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(isOpen, (newValue) => {
|
||||||
|
if (newValue) {
|
||||||
|
nextTick(() => {
|
||||||
|
menuRef.value?.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
menuRef.value?.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onClickOutside(menuRef, (event) => {
|
||||||
|
if (!triggerRef.value?.contains(event.target as Node)) {
|
||||||
|
closeMenu()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as FileItem } from './FileItem.vue'
|
||||||
|
export { default as FileLabelBar } from './FileLabelBar.vue'
|
||||||
|
export { default as FileManagerError } from './FileManagerError.vue'
|
||||||
|
export { default as FileVirtualList } from './FileVirtualList.vue'
|
||||||
|
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'
|
||||||
5
packages/ui/src/components/servers/files/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from './editor'
|
||||||
|
export * from './explorer'
|
||||||
|
export { default as FileNavbar } from './FileNavbar.vue'
|
||||||
|
export * from './modals'
|
||||||
|
export * from './upload'
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="modal" :header="`Creating a ${displayType}`">
|
||||||
|
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="font-semibold text-contrast">Name</div>
|
||||||
|
<input
|
||||||
|
ref="createInput"
|
||||||
|
v-model="itemName"
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
class="bg-bg-input w-full rounded-lg p-4"
|
||||||
|
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-start gap-4">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button :disabled="!!error" type="submit">
|
||||||
|
<PlusIcon class="h-5 w-5" />
|
||||||
|
Create {{ displayType }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button type="button" @click="hide">
|
||||||
|
<XIcon class="h-5 w-5" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
type: 'file' | 'directory'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
create: [name: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const displayType = computed(() => (props.type === 'directory' ? 'folder' : props.type))
|
||||||
|
const createInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const itemName = ref('')
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
const error = computed(() => {
|
||||||
|
if (!itemName.value) {
|
||||||
|
return 'Name is required.'
|
||||||
|
}
|
||||||
|
if (props.type === 'file') {
|
||||||
|
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||||
|
if (!validPattern.test(itemName.value)) {
|
||||||
|
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||||
|
if (!validPattern.test(itemName.value)) {
|
||||||
|
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
submitted.value = true
|
||||||
|
if (!error.value) {
|
||||||
|
emit('create', itemName.value)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
itemName.value = ''
|
||||||
|
submitted.value = false
|
||||||
|
modal.value?.show()
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
createInput.value?.focus()
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="modal" fade="danger" :header="`Deleting ${item?.type}`">
|
||||||
|
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||||
|
<div
|
||||||
|
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-brand-red bg-bg-red p-6 shadow-md"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="flex h-9 w-9 items-center justify-center rounded-full bg-highlight-red p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
|
||||||
|
>
|
||||||
|
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
|
||||||
|
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col">
|
||||||
|
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
|
||||||
|
<span
|
||||||
|
v-if="item?.type === 'directory'"
|
||||||
|
class="text-xs text-secondary group-hover:text-primary"
|
||||||
|
>
|
||||||
|
{{ item?.count }} items
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-secondary group-hover:text-primary">
|
||||||
|
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-start gap-4">
|
||||||
|
<ButtonStyled color="red">
|
||||||
|
<button type="submit">
|
||||||
|
<TrashIcon class="h-5 w-5" />
|
||||||
|
Delete {{ item?.type }}
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button type="button" @click="hide">
|
||||||
|
<XIcon class="h-5 w-5" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
item: {
|
||||||
|
name: string
|
||||||
|
type: string
|
||||||
|
count?: number
|
||||||
|
size?: number
|
||||||
|
} | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit('delete')
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="modal" :header="`Moving ${item?.name}`">
|
||||||
|
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
ref="destinationInput"
|
||||||
|
v-model="destination"
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
class="bg-bg-input w-full rounded-lg p-4"
|
||||||
|
placeholder="e.g. /mods/modname"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-nowrap">
|
||||||
|
New location:
|
||||||
|
<div class="w-full rounded-lg bg-table-alternateRow p-2 font-bold text-contrast">
|
||||||
|
<span class="text-secondary">/root</span>{{ newpath }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-start gap-4">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button type="submit">
|
||||||
|
<ArrowBigUpDashIcon class="h-5 w-5" />
|
||||||
|
Move
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button type="button" @click="hide">
|
||||||
|
<XIcon class="h-5 w-5" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowBigUpDashIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
const destinationInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: { name: string } | null
|
||||||
|
currentPath: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
move: [destination: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const destination = ref('')
|
||||||
|
const newpath = computed(() => {
|
||||||
|
const path = destination.value.replace('//', '/')
|
||||||
|
return path.startsWith('/') ? path : `/${path}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
emit('move', newpath.value)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
destination.value = props.currentPath
|
||||||
|
modal.value?.show()
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
destinationInput.value?.focus()
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<NewModal ref="modal" :header="`Renaming ${item?.type}`">
|
||||||
|
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="font-semibold text-contrast">Name</div>
|
||||||
|
<input
|
||||||
|
ref="renameInput"
|
||||||
|
v-model="itemName"
|
||||||
|
autofocus
|
||||||
|
type="text"
|
||||||
|
class="bg-bg-input w-full rounded-lg p-4"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<div v-if="submitted && error" class="text-red">{{ error }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-start gap-4">
|
||||||
|
<ButtonStyled color="brand">
|
||||||
|
<button :disabled="!!error" type="submit">
|
||||||
|
<EditIcon class="h-5 w-5" />
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
<ButtonStyled>
|
||||||
|
<button type="button" @click="hide">
|
||||||
|
<XIcon class="h-5 w-5" />
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</NewModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { EditIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled, NewModal } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: { name: string; type: string } | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
rename: [newName: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof NewModal>>()
|
||||||
|
const renameInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const itemName = ref('')
|
||||||
|
const submitted = ref(false)
|
||||||
|
|
||||||
|
const error = computed(() => {
|
||||||
|
if (!itemName.value) {
|
||||||
|
return 'Name is required.'
|
||||||
|
}
|
||||||
|
if (props.item?.type === 'file') {
|
||||||
|
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||||
|
if (!validPattern.test(itemName.value)) {
|
||||||
|
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||||
|
if (!validPattern.test(itemName.value)) {
|
||||||
|
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
submitted.value = true
|
||||||
|
if (!error.value) {
|
||||||
|
emit('rename', itemName.value)
|
||||||
|
hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const show = (item: { name: string; type: string }) => {
|
||||||
|
itemName.value = item.name
|
||||||
|
submitted.value = false
|
||||||
|
modal.value?.show()
|
||||||
|
nextTick(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
renameInput.value?.focus()
|
||||||
|
}, 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hide = () => {
|
||||||
|
modal.value?.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show, hide })
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<template>
|
||||||
|
<ConfirmModal
|
||||||
|
ref="modal"
|
||||||
|
title="Do you want to overwrite these conflicting files?"
|
||||||
|
:proceed-label="`Overwrite`"
|
||||||
|
:proceed-icon="CheckIcon"
|
||||||
|
@proceed="proceed"
|
||||||
|
>
|
||||||
|
<div class="flex max-w-[30rem] flex-col gap-4">
|
||||||
|
<p class="m-0 font-semibold leading-normal">
|
||||||
|
<template v-if="hasMany">
|
||||||
|
Over 100 files will be overwritten if you proceed with extraction; here is just some of
|
||||||
|
them:
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
The following {{ files.length }} files already exist on your server, and will be
|
||||||
|
overwritten if you proceed with extraction:
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
|
||||||
|
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
|
||||||
|
<XIcon class="shrink-0 text-red" /> {{ file }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</ConfirmModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckIcon, XIcon } from '@modrinth/assets'
|
||||||
|
import { ConfirmModal } from '@modrinth/ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const path = ref('')
|
||||||
|
const files = ref<string[]>([])
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
proceed: [path: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const modal = ref<InstanceType<typeof ConfirmModal>>()
|
||||||
|
|
||||||
|
const hasMany = computed(() => files.value.length > 100)
|
||||||
|
|
||||||
|
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||||
|
path.value = zipPath
|
||||||
|
files.value = conflictingFiles
|
||||||
|
modal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
const proceed = () => {
|
||||||
|
emit('proceed', path.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ show })
|
||||||
|
</script>
|
||||||
5
packages/ui/src/components/servers/files/modals/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export { default as FileCreateItemModal } from './FileCreateItemModal.vue'
|
||||||
|
export { default as FileDeleteItemModal } from './FileDeleteItemModal.vue'
|
||||||
|
export { default as FileMoveItemModal } from './FileMoveItemModal.vue'
|
||||||
|
export { default as FileRenameItemModal } from './FileRenameItemModal.vue'
|
||||||
|
export { default as FileUploadConflictModal } from './FileUploadConflictModal.vue'
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
@dragenter.prevent="handleDragEnter"
|
||||||
|
@dragover.prevent="handleDragOver"
|
||||||
|
@dragleave.prevent="handleDragLeave"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<div
|
||||||
|
v-if="isDragging"
|
||||||
|
:class="[
|
||||||
|
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black/60 text-contrast shadow',
|
||||||
|
overlayClass,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<UploadIcon class="mx-auto h-16 w-16 shadow-2xl" />
|
||||||
|
<p class="mt-2 text-xl">
|
||||||
|
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { UploadIcon } from '@modrinth/assets'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
filesDropped: [files: File[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
overlayClass?: string
|
||||||
|
type?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isDragging = ref(false)
|
||||||
|
const dragCounter = ref(0)
|
||||||
|
|
||||||
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
|
||||||
|
dragCounter.value++
|
||||||
|
isDragging.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragLeave = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
dragCounter.value--
|
||||||
|
if (dragCounter.value === 0) {
|
||||||
|
isDragging.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDrop = (event: DragEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
isDragging.value = false
|
||||||
|
dragCounter.value = 0
|
||||||
|
|
||||||
|
const isInternalMove = event.dataTransfer?.types.includes('application/modrinth-file-move')
|
||||||
|
if (isInternalMove) return
|
||||||
|
|
||||||
|
const files = event.dataTransfer?.files
|
||||||
|
if (files) {
|
||||||
|
emit('filesDropped', Array.from(files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
|
||||||
|
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
|
||||||
|
<div
|
||||||
|
ref="statusContentRef"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:class="['flex flex-col p-4 text-sm text-contrast']"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2 font-bold">
|
||||||
|
<FolderOpenIcon class="size-4" />
|
||||||
|
<span>
|
||||||
|
<span class="capitalize">
|
||||||
|
{{ props.fileType ? props.fileType : 'File' }} uploads
|
||||||
|
</span>
|
||||||
|
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="item in uploadQueue"
|
||||||
|
:key="item.file.name"
|
||||||
|
class="flex h-6 items-center justify-between gap-2 text-xs"
|
||||||
|
>
|
||||||
|
<div class="flex flex-1 items-center gap-2 truncate">
|
||||||
|
<transition-group name="status-icon" mode="out-in">
|
||||||
|
<SpinnerIcon
|
||||||
|
v-show="item.status === 'uploading'"
|
||||||
|
key="spinner"
|
||||||
|
class="absolute !size-4 animate-spin"
|
||||||
|
/>
|
||||||
|
<CheckCircleIcon
|
||||||
|
v-show="item.status === 'completed'"
|
||||||
|
key="check"
|
||||||
|
class="absolute size-4 text-green"
|
||||||
|
/>
|
||||||
|
<XCircleIcon
|
||||||
|
v-show="
|
||||||
|
item.status.includes('error') ||
|
||||||
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'incorrect-type'
|
||||||
|
"
|
||||||
|
key="error"
|
||||||
|
class="absolute size-4 text-red"
|
||||||
|
/>
|
||||||
|
</transition-group>
|
||||||
|
<span class="ml-6 truncate">{{ item.file.name }}</span>
|
||||||
|
<span class="text-secondary">{{ item.size }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||||
|
<template v-if="item.status === 'completed'">
|
||||||
|
<span>Done</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'error-file-exists'">
|
||||||
|
<span class="text-red">Failed - File already exists</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'error-generic'">
|
||||||
|
<span class="text-red"
|
||||||
|
>Failed - {{ item.error?.message || 'An unexpected error occured.' }}</span
|
||||||
|
>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'incorrect-type'">
|
||||||
|
<span class="text-red">Failed - Incorrect file type</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<template v-if="item.status === 'uploading'">
|
||||||
|
<span>{{ item.progress }}%</span>
|
||||||
|
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||||
|
<div
|
||||||
|
class="h-full bg-contrast transition-all duration-200"
|
||||||
|
:style="{ width: item.progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||||
|
<button>Cancel</button>
|
||||||
|
</ButtonStyled>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="item.status === 'cancelled'">
|
||||||
|
<span class="text-red">Cancelled</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>{{ item.progress }}%</span>
|
||||||
|
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
|
||||||
|
<div
|
||||||
|
class="h-full bg-contrast transition-all duration-200"
|
||||||
|
:style="{ width: item.progress + '%' }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircleIcon, FolderOpenIcon, SpinnerIcon, XCircleIcon } from '@modrinth/assets'
|
||||||
|
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const { addNotification } = injectNotificationManager()
|
||||||
|
const client = injectModrinthClient()
|
||||||
|
|
||||||
|
interface UploadItem {
|
||||||
|
file: File
|
||||||
|
progress: number
|
||||||
|
status:
|
||||||
|
| 'pending'
|
||||||
|
| 'uploading'
|
||||||
|
| 'completed'
|
||||||
|
| 'error-file-exists'
|
||||||
|
| 'error-generic'
|
||||||
|
| 'cancelled'
|
||||||
|
| 'incorrect-type'
|
||||||
|
size: string
|
||||||
|
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
|
||||||
|
error?: Error
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentPath: string
|
||||||
|
fileType?: string
|
||||||
|
marginBottom?: number
|
||||||
|
acceptedTypes?: Array<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
uploadComplete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const uploadStatusRef = ref<HTMLElement | null>(null)
|
||||||
|
const statusContentRef = ref<HTMLElement | null>(null)
|
||||||
|
const uploadQueue = ref<UploadItem[]>([])
|
||||||
|
|
||||||
|
const isUploading = computed(() => uploadQueue.value.length > 0)
|
||||||
|
const activeUploads = computed(() =>
|
||||||
|
uploadQueue.value.filter((item) => item.status === 'pending' || item.status === 'uploading'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const onUploadStatusEnter = (el: Element) => {
|
||||||
|
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
|
||||||
|
;(el as HTMLElement).style.height = '0'
|
||||||
|
|
||||||
|
void (el as HTMLElement).offsetHeight
|
||||||
|
;(el as HTMLElement).style.height = `${height}px`
|
||||||
|
}
|
||||||
|
|
||||||
|
const onUploadStatusLeave = (el: Element) => {
|
||||||
|
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
|
||||||
|
;(el as HTMLElement).style.height = `${height}px`
|
||||||
|
|
||||||
|
void (el as HTMLElement).offsetHeight
|
||||||
|
;(el as HTMLElement).style.height = '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
uploadQueue,
|
||||||
|
() => {
|
||||||
|
if (!uploadStatusRef.value) return
|
||||||
|
const el = uploadStatusRef.value
|
||||||
|
const itemsHeight = uploadQueue.value.length * 32
|
||||||
|
const headerHeight = 12
|
||||||
|
const gap = 8
|
||||||
|
const padding = 32
|
||||||
|
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0)
|
||||||
|
el.style.height = `${totalHeight}px`
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const formatFileSize = (bytes: number): string => {
|
||||||
|
if (bytes < 1024) return bytes + ' B'
|
||||||
|
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
|
||||||
|
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
|
||||||
|
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelUpload = (item: UploadItem) => {
|
||||||
|
if (item.uploader && item.status === 'uploading') {
|
||||||
|
item.uploader.cancel()
|
||||||
|
item.status = 'cancelled'
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name)
|
||||||
|
if (index !== -1) {
|
||||||
|
uploadQueue.value.splice(index, 1)
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const badFileTypeMsg = 'Upload had incorrect file type'
|
||||||
|
|
||||||
|
const uploadFile = async (file: File) => {
|
||||||
|
const uploadItem: UploadItem = {
|
||||||
|
file,
|
||||||
|
progress: 0,
|
||||||
|
status: 'pending',
|
||||||
|
size: formatFileSize(file.size),
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadQueue.value.push(uploadItem)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (
|
||||||
|
props.acceptedTypes &&
|
||||||
|
!props.acceptedTypes.includes(file.type) &&
|
||||||
|
!props.acceptedTypes.some((type) => file.name.endsWith(type))
|
||||||
|
) {
|
||||||
|
throw new Error(badFileTypeMsg)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadItem.status = 'uploading'
|
||||||
|
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
|
||||||
|
|
||||||
|
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
|
||||||
|
onProgress: ({ progress }) => {
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||||
|
if (index !== -1) {
|
||||||
|
uploadQueue.value[index].progress = Math.round(progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
uploadItem.uploader = uploader
|
||||||
|
|
||||||
|
await uploader.promise
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||||
|
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
||||||
|
uploadQueue.value[index].status = 'completed'
|
||||||
|
uploadQueue.value[index].progress = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||||
|
if (removeIndex !== -1) {
|
||||||
|
uploadQueue.value.splice(removeIndex, 1)
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
emit('uploadComplete')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading file:', error)
|
||||||
|
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||||
|
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
|
||||||
|
const target = uploadQueue.value[index]
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
if (error.message === badFileTypeMsg) {
|
||||||
|
target.status = 'incorrect-type'
|
||||||
|
} else if (target.progress === 100 && error.message.includes('401')) {
|
||||||
|
target.status = 'error-file-exists'
|
||||||
|
} else {
|
||||||
|
target.status = 'error-generic'
|
||||||
|
target.error = error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
|
||||||
|
if (removeIndex !== -1) {
|
||||||
|
uploadQueue.value.splice(removeIndex, 1)
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
if (error instanceof Error && error.message !== 'Upload cancelled') {
|
||||||
|
addNotification({
|
||||||
|
title: 'Upload failed',
|
||||||
|
text: `Failed to upload ${file.name}`,
|
||||||
|
type: 'error',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
uploadFile,
|
||||||
|
cancelUpload,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.upload-status {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status-enter-active,
|
||||||
|
.upload-status-leave-active {
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-status-enter-from,
|
||||||
|
.upload-status-leave-to {
|
||||||
|
height: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-active,
|
||||||
|
.status-icon-leave-active {
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-from,
|
||||||
|
.status-icon-leave-to {
|
||||||
|
transform: scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon-enter-to,
|
||||||
|
.status-icon-leave-from {
|
||||||
|
transform: scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
2
packages/ui/src/components/servers/files/upload/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { default as FileUploadDragAndDrop } from './FileUploadDragAndDrop.vue'
|
||||||
|
export { default as FileUploadDropdown } from './FileUploadDropdown.vue'
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './backups'
|
export * from './backups'
|
||||||
|
export * from './files'
|
||||||
export * from './icons'
|
export * from './icons'
|
||||||
export * from './labels'
|
export * from './labels'
|
||||||
export * from './marketing'
|
export * from './marketing'
|
||||||
|
|||||||
@@ -350,7 +350,6 @@ const createBackupModal = ref<InstanceType<typeof BackupCreateModal>>()
|
|||||||
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
|
const renameBackupModal = ref<InstanceType<typeof BackupRenameModal>>()
|
||||||
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
|
const restoreBackupModal = ref<InstanceType<typeof BackupRestoreModal>>()
|
||||||
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
|
const deleteBackupModal = ref<InstanceType<typeof BackupDeleteModal>>()
|
||||||
// const backupSettingsModal = ref<InstanceType<typeof BackupSettingsModal>>()
|
|
||||||
|
|
||||||
const backupRestoreDisabled = computed(() => {
|
const backupRestoreDisabled = computed(() => {
|
||||||
if (props.isServerRunning) {
|
if (props.isServerRunning) {
|
||||||
@@ -400,10 +399,6 @@ const showCreateModel = () => {
|
|||||||
createBackupModal.value?.show()
|
createBackupModal.value?.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
// const showbackupSettingsModal = () => {
|
|
||||||
// backupSettingsModal.value?.show()
|
|
||||||
// }
|
|
||||||
|
|
||||||
function triggerDownloadAnimation() {
|
function triggerDownloadAnimation() {
|
||||||
overTheTopDownloadAnimation.value = true
|
overTheTopDownloadAnimation.value = true
|
||||||
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
|
setTimeout(() => (overTheTopDownloadAnimation.value = false), 500)
|
||||||
|
|||||||
1320
packages/ui/src/pages/hosting/manage/files.vue
Normal file
@@ -1,2 +1,3 @@
|
|||||||
export { default as ServersManageBackupsPage } from './hosting/manage/backups.vue'
|
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'
|
export { default as ServersManagePageIndex } from './hosting/manage/index.vue'
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ export type BackupProgressEntry = {
|
|||||||
|
|
||||||
export type BackupsState = Map<string, BackupProgressEntry>
|
export type BackupsState = Map<string, BackupProgressEntry>
|
||||||
|
|
||||||
|
export interface FilesystemAuth {
|
||||||
|
url: string
|
||||||
|
token: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ModrinthServerContext {
|
export interface ModrinthServerContext {
|
||||||
readonly serverId: string
|
readonly serverId: string
|
||||||
readonly server: Ref<Archon.Servers.v0.Server>
|
readonly server: Ref<Archon.Servers.v0.Server>
|
||||||
@@ -26,6 +31,12 @@ export interface ModrinthServerContext {
|
|||||||
readonly isServerRunning: ComputedRef<boolean>
|
readonly isServerRunning: ComputedRef<boolean>
|
||||||
readonly backupsState: Reactive<BackupsState>
|
readonly backupsState: Reactive<BackupsState>
|
||||||
markBackupCancelled: (backupId: string) => void
|
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] =
|
export const [injectModrinthServerContext, provideModrinthServerContext] =
|
||||||
|
|||||||
15
packages/ui/src/utils/ace-theme.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import cssText from '@modrinth/assets/styles/ace.css?raw'
|
||||||
|
import ace from 'ace-builds'
|
||||||
|
|
||||||
|
ace['define'](
|
||||||
|
'ace/theme/modrinth',
|
||||||
|
['require', 'exports', 'module', 'ace/lib/dom'],
|
||||||
|
function (require, exports, _module) {
|
||||||
|
exports.isDark = false
|
||||||
|
exports.cssClass = 'ace-modrinth'
|
||||||
|
exports.cssText = cssText
|
||||||
|
|
||||||
|
const dom = require('ace/lib/dom')
|
||||||
|
dom.importCssString(exports.cssText, exports.cssClass, false)
|
||||||
|
},
|
||||||
|
)
|
||||||
152
packages/ui/src/utils/file-extensions.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
// File extension constants
|
||||||
|
export const CODE_EXTENSIONS = [
|
||||||
|
'json',
|
||||||
|
'json5',
|
||||||
|
'jsonc',
|
||||||
|
'java',
|
||||||
|
'kt',
|
||||||
|
'kts',
|
||||||
|
'sh',
|
||||||
|
'bat',
|
||||||
|
'ps1',
|
||||||
|
'yml',
|
||||||
|
'yaml',
|
||||||
|
'toml',
|
||||||
|
'js',
|
||||||
|
'ts',
|
||||||
|
'py',
|
||||||
|
'rb',
|
||||||
|
'php',
|
||||||
|
'html',
|
||||||
|
'css',
|
||||||
|
'cpp',
|
||||||
|
'c',
|
||||||
|
'h',
|
||||||
|
'rs',
|
||||||
|
'go',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const TEXT_EXTENSIONS = [
|
||||||
|
'txt',
|
||||||
|
'md',
|
||||||
|
'log',
|
||||||
|
'cfg',
|
||||||
|
'conf',
|
||||||
|
'properties',
|
||||||
|
'ini',
|
||||||
|
'sk',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'] as const
|
||||||
|
|
||||||
|
export const ARCHIVE_EXTENSIONS = ['zip'] as const
|
||||||
|
|
||||||
|
// Type for extension strings
|
||||||
|
export type CodeExtension = (typeof CODE_EXTENSIONS)[number]
|
||||||
|
export type TextExtension = (typeof TEXT_EXTENSIONS)[number]
|
||||||
|
export type ImageExtension = (typeof IMAGE_EXTENSIONS)[number]
|
||||||
|
export type ArchiveExtension = (typeof ARCHIVE_EXTENSIONS)[number]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract file extension from filename (lowercase)
|
||||||
|
*/
|
||||||
|
export function getFileExtension(filename: string): string {
|
||||||
|
return filename.split('.').pop()?.toLowerCase() ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if extension is a code file
|
||||||
|
*/
|
||||||
|
export function isCodeFile(ext: string): boolean {
|
||||||
|
return (CODE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if extension is a text file
|
||||||
|
*/
|
||||||
|
export function isTextFile(ext: string): boolean {
|
||||||
|
return (TEXT_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if extension is an image file
|
||||||
|
*/
|
||||||
|
export function isImageFile(ext: string): boolean {
|
||||||
|
return (IMAGE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if extension is an archive file
|
||||||
|
*/
|
||||||
|
export function isArchiveFile(ext: string): boolean {
|
||||||
|
return (ARCHIVE_EXTENSIONS as readonly string[]).includes(ext.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if file is editable (code or text)
|
||||||
|
*/
|
||||||
|
export function isEditableFile(ext: string): boolean {
|
||||||
|
return isCodeFile(ext) || isTextFile(ext)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Ace editor language mode for a file extension
|
||||||
|
*/
|
||||||
|
export function getEditorLanguage(ext: string): string {
|
||||||
|
const lowered = ext.toLowerCase()
|
||||||
|
switch (lowered) {
|
||||||
|
// Code files
|
||||||
|
case 'json':
|
||||||
|
case 'json5':
|
||||||
|
case 'jsonc':
|
||||||
|
return 'json'
|
||||||
|
case 'toml':
|
||||||
|
return 'toml'
|
||||||
|
case 'sh':
|
||||||
|
return 'sh'
|
||||||
|
case 'bat':
|
||||||
|
return 'batchfile'
|
||||||
|
case 'ps1':
|
||||||
|
return 'powershell'
|
||||||
|
case 'yml':
|
||||||
|
case 'yaml':
|
||||||
|
return 'yaml'
|
||||||
|
case 'js':
|
||||||
|
return 'javascript'
|
||||||
|
case 'ts':
|
||||||
|
return 'typescript'
|
||||||
|
case 'py':
|
||||||
|
return 'python'
|
||||||
|
case 'rb':
|
||||||
|
return 'ruby'
|
||||||
|
case 'php':
|
||||||
|
return 'php'
|
||||||
|
case 'html':
|
||||||
|
return 'html'
|
||||||
|
case 'css':
|
||||||
|
return 'css'
|
||||||
|
case 'java':
|
||||||
|
case 'kt':
|
||||||
|
case 'kts':
|
||||||
|
return 'java'
|
||||||
|
case 'cpp':
|
||||||
|
case 'c':
|
||||||
|
case 'h':
|
||||||
|
return 'c_cpp'
|
||||||
|
case 'rs':
|
||||||
|
return 'rust'
|
||||||
|
case 'go':
|
||||||
|
return 'golang'
|
||||||
|
// Text files
|
||||||
|
case 'md':
|
||||||
|
return 'markdown'
|
||||||
|
case 'properties':
|
||||||
|
return 'properties'
|
||||||
|
case 'ini':
|
||||||
|
case 'cfg':
|
||||||
|
case 'conf':
|
||||||
|
return 'ini'
|
||||||
|
default:
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export * from './auto-icons'
|
export * from './auto-icons'
|
||||||
export * from './common-messages'
|
export * from './common-messages'
|
||||||
export * from './events'
|
export * from './events'
|
||||||
|
export * from './file-extensions'
|
||||||
export * from './game-modes'
|
export * from './game-modes'
|
||||||
export * from './notices'
|
export * from './notices'
|
||||||
export * from './savable'
|
export * from './savable'
|
||||||
|
|||||||
@@ -16,4 +16,4 @@ export interface ModuleError {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ModuleName = 'general' | 'content' | 'backups' | 'network' | 'startup' | 'ws' | 'fs'
|
export type ModuleName = 'general' | 'content' | 'network' | 'startup'
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
@@ -613,6 +613,9 @@ importers:
|
|||||||
'@vueuse/core':
|
'@vueuse/core':
|
||||||
specifier: ^11.1.0
|
specifier: ^11.1.0
|
||||||
version: 11.3.0(vue@3.5.26(typescript@5.9.3))
|
version: 11.3.0(vue@3.5.26(typescript@5.9.3))
|
||||||
|
ace-builds:
|
||||||
|
specifier: ^1.43.5
|
||||||
|
version: 1.43.5
|
||||||
apexcharts:
|
apexcharts:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.7.0
|
version: 4.7.0
|
||||||
@@ -655,6 +658,9 @@ importers:
|
|||||||
vue-typed-virtual-list:
|
vue-typed-virtual-list:
|
||||||
specifier: ^1.0.10
|
specifier: ^1.0.10
|
||||||
version: 1.0.10(vue@3.5.26(typescript@5.9.3))
|
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:
|
vue3-apexcharts:
|
||||||
specifier: ^1.4.4
|
specifier: ^1.4.4
|
||||||
version: 1.10.0(apexcharts@4.7.0)(vue@3.5.26(typescript@5.9.3))
|
version: 1.10.0(apexcharts@4.7.0)(vue@3.5.26(typescript@5.9.3))
|
||||||
|
|||||||