refactor: align files tab with content tab design (#5621)

* fix: files.vue bugs before styling changes

* feat: move files tab to shared layout structure

* fix: qa

* fix: qa

* fix: bugs

* fix: lint

* fix: admonition cleanup with progress + actions

* fix: cleanup

* fix: modals

* fix: admon title

* fix: i18n standard

* fix: lint + i18n pass

* fix: remove transition

* fix: type errors

* feat: files tab in app

* fix: qa

* fix: backup item minmax

* fix: use ContentPageHeader for server panel

* fix: lint

* fix: lint

* fix: lint

* feat: page leave safety

* fix: lint

* fix: cargo fmt fix

* fix: blank in prod

* fix: content card table stuff

* Revert "fix: blank in prod"

This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace.

* fix: import

* feat: browse worlds/servers flow

* fix: worlds tab parity with content tab

* fix: perf bug + shader filter pill copy

* feat: singleplayer filter

* fix: ordering

* fix: breadcrumbs

* fix: lint

* fix: qa

* feat: store server proj id when adding to a non-linked instance

* fix: lint

* fix: i18n + qa

* fix: conflict

* qa: already installed modal + placeholders not server-specific

* fix: qa

* fix: add + edit server modals

* fix: qa

* fix: security

* fix: devin flags

* fix: lint

* chore: change file to break build cache

* fix: admon

* fix: import path stuff

* feat: qa

* fix: fmt fmt idiot

---------

Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
Calum H.
2026-03-26 18:55:15 +00:00
committed by GitHub
parent 706eb800cb
commit 381ea51cce
170 changed files with 8052 additions and 4571 deletions

View File

@@ -5,38 +5,48 @@
typeClasses[type],
]"
>
<ButtonStyled
v-if="dismissible"
circular
type="highlight-colored-text"
:color="buttonColors[type]"
>
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
<div
:class="[
'flex gap-2 items-start',
(header || $slots.header) && 'flex-col',
dismissible && 'pr-8',
]"
>
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component
:is="getSeverityIcon(type)"
:class="['h-6 w-6 flex-none', iconClasses[type]]"
/>
</slot>
<div v-if="header || $slots.header" class="font-semibold text-base">
<slot name="header">{{ header }}</slot>
<div class="flex items-start gap-2">
<div
:class="[
'flex flex-1 gap-2',
header || $slots.header ? 'flex-col items-start' : 'items-center',
(dismissible || $slots['top-right-actions']) && 'pr-8',
]"
>
<div
class="flex gap-2 items-start"
:class="header || $slots.header ? 'w-full' : 'contents'"
>
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component
:is="getSeverityIcon(type)"
:class="['h-6 w-6 flex-none', iconClasses[type]]"
/>
</slot>
<div v-if="header || $slots.header" class="font-semibold text-base">
<slot name="header">{{ header }}</slot>
</div>
</div>
<div class="font-normal text-contrast/80" :class="!(header || $slots.header) && 'flex-1'">
<slot>{{ body }}</slot>
</div>
</div>
<div class="font-normal text-base" :class="!(header || $slots.header) && 'flex-1'">
<slot>{{ body }}</slot>
<div v-if="$slots['top-right-actions']" class="flex shrink-0 items-center gap-2">
<slot name="top-right-actions" />
</div>
<ButtonStyled
v-else-if="dismissible"
circular
type="highlight-colored-text"
:color="buttonColors[type]"
>
<button aria-label="Dismiss" class="absolute top-3 right-3" @click="$emit('dismiss')">
<XIcon class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<div v-if="$slots.progress">
<slot name="progress" />
</div>
<div v-if="showActionsUnderneath || $slots.actions">
<slot name="actions" />

View File

@@ -0,0 +1,318 @@
<template>
<nav
v-if="filteredLinks.length > 1"
ref="scrollContainer"
class="relative flex w-fit overflow-x-auto rounded-full bg-bg-raised p-1 text-sm font-bold"
:class="{ 'shadow-sm': mode === 'navigation' }"
>
<template v-if="mode === 'navigation'">
<RouterLink
v-for="(link, index) in filteredLinks"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
:replace="replace"
:to="query ? (link.href ? `?${query}=${link.href}` : '?') : link.href"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@mouseenter="link.onHover?.()"
@focus="link.onHover?.()"
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</RouterLink>
</template>
<template v-else>
<div
v-for="(link, index) in filteredLinks"
v-show="link.shown ?? true"
:key="link.href"
ref="tabLinkElements"
class="button-animation z-[1] flex flex-row items-center gap-2 px-4 py-2 hover:cursor-pointer focus:rounded-full"
:class="getSSRFallbackClasses(index)"
@click="emit('tabClick', index, link)"
>
<component :is="link.icon" v-if="link.icon" class="size-5" :class="getIconClasses(index)" />
<span class="text-nowrap" :class="getLabelClasses(index)">
{{ link.label }}
</span>
</div>
</template>
<!-- Animated slider background -->
<div
class="pointer-events-none absolute h-[calc(100%-0.5rem)] overflow-hidden rounded-full p-1"
:class="[
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
{ 'navtabs-transition': transitionsEnabled },
]"
:style="sliderStyle"
aria-hidden="true"
/>
</nav>
</template>
<script setup lang="ts">
import type { Component } from 'vue'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
interface Tab {
label: string
href: string
shown?: boolean
icon?: Component
subpages?: string[]
onHover?: () => void
}
const props = withDefaults(
defineProps<{
replace?: boolean
links: Tab[]
query?: string
mode?: 'navigation' | 'local'
activeIndex?: number
}>(),
{
mode: 'navigation',
query: undefined,
activeIndex: undefined,
},
)
const emit = defineEmits<{
tabClick: [index: number, tab: Tab]
}>()
// DOM refs
const scrollContainer = ref<HTMLElement | null>(null)
const tabLinkElements = ref<HTMLElement[]>()
// Slider pos state
const sliderLeft = ref(4)
const sliderTop = ref(4)
const sliderRight = ref(4)
const sliderBottom = ref(4)
// active tab state
const currentActiveIndex = ref(-1)
const subpageSelected = ref(false)
// SSR state
const sliderReady = ref(false)
const transitionsEnabled = ref(false)
// Stagger delays for the trailing edges of the slider animation
const sliderDelays = ref({ left: '0ms', top: '0ms', right: '0ms', bottom: '0ms' })
const filteredLinks = computed(() => props.links.filter((link) => link.shown ?? true))
const sliderStyle = computed(() => ({
left: `${sliderLeft.value}px`,
top: `${sliderTop.value}px`,
right: `${sliderRight.value}px`,
bottom: `${sliderBottom.value}px`,
opacity: sliderReady.value && currentActiveIndex.value !== -1 ? 1 : 0,
}))
const leftDelay = computed(() => sliderDelays.value.left)
const rightDelay = computed(() => sliderDelays.value.right)
const topDelay = computed(() => sliderDelays.value.top)
const bottomDelay = computed(() => sliderDelays.value.bottom)
const isActiveAndNotSubpage = computed(
() => (index: number) => currentActiveIndex.value === index && !subpageSelected.value,
)
function getSSRFallbackClasses(index: number) {
if (sliderReady.value) return {}
if (currentActiveIndex.value !== index) return {}
return {
'rounded-full': true,
'bg-button-bgSelected': !subpageSelected.value,
'bg-button-bg': subpageSelected.value,
}
}
function getIconClasses(index: number) {
return {
'text-button-textSelected': isActiveAndNotSubpage.value(index),
'text-secondary': !isActiveAndNotSubpage.value(index),
}
}
function getLabelClasses(index: number) {
return {
'text-button-textSelected': isActiveAndNotSubpage.value(index),
'text-contrast': !isActiveAndNotSubpage.value(index),
}
}
function computeActiveIndex(): { index: number; isSubpage: boolean } {
if (props.mode === 'local' && props.activeIndex !== undefined) {
return {
index: Math.min(props.activeIndex, filteredLinks.value.length - 1),
isSubpage: false,
}
}
for (let i = filteredLinks.value.length - 1; i >= 0; i--) {
const link = filteredLinks.value[i]
const decodedPath = decodeURIComponent(route.path)
const decodedHref = decodeURIComponent(link.href.split('?')[0])
if (props.query) {
const queryValue = route.query[props.query]
if (queryValue === link.href || (!queryValue && !link.href)) {
return { index: i, isSubpage: false }
}
continue
}
if (decodedPath === decodedHref) {
return { index: i, isSubpage: false }
}
const isSubpageMatch =
(decodedPath.startsWith(decodedHref) &&
(decodedPath.length === decodedHref.length || decodedPath[decodedHref.length] === '/')) ||
link.subpages?.some((subpage) => decodedPath.includes(subpage))
if (isSubpageMatch) {
return { index: i, isSubpage: true }
}
}
return { index: -1, isSubpage: false }
}
function getTabElement(index: number): HTMLElement | null {
if (index === -1) return null
const container = scrollContainer.value as HTMLElement | undefined
if (!container) return null
const tabs = container.querySelectorAll('.button-animation')
const element = tabs[index] as HTMLElement | undefined
if (!element) return null
return element
}
function positionSlider() {
const el = getTabElement(currentActiveIndex.value)
if (!el?.offsetParent) return
const parent = el.offsetParent as HTMLElement
const newPosition = {
left: el.offsetLeft,
top: el.offsetTop,
right: parent.offsetWidth - el.offsetLeft - el.offsetWidth,
bottom: parent.offsetHeight - el.offsetTop - el.offsetHeight,
}
const isInitialPosition = sliderLeft.value === 4 && sliderRight.value === 4
if (isInitialPosition) {
sliderLeft.value = newPosition.left
sliderRight.value = newPosition.right
sliderTop.value = newPosition.top
sliderBottom.value = newPosition.bottom
sliderReady.value = true
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
} else {
animateSliderTo(newPosition)
}
}
function animateSliderTo(newPosition: {
left: number
top: number
right: number
bottom: number
}) {
const STAGGER_DELAY = '200ms'
sliderDelays.value = {
left: newPosition.left < sliderLeft.value ? '0ms' : STAGGER_DELAY,
right: newPosition.left < sliderLeft.value ? STAGGER_DELAY : '0ms',
top: newPosition.top < sliderTop.value ? '0ms' : STAGGER_DELAY,
bottom: newPosition.top < sliderTop.value ? STAGGER_DELAY : '0ms',
}
sliderLeft.value = newPosition.left
sliderRight.value = newPosition.right
sliderTop.value = newPosition.top
sliderBottom.value = newPosition.bottom
}
async function updateActiveTab() {
await nextTick()
const { index, isSubpage } = computeActiveIndex()
currentActiveIndex.value = index
subpageSelected.value = isSubpage
if (index !== -1) {
positionSlider()
} else {
sliderLeft.value = 0
sliderRight.value = 0
}
}
const initialActive = computeActiveIndex()
currentActiveIndex.value = initialActive.index
subpageSelected.value = initialActive.isSubpage
onMounted(updateActiveTab)
watch(
() => [route.path, route.query],
() => {
if (props.mode === 'navigation') {
updateActiveTab()
}
},
)
watch(
() => props.activeIndex,
() => {
if (props.mode === 'local') {
updateActiveTab()
}
},
)
watch(
() => props.links,
async () => {
await nextTick()
updateActiveTab()
},
{ deep: true },
)
</script>
<style scoped>
.navtabs-transition {
transition:
left 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(leftDelay),
right 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(rightDelay),
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>

View File

@@ -6,7 +6,6 @@
:placement="placement"
:class="dropdownClass"
@apply-hide="focusTrigger"
@apply-show="focusMenuChild"
>
<button ref="trigger" v-bind="$attrs" v-tooltip="tooltip">
<slot></slot>
@@ -52,14 +51,6 @@ defineProps({
},
})
function focusMenuChild() {
setTimeout(() => {
if (menu.value && menu.value.children && menu.value.children.length > 0) {
menu.value.children[0].focus()
}
}, 50)
}
function hideAndFocusTrigger(hide) {
hide()
focusTrigger()

View File

@@ -45,6 +45,7 @@ export type { MultiSelectOption } from './MultiSelect.vue'
export { default as MultiSelect } from './MultiSelect.vue'
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
export { default as NavTabs } from './NavTabs.vue'
export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'

View File

@@ -98,7 +98,7 @@
import { ChevronRightIcon, FolderSearchIcon, SearchIcon } from '@modrinth/assets'
import { computed, onMounted, ref, watch } from 'vue'
import { injectInstanceImport } from '../../../../providers'
import { injectInstanceImport, injectNotificationManager } from '../../../../providers'
import type { ImportableLauncher } from '../../../../providers/instance-import'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import Checkbox from '../../../base/Checkbox.vue'
@@ -108,6 +108,7 @@ import { injectCreationFlowContext } from '../creation-flow-context'
const ctx = injectCreationFlowContext()
const importProvider = injectInstanceImport()
const { addNotification } = injectNotificationManager()
const loading = ref(false)
const expandedLaunchers = ref(new Set<string>())
@@ -257,6 +258,14 @@ async function addLauncherPath() {
try {
const instances = await importProvider.getImportableInstances('Custom', path)
if (instances.length === 0) {
addNotification({
type: 'error',
title: 'No instances found',
text: `No importable instances were found at the specified path.`,
})
return
}
const launcher: ImportableLauncher = {
name: `Custom (${path.split(/[\\/]/).pop() || path})`,
path,
@@ -266,13 +275,12 @@ async function addLauncherPath() {
expandedLaunchers.value.add(launcher.name)
expandedLaunchers.value = new Set(expandedLaunchers.value)
} catch {
// Failed to load — still add with empty instances
const launcher: ImportableLauncher = {
name: `Custom (${path.split(/[\\/]/).pop() || path})`,
path,
instances: [],
}
ctx.importLaunchers.value = [...ctx.importLaunchers.value, launcher]
addNotification({
type: 'error',
title: 'No instances found',
text: `No importable instances were found at the specified path.`,
})
return
}
newLauncherPath.value = ''

View File

@@ -0,0 +1,106 @@
<template>
<NewModal ref="modal" :header="localizeIfPossible(title)" fade="warning" max-width="500px">
<div class="flex flex-col gap-6">
<Admonition :type="admonitionType" :header="localizeIfPossible(header)">
{{ localizeIfPossible(body) }}
</Admonition>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="cancel">
<XIcon />
{{ localizeIfPossible(stayLabel) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="leave">
<RightArrowIcon />
{{ localizeIfPossible(leaveLabel) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { RightArrowIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import { defineMessage, type MessageDescriptor, useVIntl } from '#ui/composables/i18n'
import NewModal from './NewModal.vue'
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{
title?: MessageDescriptor | string
header?: MessageDescriptor | string
body?: MessageDescriptor | string
stayLabel?: MessageDescriptor | string
leaveLabel?: MessageDescriptor | string
admonitionType?: 'warning' | 'critical' | 'info'
}>(),
{
title: () =>
defineMessage({
id: 'ui.confirm-leave-modal.title',
defaultMessage: 'Leave page?',
}),
header: () =>
defineMessage({
id: 'ui.confirm-leave-modal.header',
defaultMessage: 'You have unsaved changes',
}),
body: () =>
defineMessage({
id: 'ui.confirm-leave-modal.body',
defaultMessage: 'You have unsaved changes that will be lost if you leave this page.',
}),
stayLabel: () =>
defineMessage({
id: 'ui.confirm-leave-modal.stay',
defaultMessage: 'Stay on page',
}),
leaveLabel: () =>
defineMessage({
id: 'ui.confirm-leave-modal.leave',
defaultMessage: 'Leave page',
}),
admonitionType: 'critical',
},
)
function localizeIfPossible(message: MessageDescriptor | string) {
return typeof message === 'string' ? message : formatMessage(message)
}
const modal = ref<InstanceType<typeof NewModal>>()
let resolvePromise: ((value: boolean) => void) | null = null
function prompt(): Promise<boolean> {
return new Promise((resolve) => {
resolvePromise = resolve
modal.value?.show()
})
}
function leave() {
modal.value?.hide()
resolvePromise?.(true)
resolvePromise = null
}
function cancel() {
modal.value?.hide()
resolvePromise?.(false)
resolvePromise = null
}
defineExpose({ prompt })
</script>

View File

@@ -1,3 +1,4 @@
export { default as ConfirmLeaveModal } from './ConfirmLeaveModal.vue'
export { default as ConfirmModal } from './ConfirmModal.vue'
export { default as InstallToPlayModal } from './InstallToPlayModal.vue'
export { default as Modal } from './Modal.vue'

View File

@@ -49,7 +49,7 @@
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</div>

View File

@@ -154,7 +154,11 @@ const messages = defineMessages({
<template>
<div
class="grid items-center gap-4 rounded-2xl bg-bg-raised p-4 shadow-md"
:class="preview ? 'grid-cols-1' : 'grid-cols-[auto_1fr_auto] md:grid-cols-[1fr_400px_1fr]'"
:class="
preview
? 'grid-cols-1'
: 'grid-cols-[auto_1fr_auto] md:grid-cols-[minmax(0,1fr)_400px_minmax(0,1fr)]'
"
>
<div class="flex flex-row gap-4 items-center">
<div

View File

@@ -1,221 +0,0 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClockIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const relativeTime = useRelativeTime()
const props = withDefaults(
defineProps<{
type: 'create' | 'restore'
state: Archon.Backups.v1.BackupState
progress: number
backupName?: string
createdAt?: string
}>(),
{
backupName: undefined,
createdAt: undefined,
},
)
const emit = defineEmits<{
(e: 'cancel' | 'retry' | 'dismiss'): void
}>()
const isQueued = computed(() => props.state === 'ongoing' && props.progress === 0)
const isInProgress = computed(() => props.state === 'ongoing' && props.progress > 0)
const isFailed = computed(() => props.state === 'failed')
const isSuccess = computed(() => props.state === 'done')
const showCancel = computed(() => isQueued.value || isInProgress.value)
const showRetry = computed(() => isFailed.value)
const showDismiss = computed(() => isFailed.value || isSuccess.value)
const showProgress = computed(() => isInProgress.value)
const colorClasses = computed(() => {
if (isFailed.value) return 'border-brand-red bg-bg-red'
if (isSuccess.value) return 'border-brand-green bg-bg-green'
return 'border-brand-blue bg-bg-blue'
})
const icon = computed(() => {
if (isFailed.value) return TriangleAlertIcon
if (isSuccess.value) return CheckCircleIcon
return InfoIcon
})
const iconClass = computed(() => {
if (isFailed.value) return 'text-brand-red'
if (isSuccess.value) return 'text-brand-green'
return 'text-brand-blue'
})
const buttonColor = computed<'red' | 'green' | 'blue'>(() => {
if (isFailed.value) return 'red'
if (isSuccess.value) return 'green'
return 'blue'
})
const name = computed(() => props.backupName ?? formatMessage(messages.fallbackName))
const title = computed(() => {
if (props.type === 'create') {
if (isQueued.value) return formatMessage(messages.backupQueuedTitle)
if (isInProgress.value) return formatMessage(messages.creatingBackupTitle)
if (isFailed.value) return formatMessage(messages.backupFailedTitle)
}
if (isQueued.value) return formatMessage(messages.restoreQueuedTitle)
if (isInProgress.value) return formatMessage(messages.restoringBackupTitle)
if (isSuccess.value) return formatMessage(messages.restoreSuccessfulTitle)
if (isFailed.value) return formatMessage(messages.restoreFailedTitle)
return ''
})
const description = computed(() => {
if (props.type === 'create') {
if (isQueued.value)
return formatMessage(messages.backupQueuedDescription, { backupName: name.value })
if (isInProgress.value)
return formatMessage(messages.creatingBackupDescription, { backupName: name.value })
if (isFailed.value)
return formatMessage(messages.backupFailedDescription, { backupName: name.value })
}
if (isQueued.value)
return formatMessage(messages.restoreQueuedDescription, { backupName: name.value })
if (isInProgress.value)
return formatMessage(messages.restoringBackupDescription, { backupName: name.value })
if (isSuccess.value)
return formatMessage(messages.restoreSuccessfulDescription, { backupName: name.value })
if (isFailed.value)
return formatMessage(messages.restoreFailedDescription, { backupName: name.value })
return ''
})
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'Your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restoring from backup queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restoring from backup successful',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restoring from backup failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
})
</script>
<template>
<div :class="['flex flex-col rounded-2xl border border-solid p-4', colorClasses]">
<div class="flex items-start gap-2">
<div class="flex flex-1 gap-3 items-start">
<component :is="icon" :class="['size-6 shrink-0', iconClass]" />
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span class="font-semibold text-contrast">{{ title }}</span>
<div v-if="createdAt" class="flex items-center gap-1.5 text-secondary">
<ClockIcon class="size-4" />
<span class="font-medium">{{ relativeTime(createdAt) }}</span>
</div>
</div>
<span class="text-contrast opacity-80">{{ description }}</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<ButtonStyled v-if="showCancel" type="outlined" color="blue">
<button class="!border" @click="emit('cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="showRetry" color="red">
<button @click="emit('retry')">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="showDismiss"
circular
type="transparent"
hover-color-fill="background"
:color="buttonColor"
>
<button @click="emit('dismiss')">
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
<div v-if="showProgress" class="mt-4 pl-9">
<ProgressBar :progress="progress" color="blue" :waiting="progress === 0" full-width />
</div>
</div>
</template>

View File

@@ -1,12 +1,27 @@
<script setup lang="ts">
import type { Archon } from '@modrinth/api-client'
import {
CheckCircleIcon,
ClockIcon,
InfoIcon,
RotateCounterClockwiseIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, reactive, watch } from 'vue'
import { useRelativeTime } from '../../../composables'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { injectModrinthClient, injectModrinthServerContext } from '../../../providers'
import type { BackupProgressEntry } from '../../../providers/server-context'
import BackupProgressAdmonition from './BackupProgressAdmonition.vue'
import { commonMessages } from '../../../utils'
import Admonition from '../../base/Admonition.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import ProgressBar from '../../base/ProgressBar.vue'
const { formatMessage } = useVIntl()
const relativeTime = useRelativeTime()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { serverId, worldId, backupsState, markBackupCancelled } = injectModrinthServerContext()
@@ -81,7 +96,6 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
const result: AdmonitionEntry[] = []
const seenIds = new Set<string>()
// 1. Active WS entries (real-time progress from backupsState)
for (const [id, entry] of backupsState.entries()) {
const backup = findBackup(id)
seenIds.add(id)
@@ -115,7 +129,6 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
}
}
// 2. REST-based entries for pending/in_progress backups without WS data yet
if (backupsList.value) {
for (const backup of backupsList.value) {
if (seenIds.has(backup.id)) continue
@@ -136,7 +149,6 @@ const admonitions = computed<AdmonitionEntry[]>(() => {
}
}
// 3. Terminal entries (snapshotted before cleanup)
for (const [key, entry] of terminalEntries.entries()) {
if (dismissedIds.has(key)) continue
if (result.some((r) => r.key === key)) continue
@@ -177,6 +189,128 @@ function handleDismiss(key: string) {
dismissedIds.add(key)
terminalEntries.delete(key)
}
function getAdmonitionType(state: Archon.Backups.v1.BackupState): 'info' | 'critical' | 'success' {
if (state === 'failed') return 'critical'
if (state === 'done') return 'success'
return 'info'
}
function getIcon(state: Archon.Backups.v1.BackupState) {
if (state === 'failed') return TriangleAlertIcon
if (state === 'done') return CheckCircleIcon
return InfoIcon
}
function getButtonColor(state: Archon.Backups.v1.BackupState): 'red' | 'green' | 'blue' {
if (state === 'failed') return 'red'
if (state === 'done') return 'green'
return 'blue'
}
function isQueued(item: AdmonitionEntry) {
return item.state === 'ongoing' && item.progress === 0
}
function isInProgress(item: AdmonitionEntry) {
return item.state === 'ongoing' && item.progress > 0
}
function getTitle(item: AdmonitionEntry) {
if (item.type === 'create') {
if (isQueued(item)) return formatMessage(messages.backupQueuedTitle)
if (isInProgress(item)) return formatMessage(messages.creatingBackupTitle)
if (item.state === 'failed') return formatMessage(messages.backupFailedTitle)
}
if (isQueued(item)) return formatMessage(messages.restoreQueuedTitle)
if (isInProgress(item)) return formatMessage(messages.restoringBackupTitle)
if (item.state === 'done') return formatMessage(messages.restoreSuccessfulTitle)
if (item.state === 'failed') return formatMessage(messages.restoreFailedTitle)
return ''
}
function getDescription(item: AdmonitionEntry) {
const backupName = item.name ?? formatMessage(messages.fallbackName)
if (item.type === 'create') {
if (isQueued(item)) return formatMessage(messages.backupQueuedDescription, { backupName })
if (isInProgress(item)) return formatMessage(messages.creatingBackupDescription, { backupName })
if (item.state === 'failed')
return formatMessage(messages.backupFailedDescription, { backupName })
}
if (isQueued(item)) return formatMessage(messages.restoreQueuedDescription, { backupName })
if (isInProgress(item)) return formatMessage(messages.restoringBackupDescription, { backupName })
if (item.state === 'done')
return formatMessage(messages.restoreSuccessfulDescription, { backupName })
if (item.state === 'failed')
return formatMessage(messages.restoreFailedDescription, { backupName })
return ''
}
const messages = defineMessages({
fallbackName: {
id: 'servers.backups.admonition.fallback-name',
defaultMessage: 'Your backup',
},
backupQueuedTitle: {
id: 'servers.backups.admonition.backup-queued.title',
defaultMessage: 'Backup queued',
},
backupQueuedDescription: {
id: 'servers.backups.admonition.backup-queued.description',
defaultMessage: '{backupName} is queued and will start shortly.',
},
creatingBackupTitle: {
id: 'servers.backups.admonition.creating-backup.title',
defaultMessage: 'Creating backup',
},
creatingBackupDescription: {
id: 'servers.backups.admonition.creating-backup.description',
defaultMessage:
'Saving world data and server configuration for {backupName}. This can take a few minutes.',
},
backupFailedTitle: {
id: 'servers.backups.admonition.backup-failed.title',
defaultMessage: 'Backup failed',
},
backupFailedDescription: {
id: 'servers.backups.admonition.backup-failed.description',
defaultMessage:
'Something went wrong while creating {backupName}. Please try again or contact support if the issue continues.',
},
restoreQueuedTitle: {
id: 'servers.backups.admonition.restore-queued.title',
defaultMessage: 'Restoring from backup queued',
},
restoreQueuedDescription: {
id: 'servers.backups.admonition.restore-queued.description',
defaultMessage: 'Restoring from {backupName} is queued and will start shortly.',
},
restoringBackupTitle: {
id: 'servers.backups.admonition.restoring-backup.title',
defaultMessage: 'Restoring from backup',
},
restoringBackupDescription: {
id: 'servers.backups.admonition.restoring-backup.description',
defaultMessage: 'Restoring your server from {backupName}. This may take a couple of minutes.',
},
restoreSuccessfulTitle: {
id: 'servers.backups.admonition.restore-successful.title',
defaultMessage: 'Restoring from backup successful',
},
restoreSuccessfulDescription: {
id: 'servers.backups.admonition.restore-successful.description',
defaultMessage: 'Your server has been restored to {backupName} and is ready to start.',
},
restoreFailedTitle: {
id: 'servers.backups.admonition.restore-failed.title',
defaultMessage: 'Restoring from backup failed',
},
restoreFailedDescription: {
id: 'servers.backups.admonition.restore-failed.description',
defaultMessage:
'Something went wrong while restoring from {backupName}. Please try again or contact support if the issue continues.',
},
})
</script>
<template>
@@ -186,18 +320,55 @@ function handleDismiss(key: string) {
tag="div"
class="flex flex-col gap-3"
>
<BackupProgressAdmonition
v-for="item in admonitions"
:key="item.key"
:type="item.type"
:state="item.state"
:progress="item.progress"
:backup-name="item.name"
:created-at="item.createdAt"
@cancel="handleCancel(item.backupId)"
@retry="handleRetry(item.backupId, item.key)"
@dismiss="handleDismiss(item.key)"
/>
<Admonition v-for="item in admonitions" :key="item.key" :type="getAdmonitionType(item.state)">
<template #icon="{ iconClass }">
<component :is="getIcon(item.state)" :class="iconClass" />
</template>
<template #header>
<div class="flex items-center gap-2">
<span>{{ getTitle(item) }}</span>
<div v-if="item.createdAt" class="flex items-center gap-1.5 text-secondary">
<ClockIcon class="size-4" />
<span class="font-medium">{{ relativeTime(item.createdAt) }}</span>
</div>
</div>
</template>
{{ getDescription(item) }}
<template #top-right-actions>
<ButtonStyled v-if="isQueued(item) || isInProgress(item)" type="outlined" color="blue">
<button class="!border" @click="handleCancel(item.backupId)">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="item.state === 'failed'" color="red">
<button @click="handleRetry(item.backupId, item.key)">
<RotateCounterClockwiseIcon class="size-5" />
{{ formatMessage(commonMessages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled
v-if="item.state === 'failed' || item.state === 'done'"
circular
type="transparent"
hover-color-fill="background"
:color="getButtonColor(item.state)"
>
<button @click="handleDismiss(item.key)">
<XIcon />
</button>
</ButtonStyled>
</template>
<template v-if="isInProgress(item)" #progress>
<div class="pl-9">
<ProgressBar
:progress="item.progress"
color="blue"
:waiting="item.progress === 0"
full-width
/>
</div>
</template>
</Admonition>
</TransitionGroup>
</template>

View File

@@ -1,7 +1,6 @@
export { default as BackupCreateModal } from './BackupCreateModal.vue'
export { default as BackupDeleteModal } from './BackupDeleteModal.vue'
export { default as BackupItem } from './BackupItem.vue'
export { default as BackupProgressAdmonition } from './BackupProgressAdmonition.vue'
export { default as BackupProgressAdmonitions } from './BackupProgressAdmonitions.vue'
export { default as BackupRenameModal } from './BackupRenameModal.vue'
export { default as BackupRestoreModal } from './BackupRestoreModal.vue'

View File

@@ -1,251 +0,0 @@
<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">
<StyledInput
id="search-folder"
:model-value="searchQuery"
:icon="SearchIcon"
type="search"
name="search"
autocomplete="off"
placeholder="Search files"
wrapper-class="w-full sm:w-[280px]"
@update:model-value="$emit('update:searchQuery', $event)"
/>
<ButtonStyled v-if="showRefreshButton" type="outlined">
<button
type="button"
class="flex h-10 items-center gap-2 !border-[1px] !border-surface-5"
@click="$emit('refresh')"
>
<RefreshCwIcon aria-hidden="true" class="h-5 w-5" />
Refresh
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<OverflowMenu
:dropdown-id="`create-new-${baseId}`"
position="bottom"
direction="left"
aria-label="Create new..."
:disabled="disabled"
:tooltip="disabled ? disabledTooltip : undefined"
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, StyledInput } 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
showRefreshButton?: boolean
baseId: string
disabled?: boolean
disabledTooltip?: string
}>()
defineEmits<{
navigate: [index: number]
navigateHome: []
prefetchHome: []
'update:searchQuery': [value: string]
create: [type: 'file' | 'directory']
upload: []
uploadZip: []
unzipFromUrl: [cf: boolean]
refresh: []
save: []
saveAs: []
saveRestart: []
share: []
}>()
const isLogFile = computed(() => {
return (
props.editingFilePath?.startsWith('logs') ||
props.editingFilePath?.startsWith('crash-reports') ||
props.editingFilePath?.endsWith('.log')
)
})
</script>
<style scoped>
.breadcrumb-move,
.breadcrumb-enter-active,
.breadcrumb-leave-active {
transition: all 0.2s ease;
}
.breadcrumb-enter-from {
opacity: 0;
transform: translateX(-10px) scale(0.9);
}
.breadcrumb-leave-to {
opacity: 0;
transform: translateX(-10px) scale(0.8);
filter: blur(4px);
}
.breadcrumb-leave-active {
position: relative;
pointer-events: none;
}
.breadcrumb-move {
z-index: 1;
}
</style>

View File

@@ -1,242 +0,0 @@
<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)
const normalizedPath = file.path.startsWith('/') ? file.path : `/${file.path}`
if (file.type === 'file' && isImageFile(extension)) {
const content = await client.kyros.files_v0.downloadFile(normalizedPath)
isEditingImage.value = true
imagePreview.value = content
} else {
isEditingImage.value = false
const cachedContent = queryClient.getQueryData<string>([
'file-content',
serverId,
normalizedPath,
])
if (cachedContent) {
fileContent.value = cachedContent
} else {
const content = await client.kyros.files_v0.downloadFile(normalizedPath)
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 {
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
await client.kyros.files_v0.updateFile(normalizedPath, fileContent.value)
if (exit) {
await queryClient.invalidateQueries({ queryKey: ['servers', 'detail', serverId] })
emit('close')
}
addNotification({
title: 'File saved',
text: 'Your file has been saved.',
type: 'success',
})
} catch (error) {
console.error('Error saving file content:', error)
addNotification({ title: 'Save failed', text: 'Could not save the file.', type: 'error' })
}
}
async function saveAndRestart() {
await saveFileContent(false)
await client.archon.servers_v0.power(serverId, 'Restart')
addNotification({
title: 'Server restarted',
text: 'Your server has been restarted.',
type: 'success',
})
emit('close')
}
async function shareToMclogs() {
try {
const response = await fetch('https://api.mclo.gs/1/log', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ content: fileContent.value }),
})
const data = (await response.json()) as MclogsResponse
if (data.success && data.url) {
await navigator.clipboard.writeText(data.url)
addNotification({
title: 'Log URL copied',
text: 'Your log file URL has been copied to your clipboard.',
type: 'success',
})
} else {
throw new Error(data.error)
}
} catch (error) {
console.error('Error sharing file:', error)
addNotification({
title: 'Failed to share file',
text: 'Could not upload to mclo.gs.',
type: 'error',
})
}
}
function close() {
resetState()
emit('close')
}
onMounted(async () => {
if (modulesLoaded) {
await modulesLoaded
}
})
onUnmounted(() => {
editorInstance.value = null
resetState()
})
defineExpose({
saveFileContent,
saveAndRestart,
shareToMclogs,
close,
isEditingImage,
fileContent,
})
</script>

View File

@@ -1,178 +0,0 @@
<template>
<div class="flex h-[calc(100vh-12rem)] w-full flex-col items-center">
<div
ref="container"
class="relative w-full flex-grow cursor-grab overflow-hidden rounded-[20px] bg-black active:cursor-grabbing"
@mousedown="startPan"
@mousemove="handlePan"
@mouseup="stopPan"
@mouseleave="stopPan"
@wheel.prevent="handleWheel"
>
<div v-if="state.isLoading" />
<div
v-if="state.hasError"
class="flex h-full w-full flex-col items-center justify-center gap-8"
>
<TriangleAlertIcon class="size-8 text-red" />
<p class="m-0">{{ state.errorMessage || 'Invalid or empty image file.' }}</p>
</div>
<img
v-show="isReady"
ref="imageRef"
:src="imageObjectUrl"
class="pointer-events-none absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform"
:style="imageStyle"
alt="Viewed image"
@load="handleImageLoad"
@error="handleImageError"
/>
</div>
<div
v-if="!state.hasError"
class="absolute bottom-0 mb-2 flex w-fit justify-center gap-2 space-x-4 rounded-2xl bg-bg p-2"
>
<ButtonStyled type="transparent" @click="zoom(ZOOM_IN_FACTOR)">
<button v-tooltip="'Zoom in'">
<ZoomInIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" @click="zoom(ZOOM_OUT_FACTOR)">
<button v-tooltip="'Zoom out'">
<ZoomOutIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" @click="reset">
<button>
<span class="font-mono">{{ Math.round(state.scale * 100) }}%</span>
<span class="ml-4 text-sm text-blue">Reset</span>
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import { TriangleAlertIcon, ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
const ZOOM_MIN = 0.1
const ZOOM_MAX = 5
const ZOOM_IN_FACTOR = 1.2
const ZOOM_OUT_FACTOR = 0.8
const INITIAL_SCALE = 0.5
const MAX_IMAGE_DIMENSION = 4096
const props = defineProps<{
imageBlob: Blob
}>()
const state = ref({
scale: INITIAL_SCALE,
translateX: 0,
translateY: 0,
isPanning: false,
startX: 0,
startY: 0,
isLoading: false,
hasError: false,
errorMessage: '',
})
const imageRef = ref<HTMLImageElement | null>(null)
const container = ref<HTMLElement | null>(null)
const imageObjectUrl = ref('')
const rafId = ref(0)
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
const imageStyle = computed(() => ({
transform: `translate(-50%, -50%) scale(${state.value.scale}) translate(${state.value.translateX}px, ${state.value.translateY}px)`,
transition: state.value.isPanning ? 'none' : 'transform 0.3s ease-out',
}))
const validateImageDimensions = (img: HTMLImageElement): boolean => {
if (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION) {
state.value.hasError = true
state.value.errorMessage = `Image too large to view (max ${MAX_IMAGE_DIMENSION}x${MAX_IMAGE_DIMENSION} pixels)`
return false
}
return true
}
const updateImageUrl = (blob: Blob) => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
imageObjectUrl.value = URL.createObjectURL(blob)
}
const handleImageLoad = () => {
if (!imageRef.value || !validateImageDimensions(imageRef.value)) {
state.value.isLoading = false
return
}
state.value.isLoading = false
reset()
}
const handleImageError = () => {
state.value.isLoading = false
state.value.hasError = true
state.value.errorMessage = 'Failed to load image'
}
const zoom = (factor: number) => {
const newScale = state.value.scale * factor
state.value.scale = Math.max(ZOOM_MIN, Math.min(newScale, ZOOM_MAX))
}
const reset = () => {
state.value.scale = INITIAL_SCALE
state.value.translateX = 0
state.value.translateY = 0
}
const startPan = (e: MouseEvent) => {
state.value.isPanning = true
state.value.startX = e.clientX - state.value.translateX
state.value.startY = e.clientY - state.value.translateY
}
const handlePan = (e: MouseEvent) => {
if (!state.value.isPanning) return
cancelAnimationFrame(rafId.value)
rafId.value = requestAnimationFrame(() => {
state.value.translateX = e.clientX - state.value.startX
state.value.translateY = e.clientY - state.value.startY
})
}
const stopPan = () => {
state.value.isPanning = false
}
const handleWheel = (e: WheelEvent) => {
const delta = e.deltaY * -0.001
const factor = 1 + delta
zoom(factor)
}
watch(
() => props.imageBlob,
(newBlob) => {
if (!newBlob) return
state.value.isLoading = true
state.value.hasError = false
updateImageUrl(newBlob)
},
)
onMounted(() => {
if (props.imageBlob) updateImageUrl(props.imageBlob)
})
onUnmounted(() => {
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
cancelAnimationFrame(rafId.value)
})
</script>

View File

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

View File

@@ -1,360 +0,0 @@
<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,
useFormatDateTime,
} 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
writeDisabled?: boolean
writeDisabledTooltip?: string
}
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 formatDateTime = useFormatDateTime({
year: '2-digit',
month: '2-digit',
day: '2-digit',
hour: 'numeric',
minute: 'numeric',
})
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(() => {
const item = { name: props.name, type: props.type, path: props.path }
const wd = props.writeDisabled
const wdTooltip = props.writeDisabledTooltip
return [
{
id: 'extract',
shown: isZip.value,
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('extract', item),
},
{
divider: true,
shown: isZip.value,
},
{
id: 'rename',
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('rename', item),
},
{
id: 'move',
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('move', item),
},
{
id: 'download',
action: () => emit('download', item),
shown: props.type !== 'directory',
},
{
id: 'delete',
disabled: wd,
tooltip: wd ? wdTooltip : undefined,
action: () => emit('delete', item),
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 formatDateTime(date)
})
const formattedCreationDate = computed(() => {
const date = new Date(props.created * 1000)
return formatDateTime(date)
})
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 } })
}
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 3%);
}
:global(.dark-mode) .file-row-alt,
:global(.dark) .file-row-alt,
:global(.oled-mode) .file-row-alt {
background: color-mix(in srgb, var(--surface-2), black 10%);
}
</style>

View File

@@ -1,100 +0,0 @@
<template>
<div
aria-hidden="true"
class="sticky top-0 z-20 flex w-full select-none flex-row items-center justify-between border border-b-0 border-solid border-surface-3 bg-surface-3 p-4 text-sm font-medium transition-[border-radius] duration-100 before:pointer-events-none before:absolute before:inset-x-0 before:-top-5 before:h-5 before:bg-surface-3"
:class="isStuck ? 'rounded-none' : 'rounded-t-[20px]'"
>
<div class="flex flex-1 items-center gap-3">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected && !allSelected"
@update:model-value="$emit('toggle-all')"
/>
<button
class="flex appearance-none items-center gap-1.5 bg-transparent text-contrast hover:text-brand"
@click="$emit('sort', 'name')"
>
<span>Name</span>
<ChevronUpIcon
v-if="sortField === 'name' && !sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'name' && sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
</div>
<div class="flex shrink-0 items-center gap-4 md:gap-12">
<button
class="hidden w-[100px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
@click="$emit('sort', 'size')"
>
<span class="ml-2">Size</span>
<ChevronUpIcon
v-if="sortField === 'size' && !sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'size' && sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<button
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
@click="$emit('sort', 'created')"
>
<span class="ml-2">Created</span>
<ChevronUpIcon
v-if="sortField === 'created' && !sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'created' && sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<button
class="hidden w-[160px] appearance-none items-center justify-start gap-1 bg-transparent text-primary hover:text-brand md:flex"
@click="$emit('sort', 'modified')"
>
<span class="ml-2">Modified</span>
<ChevronUpIcon
v-if="sortField === 'modified' && !sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
<ChevronDownIcon
v-if="sortField === 'modified' && sortDesc"
class="h-4 w-4"
aria-hidden="true"
/>
</button>
<span class="w-[51px] text-right text-primary">Actions</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { Checkbox } from '@modrinth/ui'
defineProps<{
sortField: string
sortDesc: boolean
allSelected: boolean
someSelected: boolean
isStuck: boolean
}>()
defineEmits<{
sort: [field: string]
'toggle-all': []
}>()
</script>

View File

@@ -1,40 +0,0 @@
<template>
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
<FileIcon class="size-28" />
<div class="flex flex-col gap-2">
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
<p class="m-0 text-sm text-secondary">
{{ message }}
</p>
<div class="flex gap-2">
<ButtonStyled>
<button size="sm" @click="$emit('refetch')">
<RefreshCwIcon class="h-5 w-5" />
Try again
</button>
</ButtonStyled>
<ButtonStyled>
<button size="sm" @click="$emit('home')">
<HomeIcon class="h-5 w-5" />
Go to home folder
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FileIcon, HomeIcon, RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
defineProps<{
title: string
message: string
}>()
defineEmits<{
refetch: []
home: []
}>()
</script>

View File

@@ -1,86 +0,0 @@
<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)"
:write-disabled="writeDisabled"
:write-disabled-tooltip="writeDisabledTooltip"
@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 { toRef } from 'vue'
import { useVirtualScroll } from '../../../../composables/virtual-scroll'
import FileItem from './FileItem.vue'
const props = defineProps<{
items: Kyros.Files.v0.DirectoryItem[]
selectedItems: Set<string>
writeDisabled?: boolean
writeDisabledTooltip?: 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 { listContainer, totalHeight, visibleRange, visibleTop, visibleItems } = useVirtualScroll(
toRef(props, 'items'),
{
itemHeight: 61,
bufferSize: 5,
onNearEnd: () => emit('loadMore'),
},
)
</script>

View File

@@ -1,5 +0,0 @@
export { default as FileItem } from './FileItem.vue'
export { default as FileLabelBar } from './FileLabelBar.vue'
export { default as FileManagerError } from './FileManagerError.vue'
export { default as FileVirtualList } from './FileVirtualList.vue'
export { default as TeleportOverflowMenu } from './TeleportOverflowMenu.vue'

View File

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

View File

@@ -1,93 +0,0 @@
<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>
<StyledInput
ref="createInput"
v-model="itemName"
:placeholder="`e.g. ${type === 'file' ? 'config.yml' : 'plugins'}`"
wrapper-class="w-full"
/>
<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, StyledInput } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const props = defineProps<{
type: 'file' | 'directory'
}>()
const emit = defineEmits<{
create: [name: string]
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const displayType = computed(() => (props.type === 'directory' ? 'folder' : props.type))
const createInput = ref<HTMLInputElement | null>(null)
const itemName = ref('')
const submitted = ref(false)
const error = computed(() => {
if (!itemName.value) {
return 'Name is required.'
}
if (props.type === 'file') {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
}
}
return ''
})
const handleSubmit = () => {
submitted.value = true
if (!error.value) {
emit('create', itemName.value)
hide()
}
}
const show = () => {
itemName.value = ''
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
createInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,78 +0,0 @@
<template>
<NewModal ref="modal" fade="danger" :header="`Deleting ${item?.type}`">
<form class="flex flex-col gap-4 md:w-[600px]" @submit.prevent="handleSubmit">
<div
class="relative flex w-full items-center gap-2 rounded-2xl border border-solid border-brand-red bg-bg-red p-6 shadow-md"
>
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-highlight-red p-[6px] group-hover:bg-brand-highlight group-hover:text-brand"
>
<FolderOpenIcon v-if="item?.type === 'directory'" class="h-5 w-5" />
<FileIcon v-else-if="item?.type === 'file'" class="h-5 w-5" />
</div>
<div class="flex flex-col">
<span class="font-bold group-hover:text-contrast">{{ item?.name }}</span>
<span
v-if="item?.type === 'directory'"
class="text-xs text-secondary group-hover:text-primary"
>
{{ item?.count }} items
</span>
<span v-else class="text-xs text-secondary group-hover:text-primary">
{{ ((item?.size ?? 0) / 1024 / 1024).toFixed(2) }} MB
</span>
</div>
</div>
<div class="flex justify-start gap-4">
<ButtonStyled color="red">
<button type="submit">
<TrashIcon class="h-5 w-5" />
Delete {{ item?.type }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon class="h-5 w-5" />
Cancel
</button>
</ButtonStyled>
</div>
</form>
</NewModal>
</template>
<script setup lang="ts">
import { FileIcon, FolderOpenIcon, TrashIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, NewModal } from '@modrinth/ui'
import { ref } from 'vue'
defineProps<{
item: {
name: string
type: string
count?: number
size?: number
} | null
}>()
const emit = defineEmits<{
delete: []
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const handleSubmit = () => {
emit('delete')
hide()
}
const show = () => {
modal.value?.show()
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,79 +0,0 @@
<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">
<StyledInput
ref="destinationInput"
v-model="destination"
placeholder="e.g. /mods/modname"
wrapper-class="w-full"
/>
</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, StyledInput } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const destinationInput = ref<HTMLInputElement | null>(null)
const props = defineProps<{
item: { name: string } | null
currentPath: string
}>()
const emit = defineEmits<{
move: [destination: string]
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const destination = ref('')
const newpath = computed(() => {
const path = destination.value.replace('//', '/')
return path.startsWith('/') ? path : `/${path}`
})
const handleSubmit = () => {
emit('move', newpath.value)
hide()
}
const show = () => {
destination.value = props.currentPath
modal.value?.show()
nextTick(() => {
setTimeout(() => {
destinationInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,87 +0,0 @@
<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>
<StyledInput ref="renameInput" v-model="itemName" wrapper-class="w-full" />
<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, StyledInput } from '@modrinth/ui'
import { computed, nextTick, ref } from 'vue'
const props = defineProps<{
item: { name: string; type: string } | null
}>()
const emit = defineEmits<{
rename: [newName: string]
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const renameInput = ref<HTMLInputElement | null>(null)
const itemName = ref('')
const submitted = ref(false)
const error = computed(() => {
if (!itemName.value) {
return 'Name is required.'
}
if (props.item?.type === 'file') {
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.'
}
} else {
const validPattern = /^[a-zA-Z0-9-_\s]+$/
if (!validPattern.test(itemName.value)) {
return 'Name must contain only alphanumeric characters, dashes, underscores, or spaces.'
}
}
return ''
})
const handleSubmit = () => {
submitted.value = true
if (!error.value) {
emit('rename', itemName.value)
hide()
}
}
const show = (item: { name: string; type: string }) => {
itemName.value = item.name
submitted.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
renameInput.value?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,56 +0,0 @@
<template>
<ConfirmModal
ref="modal"
title="Do you want to overwrite these conflicting files?"
:proceed-label="`Overwrite`"
:proceed-icon="CheckIcon"
@proceed="proceed"
>
<div class="flex max-w-[30rem] flex-col gap-4">
<p class="m-0 font-semibold leading-normal">
<template v-if="hasMany">
Over 100 files will be overwritten if you proceed with extraction; here is just some of
them:
</template>
<template v-else>
The following {{ files.length }} files already exist on your server, and will be
overwritten if you proceed with extraction:
</template>
</p>
<ul class="m-0 max-h-80 list-none overflow-auto rounded-2xl bg-bg px-4 py-3">
<li v-for="file in files" :key="file" class="flex items-center gap-1 py-1 font-medium">
<XIcon class="shrink-0 text-red" /> {{ file }}
</li>
</ul>
</div>
</ConfirmModal>
</template>
<script setup lang="ts">
import { CheckIcon, XIcon } from '@modrinth/assets'
import { ConfirmModal } from '@modrinth/ui'
import { computed, ref } from 'vue'
const path = ref('')
const files = ref<string[]>([])
const emit = defineEmits<{
proceed: [path: string]
}>()
const modal = ref<InstanceType<typeof ConfirmModal>>()
const hasMany = computed(() => files.value.length > 100)
const show = (zipPath: string, conflictingFiles: string[]) => {
path.value = zipPath
files.value = conflictingFiles
modal.value?.show()
}
const proceed = () => {
emit('proceed', path.value)
}
defineExpose({ show })
</script>

View File

@@ -1,219 +0,0 @@
<template>
<NewModal
ref="modal"
:header="cf ? `Installing a CurseForge modpack` : `Uploading .zip contents from URL`"
>
<form class="flex flex-col gap-5 md:w-[620px]" @submit.prevent="handleSubmit">
<!-- CurseForge stepper cards -->
<div v-if="cf" class="flex flex-col gap-2 w-full">
<div class="grid gap-2 sm:grid-cols-3">
<div
v-for="(step, i) in steps"
:key="i"
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-surface-4 p-4"
>
<div class="flex items-center gap-2">
<span
class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-brand-highlight text-xs font-bold text-brand"
>
{{ i + 1 }}
</span>
</div>
<div class="text-sm font-semibold leading-snug text-contrast">
{{ step.title }}
</div>
<div class="text-xs leading-relaxed text-secondary">
{{ step.description }}
</div>
<a
v-if="step.link"
:href="step.link"
target="_blank"
rel="noopener noreferrer"
class="mt-auto inline-flex items-center gap-1 text-xs font-semibold text-[#F16436] transition-all hover:underline"
>
Browse CurseForge
<ExternalIcon class="h-3 w-3" />
</a>
</div>
</div>
</div>
<!-- URL input -->
<div class="flex flex-col gap-2">
<div v-if="!cf" class="text-sm text-secondary">
Copy and paste the direct download URL of a .zip file.
</div>
<StyledInput
v-model="url"
:icon="LinkIcon"
type="url"
:placeholder="
cf
? 'https://www.curseforge.com/minecraft/modpacks/.../files/6412259'
: 'https://www.example.com/.../modpack-name-1.0.2.zip'
"
:disabled="submitted"
:error="touched && !!error"
autocomplete="off"
@focus="touched = true"
/>
<div v-if="touched && error" class="text-xs text-red">{{ error }}</div>
</div>
<!-- Backup warning -->
<Admonition type="warning">
You may want to
<AutoLink
:to="`/hosting/manage/${serverId}/backups`"
class="font-semibold text-orange hover:underline"
>create a backup</AutoLink
>
before proceeding, as this process is irreversible and may permanently alter your world or
the files on your server.
</Admonition>
</form>
<template #actions>
<div class="flex gap-2 justify-start">
<ButtonStyled color="brand">
<button
v-tooltip="error"
:disabled="submitted || !!error"
type="submit"
@click="handleSubmit"
>
<SpinnerIcon v-if="submitted" class="animate-spin" />
<DownloadIcon v-else />
{{ submitted ? 'Installing...' : 'Install' }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" @click="hide">
<XIcon />
{{ submitted ? 'Close' : 'Cancel' }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
DownloadIcon,
ExternalIcon,
FileTextIcon,
LinkIcon,
SearchIcon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
import { computed, nextTick, ref } from 'vue'
import {
injectModrinthClient,
injectModrinthServerContext,
injectNotificationManager,
} from '../../../../providers'
import Admonition from '../../../base/Admonition.vue'
import AutoLink from '../../../base/AutoLink.vue'
import ButtonStyled from '../../../base/ButtonStyled.vue'
import StyledInput from '../../../base/StyledInput.vue'
import NewModal from '../../../modal/NewModal.vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
const { serverId } = injectModrinthServerContext()
const steps = [
{
icon: SearchIcon,
title: 'Find the modpack',
description: 'Browse CurseForge and locate the modpack you want.',
link: 'https://www.curseforge.com/minecraft/search?page=1&pageSize=40&sortBy=relevancy&class=modpacks',
},
{
icon: FileTextIcon,
title: 'Select a version',
description: 'Go to the "Files" tab and pick the version to install.',
},
{
icon: LinkIcon,
title: 'Copy the URL',
description: 'Copy the version page URL and paste it below.',
},
]
const cf = ref(false)
const modal = ref<InstanceType<typeof NewModal>>()
const url = ref('')
const submitted = ref(false)
const touched = ref(false)
const trimmedUrl = computed(() => url.value.trim())
const regex = /https:\/\/(www\.)?curseforge\.com\/minecraft\/modpacks\/[^/]+\/files\/\d+/
const error = computed(() => {
if (trimmedUrl.value.length === 0) {
return 'URL is required.'
}
if (cf.value && !regex.test(trimmedUrl.value)) {
return 'URL must be a CurseForge modpack version URL.'
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
return 'URL must be valid.'
}
return ''
})
const handleSubmit = async () => {
touched.value = true
if (error.value) return
submitted.value = true
try {
const dry = await client.kyros.files_v0.extractFile(trimmedUrl.value, true, true)
if (!cf.value || dry.modpack_name) {
await client.kyros.files_v0.extractFile(trimmedUrl.value, true, false)
hide()
} else {
submitted.value = false
addNotification({
title: 'CurseForge modpack not found',
text: `Could not find CurseForge modpack at that URL.`,
type: 'error',
})
}
} catch (err) {
submitted.value = false
console.error('Error installing:', err)
addNotification({
title: 'Installation failed',
text: err instanceof Error ? err.message : 'An unknown error occurred',
type: 'error',
})
}
}
const show = (isCf: boolean) => {
cf.value = isCf
url.value = ''
submitted.value = false
touched.value = false
modal.value?.show()
nextTick(() => {
setTimeout(() => {
modal.value?.$el?.querySelector('input')?.focus()
}, 100)
})
}
const hide = () => {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -1,6 +0,0 @@
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'
export { default as FileUploadZipUrlModal } from './FileUploadZipUrlModal.vue'

View File

@@ -1,75 +0,0 @@
<template>
<div
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<slot />
<div
v-if="isDragging"
:class="[
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black/60 text-contrast shadow',
overlayClass,
]"
>
<div class="text-center">
<UploadIcon class="mx-auto h-16 w-16 shadow-2xl" />
<p class="mt-2 text-xl">
Drop {{ type ? type.toLocaleLowerCase() : 'file' }}s here to upload
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { UploadIcon } from '@modrinth/assets'
import { ref } from 'vue'
const emit = defineEmits<{
filesDropped: [files: File[]]
}>()
defineProps<{
overlayClass?: string
type?: string
}>()
const isDragging = ref(false)
const dragCounter = ref(0)
const handleDragEnter = (event: DragEvent) => {
event.preventDefault()
if (!event.dataTransfer?.types.includes('application/modrinth-file-move')) {
dragCounter.value++
isDragging.value = true
}
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
dragCounter.value--
if (dragCounter.value === 0) {
isDragging.value = false
}
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragging.value = false
dragCounter.value = 0
const isInternalMove = event.dataTransfer?.types.includes('application/modrinth-file-move')
if (isInternalMove) return
const files = event.dataTransfer?.files
if (files) {
emit('filesDropped', Array.from(files))
}
}
</script>

View File

@@ -1,333 +0,0 @@
<template>
<div>
<Transition name="upload-status" @enter="onUploadStatusEnter" @leave="onUploadStatusLeave">
<div v-show="isUploading" ref="uploadStatusRef" class="upload-status">
<div
ref="statusContentRef"
v-bind="$attrs"
:class="['flex flex-col p-4 text-sm text-contrast']"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2 font-bold">
<FolderOpenIcon class="size-4" />
<span>
<span class="capitalize">
{{ props.fileType ? props.fileType : 'File' }} uploads
</span>
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}</span>
</span>
</div>
</div>
<div class="mt-2 space-y-2">
<div
v-for="item in uploadQueue"
:key="item.file.name"
class="flex h-6 items-center justify-between gap-2 text-xs"
>
<div class="flex flex-1 items-center gap-2 truncate">
<transition-group name="status-icon" mode="out-in">
<SpinnerIcon
v-show="item.status === 'uploading'"
key="spinner"
class="absolute !size-4 animate-spin"
/>
<CheckCircleIcon
v-show="item.status === 'completed'"
key="check"
class="absolute size-4 text-green"
/>
<XCircleIcon
v-show="
item.status.includes('error') ||
item.status === 'cancelled' ||
item.status === 'incorrect-type'
"
key="error"
class="absolute size-4 text-red"
/>
</transition-group>
<span class="ml-6 truncate">{{ item.file.name }}</span>
<span class="text-secondary">{{ item.size }}</span>
</div>
<div class="flex min-w-[80px] items-center justify-end gap-2">
<template v-if="item.status === 'completed'">
<span>Done</span>
</template>
<template v-else-if="item.status === 'error-file-exists'">
<span class="text-red">Failed - File already exists</span>
</template>
<template v-else-if="item.status === 'error-generic'">
<span class="text-red"
>Failed - {{ item.error?.message || 'An unexpected error occured.' }}</span
>
</template>
<template v-else-if="item.status === 'incorrect-type'">
<span class="text-red">Failed - Incorrect file type</span>
</template>
<template v-else>
<template v-if="item.status === 'uploading'">
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
<button>Cancel</button>
</ButtonStyled>
</template>
<template v-else-if="item.status === 'cancelled'">
<span class="text-red">Cancelled</span>
</template>
<template v-else>
<span>{{ item.progress }}%</span>
<div class="h-1 w-20 overflow-hidden rounded-full bg-bg">
<div
class="h-full bg-contrast transition-all duration-200"
:style="{ width: item.progress + '%' }"
/>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
import { CheckCircleIcon, FolderOpenIcon, SpinnerIcon, XCircleIcon } from '@modrinth/assets'
import { ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { computed, nextTick, ref, watch } from 'vue'
const { addNotification } = injectNotificationManager()
const client = injectModrinthClient()
interface UploadItem {
file: File
progress: number
status:
| 'pending'
| 'uploading'
| 'completed'
| 'error-file-exists'
| 'error-generic'
| 'cancelled'
| 'incorrect-type'
size: string
uploader?: ReturnType<typeof client.kyros.files_v0.uploadFile>
error?: Error
}
interface Props {
currentPath: string
fileType?: string
marginBottom?: number
acceptedTypes?: Array<string>
}
defineOptions({
inheritAttrs: false,
})
const props = defineProps<Props>()
const emit = defineEmits<{
uploadComplete: []
}>()
const uploadStatusRef = ref<HTMLElement | null>(null)
const statusContentRef = ref<HTMLElement | null>(null)
const uploadQueue = ref<UploadItem[]>([])
const isUploading = computed(() => uploadQueue.value.length > 0)
const activeUploads = computed(() =>
uploadQueue.value.filter((item) => item.status === 'pending' || item.status === 'uploading'),
)
const onUploadStatusEnter = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
;(el as HTMLElement).style.height = '0'
void (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.height = `${height}px`
}
const onUploadStatusLeave = (el: Element) => {
const height = (el as HTMLElement).scrollHeight + (props.marginBottom || 0)
;(el as HTMLElement).style.height = `${height}px`
void (el as HTMLElement).offsetHeight
;(el as HTMLElement).style.height = '0'
}
watch(
uploadQueue,
() => {
if (!uploadStatusRef.value) return
const el = uploadStatusRef.value
const itemsHeight = uploadQueue.value.length * 32
const headerHeight = 12
const gap = 8
const padding = 32
const totalHeight = padding + headerHeight + gap + itemsHeight + (props.marginBottom || 0)
el.style.height = `${totalHeight}px`
},
{ deep: true },
)
const formatFileSize = (bytes: number): string => {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + ' KB'
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + ' MB'
return (bytes / 1024 ** 3).toFixed(1) + ' GB'
}
const cancelUpload = (item: UploadItem) => {
if (item.uploader && item.status === 'uploading') {
item.uploader.cancel()
item.status = 'cancelled'
setTimeout(async () => {
const index = uploadQueue.value.findIndex((qItem) => qItem.file.name === item.file.name)
if (index !== -1) {
uploadQueue.value.splice(index, 1)
await nextTick()
}
}, 5000)
}
}
const badFileTypeMsg = 'Upload had incorrect file type'
const uploadFile = async (file: File) => {
const uploadItem: UploadItem = {
file,
progress: 0,
status: 'pending',
size: formatFileSize(file.size),
}
uploadQueue.value.push(uploadItem)
try {
if (
props.acceptedTypes &&
!props.acceptedTypes.includes(file.type) &&
!props.acceptedTypes.some((type) => file.name.endsWith(type))
) {
throw new Error(badFileTypeMsg)
}
uploadItem.status = 'uploading'
const filePath = `${props.currentPath}/${file.name}`.replace('//', '/')
const uploader = client.kyros.files_v0.uploadFile(filePath, file, {
onProgress: ({ progress }) => {
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1) {
uploadQueue.value[index].progress = Math.round(progress)
}
},
})
uploadItem.uploader = uploader
await uploader.promise
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
uploadQueue.value[index].status = 'completed'
uploadQueue.value[index].progress = 100
}
await nextTick()
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1)
await nextTick()
}
}, 5000)
emit('uploadComplete')
} catch (error) {
console.error('Error uploading file:', error)
const index = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (index !== -1 && uploadQueue.value[index].status !== 'cancelled') {
const target = uploadQueue.value[index]
if (error instanceof Error) {
if (error.message === badFileTypeMsg) {
target.status = 'incorrect-type'
} else if (target.progress === 100 && error.message.includes('401')) {
target.status = 'error-file-exists'
} else {
target.status = 'error-generic'
target.error = error
}
}
}
setTimeout(async () => {
const removeIndex = uploadQueue.value.findIndex((item) => item.file.name === file.name)
if (removeIndex !== -1) {
uploadQueue.value.splice(removeIndex, 1)
await nextTick()
}
}, 5000)
if (error instanceof Error && error.message !== 'Upload cancelled') {
addNotification({
title: 'Upload failed',
text: `Failed to upload ${file.name}`,
type: 'error',
})
}
}
}
defineExpose({
uploadFile,
cancelUpload,
})
</script>
<style scoped>
.upload-status {
overflow: hidden;
transition: height 0.2s ease;
}
.upload-status-enter-active,
.upload-status-leave-active {
transition: height 0.2s ease;
overflow: hidden;
}
.upload-status-enter-from,
.upload-status-leave-to {
height: 0 !important;
}
.status-icon-enter-active,
.status-icon-leave-active {
transition: all 0.25s ease;
}
.status-icon-enter-from,
.status-icon-leave-to {
transform: scale(0);
opacity: 0;
}
.status-icon-enter-to,
.status-icon-leave-from {
transform: scale(1);
opacity: 1;
}
</style>

View File

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

View File

@@ -1,5 +1,4 @@
export * from './backups'
export * from './files'
export * from './flows'
export * from './icons'
export { default as InstallingBanner } from './InstallingBanner.vue'

View File

@@ -1,6 +1,6 @@
<template>
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-4 truncate">
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div v-tooltip="'Change server loader'" class="flex min-w-0 flex-row items-center gap-2 truncate">
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex flex-row items-center gap-2">
<LoaderIcon v-if="loader" :loader="loader" class="flex shrink-0 [&&]:size-5" />
<div v-else class="size-5 shrink-0 animate-pulse rounded-full bg-button-border"></div>

View File

@@ -2,9 +2,9 @@
<div
v-if="subdomain && !isHidden"
v-tooltip="'Copy custom URL'"
class="flex min-w-0 flex-row items-center gap-4 truncate hover:cursor-pointer"
class="flex min-w-0 flex-row items-center gap-2 truncate hover:cursor-pointer"
>
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex flex-row items-center gap-2">
<LinkIcon class="flex size-5 shrink-0" />
<div

View File

@@ -2,10 +2,10 @@
<div
v-if="uptimeSeconds || uptimeSeconds !== 0"
v-tooltip="`Online for ${verboseUptime}`"
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-4"
class="server-action-buttons-anim flex min-w-0 flex-row items-center gap-2"
data-pyro-uptime
>
<div v-if="!noSeparator" class="experimental-styles-within h-6 w-0.5 bg-button-border"></div>
<div v-if="!noSeparator" class="w-1.5 h-1.5 rounded-full bg-surface-5"></div>
<div class="flex gap-2">
<TimerIcon class="flex size-5 shrink-0" />

View File

@@ -75,7 +75,7 @@
:show-game-label="showGameLabel"
:show-loader-label="showLoaderLabel"
:linked="false"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-4 text-secondary *:hidden sm:flex-row sm:*:flex"
class="pointer-events-none flex w-full flex-row flex-wrap items-center gap-2 text-secondary *:hidden sm:flex-row sm:*:flex"
/>
</div>
</AutoLink>