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:
@@ -68,6 +68,10 @@ CSS custom properties are defined in `packages/assets/styles/variables.scss` wit
|
||||
|
||||
**Color palette** (each with shades 50–950): red, orange, green, blue, purple, gray. Platform-specific colors also exist (fabric, forge, quilt, neoforge, etc.).
|
||||
|
||||
## Storybook
|
||||
|
||||
When modifying a component in `src/components/`, you must also update its corresponding Storybook story in `src/stories/` to reflect the changes. If a story file doesn't exist yet, create one. Stories should cover the component's key states and variants.
|
||||
|
||||
## Dependency Injection
|
||||
|
||||
This package defines the DI layer using `createContext` from `src/providers/index.ts`. See the `dependency-injection` skill (`.claude/skills/dependency-injection/SKILL.md`) for full documentation.
|
||||
|
||||
@@ -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" />
|
||||
|
||||
318
packages/ui/src/components/base/NavTabs.vue
Normal file
318
packages/ui/src/components/base/NavTabs.vue
Normal 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>
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = ''
|
||||
|
||||
106
packages/ui/src/components/modal/ConfirmLeaveModal.vue
Normal file
106
packages/ui/src/components/modal/ConfirmLeaveModal.vue
Normal 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>
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as FileEditor } from './FileEditor.vue'
|
||||
export { default as FileImageViewer } from './FileImageViewer.vue'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -1,5 +0,0 @@
|
||||
export * from './editor'
|
||||
export * from './explorer'
|
||||
export { default as FileNavbar } from './FileNavbar.vue'
|
||||
export * from './modals'
|
||||
export * from './upload'
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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'
|
||||
@@ -1,2 +0,0 @@
|
||||
export { default as FileUploadDragAndDrop } from './FileUploadDragAndDrop.vue'
|
||||
export { default as FileUploadDropdown } from './FileUploadDropdown.vue'
|
||||
@@ -1,5 +1,4 @@
|
||||
export * from './backups'
|
||||
export * from './files'
|
||||
export * from './flows'
|
||||
export * from './icons'
|
||||
export { default as InstallingBanner } from './InstallingBanner.vue'
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@ export * from './format-number'
|
||||
export * from './how-ago'
|
||||
export * from './i18n'
|
||||
export * from './i18n-debug'
|
||||
export * from './page-leave-safety'
|
||||
export * from './scroll-indicator'
|
||||
export * from './sticky-observer'
|
||||
export * from './virtual-scroll'
|
||||
|
||||
38
packages/ui/src/composables/page-leave-safety.ts
Normal file
38
packages/ui/src/composables/page-leave-safety.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave } from 'vue-router'
|
||||
|
||||
import type ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
|
||||
export function usePageLeaveSafety(dirty: Ref<boolean> | ComputedRef<boolean>) {
|
||||
const confirmLeaveModal = ref<InstanceType<typeof ConfirmLeaveModal>>()
|
||||
|
||||
function handleBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (dirty.value) {
|
||||
e.preventDefault()
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
watch(dirty, (isDirty) => {
|
||||
if (isDirty) {
|
||||
window.addEventListener('beforeunload', handleBeforeUnload)
|
||||
} else {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (dirty.value) {
|
||||
return (await confirmLeaveModal.value?.prompt()) ?? false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return { confirmLeaveModal }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { computed, ref, watch, watchEffect } from 'vue'
|
||||
|
||||
export interface VirtualScrollOptions {
|
||||
itemHeight: number
|
||||
@@ -16,6 +16,7 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
const scrollContainer = ref<HTMLElement | Window | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const viewportHeight = ref(0)
|
||||
const containerOffset = ref(0)
|
||||
|
||||
const totalHeight = computed(() => items.value.length * itemHeight)
|
||||
|
||||
@@ -41,13 +42,25 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
return container instanceof Window ? window.innerHeight : container.clientHeight
|
||||
}
|
||||
|
||||
function getContainerOffset(listEl: HTMLElement, container: HTMLElement | Window): number {
|
||||
function updateContainerOffset() {
|
||||
const listEl = listContainer.value
|
||||
const container = scrollContainer.value
|
||||
if (!listEl || !container) return
|
||||
|
||||
if (container instanceof Window) {
|
||||
return listEl.getBoundingClientRect().top + window.scrollY
|
||||
containerOffset.value = listEl.getBoundingClientRect().top + window.scrollY
|
||||
} else {
|
||||
const listRect = listEl.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
containerOffset.value = listRect.top - containerRect.top + container.scrollTop
|
||||
}
|
||||
const listRect = listEl.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
return listRect.top - containerRect.top + container.scrollTop
|
||||
}
|
||||
|
||||
function syncScrollState() {
|
||||
if (!scrollContainer.value) return
|
||||
scrollTop.value = getScrollTop(scrollContainer.value)
|
||||
viewportHeight.value = getViewportHeight(scrollContainer.value)
|
||||
updateContainerOffset()
|
||||
}
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
@@ -57,15 +70,17 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
|
||||
if (!listContainer.value || !scrollContainer.value) return { start: 0, end: 0 }
|
||||
|
||||
const containerOffset = getContainerOffset(listContainer.value, scrollContainer.value)
|
||||
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset)
|
||||
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset.value)
|
||||
|
||||
const start = Math.floor(relativeScrollTop / itemHeight)
|
||||
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
|
||||
|
||||
const rangeStart = Math.max(0, start - bufferSize)
|
||||
const rangeEnd = Math.min(items.value.length, start + visibleCount + bufferSize * 2)
|
||||
|
||||
return {
|
||||
start: Math.max(0, start - bufferSize),
|
||||
end: Math.min(items.value.length, start + visibleCount + bufferSize * 2),
|
||||
start: Math.min(rangeStart, rangeEnd),
|
||||
end: rangeEnd,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -91,16 +106,20 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
function handleScroll() {
|
||||
if (scrollContainer.value) {
|
||||
scrollTop.value = getScrollTop(scrollContainer.value)
|
||||
updateContainerOffset()
|
||||
}
|
||||
checkNearEnd()
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (scrollContainer.value) {
|
||||
viewportHeight.value = getViewportHeight(scrollContainer.value)
|
||||
}
|
||||
syncScrollState()
|
||||
}
|
||||
|
||||
// Re-sync scroll state when items change to avoid stale scrollTop/offset
|
||||
watch(items, () => {
|
||||
syncScrollState()
|
||||
})
|
||||
|
||||
watchEffect((onCleanup) => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
@@ -109,15 +128,24 @@ export function useVirtualScroll<T>(items: Ref<T[]>, options: VirtualScrollOptio
|
||||
|
||||
const container = findScrollableAncestor(listEl)
|
||||
scrollContainer.value = container
|
||||
viewportHeight.value = getViewportHeight(container)
|
||||
scrollTop.value = getScrollTop(container)
|
||||
syncScrollState()
|
||||
|
||||
container.addEventListener('scroll', handleScroll, { passive: true })
|
||||
window.addEventListener('resize', handleResize, { passive: true })
|
||||
|
||||
// Use ResizeObserver for element scroll containers
|
||||
let resizeObserver: ResizeObserver | undefined
|
||||
if (!(container instanceof Window)) {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
syncScrollState()
|
||||
})
|
||||
resizeObserver.observe(container)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
window.removeEventListener('resize', handleResize)
|
||||
resizeObserver?.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './shared/content-tab'
|
||||
export * from './shared/files-tab'
|
||||
export * from './shared/installation-settings'
|
||||
export * from './wrapped'
|
||||
|
||||
@@ -19,8 +19,8 @@ import BulletDivider from '#ui/components/base/BulletDivider.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import Toggle from '#ui/components/base/Toggle.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/servers/files/explorer/TeleportOverflowMenu.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { truncatedTooltip } from '#ui/utils/truncate'
|
||||
@@ -81,6 +81,8 @@ const hasSwitchVersionListener = computed(
|
||||
const versionNumberRef = ref<HTMLElement | null>(null)
|
||||
const fileNameRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const isDisabled = computed(() => props.disabled || props.installing)
|
||||
|
||||
const { shift: shiftHeld } = useMagicKeys()
|
||||
const deleteHovered = ref(false)
|
||||
</script>
|
||||
@@ -94,7 +96,7 @@ const deleteHovered = ref(false)
|
||||
<div
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
@@ -252,7 +254,7 @@ const deleteHovered = ref(false)
|
||||
>
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.updateAvailableLabel)"
|
||||
:disabled="disabled"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('update')"
|
||||
>
|
||||
<DownloadIcon class="size-5" />
|
||||
@@ -261,7 +263,7 @@ const deleteHovered = ref(false)
|
||||
<ButtonStyled v-else-if="hasSwitchVersionListener && version" circular type="transparent">
|
||||
<button
|
||||
v-tooltip="formatMessage(commonMessages.switchVersionButton)"
|
||||
:disabled="disabled"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('switchVersion')"
|
||||
>
|
||||
<ArrowLeftRightIcon class="size-5" />
|
||||
@@ -272,7 +274,7 @@ const deleteHovered = ref(false)
|
||||
<Toggle
|
||||
v-if="enabled !== undefined"
|
||||
:model-value="enabled"
|
||||
:disabled="disabled"
|
||||
:disabled="isDisabled"
|
||||
:aria-label="project.title"
|
||||
class="my-auto"
|
||||
@update:model-value="(val) => emit('update:enabled', val as boolean)"
|
||||
@@ -287,7 +289,7 @@ const deleteHovered = ref(false)
|
||||
: commonMessages.deleteLabel,
|
||||
)
|
||||
"
|
||||
:disabled="disabled"
|
||||
:disabled="isDisabled"
|
||||
@click="emit('delete', $event)"
|
||||
@mouseenter="deleteHovered = true"
|
||||
@mouseleave="deleteHovered = false"
|
||||
@@ -311,7 +313,7 @@ const deleteHovered = ref(false)
|
||||
<TeleportOverflowMenu
|
||||
v-if="overflowOptions?.length"
|
||||
:options="overflowOptions"
|
||||
:disabled="disabled"
|
||||
:disabled="isDisabled"
|
||||
>
|
||||
<MoreVerticalIcon class="size-5" />
|
||||
</TeleportOverflowMenu>
|
||||
|
||||
@@ -192,9 +192,7 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
role="row"
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
hasAnyActions
|
||||
? 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1'
|
||||
hasAnyActions ? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none' : 'flex-1'
|
||||
"
|
||||
>
|
||||
<Checkbox
|
||||
@@ -299,7 +297,9 @@ function handleSort(column: ContentCardTableSortColumn) {
|
||||
@update:enabled="(val) => emit('update:enabled', item.id, val)"
|
||||
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
|
||||
@update="emit('update', item.id)"
|
||||
@switch-version="emit('switchVersion', item.id)"
|
||||
v-on="
|
||||
hasSwitchVersionListener ? { switchVersion: () => emit('switchVersion', item.id) } : {}
|
||||
"
|
||||
>
|
||||
<template #additionalButtonsLeft>
|
||||
<slot name="itemButtonsLeft" :item="item" :index="visibleRange.start + idx" />
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
DownloadIcon,
|
||||
HeartIcon,
|
||||
MoreVerticalIcon,
|
||||
SettingsIcon,
|
||||
Settings2Icon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
@@ -20,8 +20,8 @@ import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import OverflowMenu, {
|
||||
type Option as OverflowMenuOption,
|
||||
} from '#ui/components/base/OverflowMenu.vue'
|
||||
import TagItem from '#ui/components/base/TagItem.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/servers/files/explorer/TeleportOverflowMenu.vue'
|
||||
import TagTagItem from '#ui/components/base/TagTagItem.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import { useRelativeTime } from '#ui/composables/how-ago'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
@@ -36,10 +36,6 @@ import type {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
updating: {
|
||||
id: 'content.modpack-card.updating',
|
||||
defaultMessage: 'Updating...',
|
||||
},
|
||||
contentHintTitle: {
|
||||
id: 'content.modpack-card.content-hint-title',
|
||||
defaultMessage: 'Modpack content moved',
|
||||
@@ -195,7 +191,7 @@ onUnmounted(() => {
|
||||
<div class="flex items-center gap-2 text-secondary">
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
<span class="font-semibold">{{
|
||||
disabledText ?? formatMessage(messages.updating)
|
||||
disabledText ?? formatMessage(commonMessages.updatingLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -268,7 +264,7 @@ onUnmounted(() => {
|
||||
}
|
||||
"
|
||||
>
|
||||
<SettingsIcon />
|
||||
<Settings2Icon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -305,7 +301,7 @@ onUnmounted(() => {
|
||||
{{ formatMessage(commonMessages.contentLabel) }}
|
||||
</template>
|
||||
<template #settings>
|
||||
<SettingsIcon class="size-5" />
|
||||
<Settings2Icon class="size-5" />
|
||||
{{ formatMessage(commonMessages.settingsLabel) }}
|
||||
</template>
|
||||
</TeleportOverflowMenu></ButtonStyled
|
||||
@@ -362,9 +358,13 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="categories?.length" class="flex flex-wrap items-center gap-1">
|
||||
<TagItem v-for="cat in categories" :key="cat.name" :action="cat.action">
|
||||
{{ cat.name }}
|
||||
</TagItem>
|
||||
<TagTagItem
|
||||
v-for="cat in categories"
|
||||
:key="cat.name"
|
||||
:tag="cat.name"
|
||||
:action="cat.action"
|
||||
hide-non-loader-icon
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,14 +21,6 @@ const messages = defineMessages({
|
||||
id: 'content.selection-bar.selected-count-simple',
|
||||
defaultMessage: '{count, number} selected',
|
||||
},
|
||||
enable: {
|
||||
id: 'content.selection-bar.enable',
|
||||
defaultMessage: 'Enable',
|
||||
},
|
||||
disable: {
|
||||
id: 'content.selection-bar.disable',
|
||||
defaultMessage: 'Disable',
|
||||
},
|
||||
bulkEnabling: {
|
||||
id: 'content.selection-bar.bulk.enabling',
|
||||
defaultMessage: 'Enabling {progress}/{total} {contentType}...',
|
||||
@@ -162,13 +154,15 @@ const bulkProgressMessage = computed(() => {
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
v-tooltip="
|
||||
allEnabled ? formatMessage(messages.allAlreadyEnabled) : formatMessage(messages.enable)
|
||||
allEnabled
|
||||
? formatMessage(messages.allAlreadyEnabled)
|
||||
: formatMessage(commonMessages.enableButton)
|
||||
"
|
||||
:disabled="isBusy || allEnabled"
|
||||
@click="emit('enable')"
|
||||
>
|
||||
<PowerIcon />
|
||||
<span class="bar-label">{{ formatMessage(messages.enable) }}</span>
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.enableButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
@@ -176,13 +170,13 @@ const bulkProgressMessage = computed(() => {
|
||||
v-tooltip="
|
||||
allDisabled
|
||||
? formatMessage(messages.allAlreadyDisabled)
|
||||
: formatMessage(messages.disable)
|
||||
: formatMessage(commonMessages.disableButton)
|
||||
"
|
||||
:disabled="isBusy || allDisabled"
|
||||
@click="emit('disable')"
|
||||
>
|
||||
<PowerOffIcon />
|
||||
<span class="bar-label">{{ formatMessage(messages.disable) }}</span>
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.disableButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.leavePageTitle)"
|
||||
fade="warning"
|
||||
max-width="500px"
|
||||
>
|
||||
<div class="flex flex-col gap-6">
|
||||
<Admonition type="critical" :header="formatMessage(messages.uploadInProgress)">
|
||||
{{ formatMessage(messages.leavePageBody) }}
|
||||
</Admonition>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="cancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.stayOnPageButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="leave">
|
||||
<RightArrowIcon />
|
||||
{{ formatMessage(messages.leavePageButton) }}
|
||||
</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 NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
leavePageTitle: {
|
||||
id: 'instances.confirm-leave-modal.title',
|
||||
defaultMessage: 'Leave page?',
|
||||
},
|
||||
uploadInProgress: {
|
||||
id: 'instances.confirm-leave-modal.upload-in-progress',
|
||||
defaultMessage: 'Upload in progress',
|
||||
},
|
||||
leavePageBody: {
|
||||
id: 'instances.confirm-leave-modal.body',
|
||||
defaultMessage:
|
||||
'Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost.',
|
||||
},
|
||||
stayOnPageButton: {
|
||||
id: 'instances.confirm-leave-modal.stay',
|
||||
defaultMessage: 'Stay on page',
|
||||
},
|
||||
leavePageButton: {
|
||||
id: 'instances.confirm-leave-modal.leave',
|
||||
defaultMessage: 'Leave page',
|
||||
},
|
||||
})
|
||||
|
||||
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>
|
||||
@@ -13,7 +13,7 @@
|
||||
formatMessage(messages.admonitionHeader, { action: downgrade ? 'downgrade' : 'update' })
|
||||
"
|
||||
>
|
||||
{{ formatMessage(messages.admonitionBody) }}
|
||||
{{ formatMessage(server ? messages.admonitionBody : messages.admonitionBodyApp) }}
|
||||
</Admonition>
|
||||
<InlineBackupCreator
|
||||
ref="backupCreator"
|
||||
@@ -83,6 +83,10 @@ const messages = defineMessages({
|
||||
id: 'content.confirm-modpack-update.admonition-body',
|
||||
defaultMessage: 'Any mods or content you added on top of the modpack will be deleted.',
|
||||
},
|
||||
admonitionBodyApp: {
|
||||
id: 'content.confirm-modpack-update.admonition-body-app',
|
||||
defaultMessage: 'Any mods or content you added on top of the modpack will be preserved.',
|
||||
},
|
||||
confirmButton: {
|
||||
id: 'content.confirm-modpack-update.confirm-button',
|
||||
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} modpack',
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<button @click="emit('install', inst)">
|
||||
{{
|
||||
inst.installing
|
||||
? formatMessage(messages.installingLabel)
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: formatMessage(messages.installButton)
|
||||
}}
|
||||
</button>
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<span class="font-semibold text-contrast">
|
||||
{{ formatMessage(messages.gameVersionLabel) }}
|
||||
{{ formatMessage(commonMessages.gameVersionLabel) }}
|
||||
</span>
|
||||
<Combobox
|
||||
v-model="selectedGameVersion"
|
||||
@@ -195,8 +195,8 @@
|
||||
<EyeIcon v-else class="size-4" />
|
||||
{{
|
||||
showSnapshots
|
||||
? formatMessage(messages.hideSnapshots)
|
||||
: formatMessage(messages.showAllVersions)
|
||||
? formatMessage(commonMessages.hideSnapshotsButton)
|
||||
: formatMessage(commonMessages.showAllVersionsButton)
|
||||
}}
|
||||
</button>
|
||||
</template>
|
||||
@@ -291,10 +291,6 @@ const messages = defineMessages({
|
||||
id: 'instances.content-install.installed-badge',
|
||||
defaultMessage: 'Installed',
|
||||
},
|
||||
installingLabel: {
|
||||
id: 'instances.content-install.installing-label',
|
||||
defaultMessage: 'Installing...',
|
||||
},
|
||||
installButton: {
|
||||
id: 'instances.content-install.install-button',
|
||||
defaultMessage: 'Install',
|
||||
@@ -319,10 +315,6 @@ const messages = defineMessages({
|
||||
id: 'instances.content-install.loader-label',
|
||||
defaultMessage: 'Loader',
|
||||
},
|
||||
gameVersionLabel: {
|
||||
id: 'instances.content-install.game-version-label',
|
||||
defaultMessage: 'Game version',
|
||||
},
|
||||
gameVersionPlaceholder: {
|
||||
id: 'instances.content-install.game-version-placeholder',
|
||||
defaultMessage: 'Select game version',
|
||||
@@ -335,14 +327,6 @@ const messages = defineMessages({
|
||||
id: 'instances.content-install.no-instances',
|
||||
defaultMessage: 'No compatible instances found',
|
||||
},
|
||||
showAllVersions: {
|
||||
id: 'instances.content-install.show-all-versions',
|
||||
defaultMessage: 'Show all versions',
|
||||
},
|
||||
hideSnapshots: {
|
||||
id: 'instances.content-install.hide-snapshots',
|
||||
defaultMessage: 'Hide snapshots',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ContentInstallInstance {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<span class="text-lg font-extrabold text-contrast">{{
|
||||
header ??
|
||||
formatMessage(
|
||||
isModpack.value
|
||||
isModpack
|
||||
? messages.switchModpackVersionHeader
|
||||
: switchMode
|
||||
? messages.switchVersionHeader
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowLeftRightIcon,
|
||||
BoxIcon,
|
||||
FilterIcon,
|
||||
GlassesIcon,
|
||||
@@ -7,7 +8,6 @@ import {
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { formatProjectType } from '@modrinth/utils'
|
||||
import Fuse from 'fuse.js'
|
||||
import { computed, nextTick, ref, watchSyncEffect } from 'vue'
|
||||
|
||||
@@ -18,7 +18,12 @@ import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowM
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import {
|
||||
commonMessages,
|
||||
commonProjectTypeCategoryMessages,
|
||||
commonProjectTypeTitleMessages,
|
||||
normalizeProjectType,
|
||||
} from '#ui/utils/common-messages'
|
||||
|
||||
import { isClientOnlyEnvironment } from '../../composables/content-filtering'
|
||||
import type { ContentCardTableItem, ContentItem } from '../../types'
|
||||
@@ -32,6 +37,7 @@ interface Props {
|
||||
modpackIconUrl?: string
|
||||
enableToggle?: boolean
|
||||
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
|
||||
switchVersion?: (item: ContentItem) => void
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -39,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
modpackIconUrl: undefined,
|
||||
enableToggle: false,
|
||||
getOverflowOptions: undefined,
|
||||
switchVersion: undefined,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -72,14 +79,6 @@ const messages = defineMessages({
|
||||
id: 'instances.modpack-content-modal.no-results',
|
||||
defaultMessage: 'No projects match your search.',
|
||||
},
|
||||
allFilter: {
|
||||
id: 'instances.modpack-content-modal.filter-all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
copyLink: {
|
||||
id: 'instances.modpack-content-modal.copy-link',
|
||||
defaultMessage: 'Copy link',
|
||||
},
|
||||
})
|
||||
|
||||
export interface ModpackContentModalState {
|
||||
@@ -133,25 +132,36 @@ watchSyncEffect(() => fuse.setCollection(items.value))
|
||||
const filterOptions = computed(() => {
|
||||
const frequency = items.value.reduce(
|
||||
(map, item) => {
|
||||
map[item.project_type] = (map[item.project_type] || 0) + 1
|
||||
const normalized = normalizeProjectType(item.project_type)
|
||||
map[normalized] = (map[normalized] || 0) + 1
|
||||
return map
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
)
|
||||
|
||||
// Sort by frequency (most common first)
|
||||
return Object.entries(frequency)
|
||||
const options = Object.entries(frequency)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.map(([type]) => ({
|
||||
id: type,
|
||||
label: formatProjectType(type) + 's',
|
||||
}))
|
||||
.map(([type]) => {
|
||||
const msg =
|
||||
commonProjectTypeCategoryMessages[type as keyof typeof commonProjectTypeCategoryMessages]
|
||||
return {
|
||||
id: type,
|
||||
label: msg ? formatMessage(msg) : type.charAt(0).toUpperCase() + type.slice(1) + 's',
|
||||
}
|
||||
})
|
||||
|
||||
if (items.value.some((item) => !item.enabled)) {
|
||||
options.push({ id: 'disabled', label: 'Disabled' })
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
const stats = computed(() => {
|
||||
const counts: Record<string, number> = {}
|
||||
for (const item of items.value) {
|
||||
counts[item.project_type] = (counts[item.project_type] || 0) + 1
|
||||
const normalized = normalizeProjectType(item.project_type)
|
||||
counts[normalized] = (counts[normalized] || 0) + 1
|
||||
}
|
||||
return counts
|
||||
})
|
||||
@@ -165,9 +175,18 @@ function toggleFilter(filterId: string) {
|
||||
}
|
||||
}
|
||||
|
||||
const attributeFilterIds = new Set(['disabled'])
|
||||
|
||||
const typeFilteredCount = computed(() => {
|
||||
if (selectedFilters.value.length === 0) return items.value.length
|
||||
return items.value.filter((item) => selectedFilters.value.includes(item.project_type)).length
|
||||
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
|
||||
const hasDisabledFilter = selectedFilters.value.includes('disabled')
|
||||
return items.value.filter((item) => {
|
||||
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
|
||||
return false
|
||||
if (hasDisabledFilter && item.enabled) return false
|
||||
return true
|
||||
}).length
|
||||
})
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
@@ -184,9 +203,15 @@ const filteredItems = computed(() => {
|
||||
})
|
||||
}
|
||||
|
||||
// Apply type filters
|
||||
if (selectedFilters.value.length > 0) {
|
||||
result = result.filter((item) => selectedFilters.value.includes(item.project_type))
|
||||
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
|
||||
const hasDisabledFilter = selectedFilters.value.includes('disabled')
|
||||
result = result.filter((item) => {
|
||||
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
|
||||
return false
|
||||
if (hasDisabledFilter && item.enabled) return false
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
@@ -216,7 +241,18 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
|
||||
...(props.enableToggle ? { enabled: item.enabled } : {}),
|
||||
isClientOnly: isClientOnlyEnvironment(item.environment),
|
||||
disabled: disabledIds.value.has(item.file_name),
|
||||
overflowOptions: props.getOverflowOptions?.(item),
|
||||
overflowOptions: [
|
||||
...(props.switchVersion
|
||||
? [
|
||||
{
|
||||
id: formatMessage(commonMessages.switchVersionButton),
|
||||
icon: ArrowLeftRightIcon,
|
||||
action: () => props.switchVersion!(item),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(props.getOverflowOptions?.(item) ?? []),
|
||||
],
|
||||
})),
|
||||
)
|
||||
|
||||
@@ -344,7 +380,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
|
||||
/>
|
||||
|
||||
<!-- Filters -->
|
||||
<div v-if="filterOptions.length > 1" class="flex items-center gap-2">
|
||||
<div v-if="filterOptions.length > 0" class="flex items-center gap-2">
|
||||
<FilterIcon class="size-5 text-secondary shrink-0" />
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
@@ -357,7 +393,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
|
||||
"
|
||||
@click="selectedFilters = []"
|
||||
>
|
||||
{{ formatMessage(messages.allFilter) }}
|
||||
{{ formatMessage(commonMessages.allProjectType) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="option in filterOptions"
|
||||
@@ -416,7 +452,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
|
||||
class="flex min-w-0 items-center gap-4"
|
||||
:class="
|
||||
props.enableToggle
|
||||
? 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
|
||||
: 'flex-1'
|
||||
"
|
||||
>
|
||||
@@ -434,7 +470,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
|
||||
</div>
|
||||
<div
|
||||
class="hidden @[800px]:flex"
|
||||
:class="props.enableToggle ? 'w-[335px] min-w-0' : 'flex-1'"
|
||||
:class="props.enableToggle ? 'flex-1 min-w-0' : 'flex-1'"
|
||||
>
|
||||
<span class="font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.versionLabel)
|
||||
@@ -475,7 +511,17 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
|
||||
<div class="flex items-center gap-1.5">
|
||||
<component :is="getTypeIcon(type as string)" class="size-5 text-secondary" />
|
||||
<span class="font-medium text-primary">
|
||||
{{ count }} {{ formatProjectType(type as string) }}{{ count !== 1 ? 's' : '' }}
|
||||
{{ count }}
|
||||
{{
|
||||
formatMessage(
|
||||
commonProjectTypeTitleMessages[
|
||||
normalizeProjectType(
|
||||
type as string,
|
||||
) as keyof typeof commonProjectTypeTitleMessages
|
||||
] ?? commonProjectTypeTitleMessages.project,
|
||||
{ count },
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -2,6 +2,9 @@ import { useSessionStorage } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { commonProjectTypeCategoryMessages, normalizeProjectType } from '#ui/utils/common-messages'
|
||||
|
||||
import type { ContentItem } from '../types'
|
||||
|
||||
const CLIENT_ONLY_ENVIRONMENTS = new Set(['client_only', 'singleplayer_only'])
|
||||
@@ -20,11 +23,12 @@ export interface ContentFilterConfig {
|
||||
showUpdateFilter?: boolean
|
||||
showClientOnlyFilter?: boolean
|
||||
isPackLocked?: Ref<boolean>
|
||||
formatProjectType?: (type: string) => string
|
||||
persistKey?: string
|
||||
}
|
||||
|
||||
export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFilterConfig) {
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const selectedFilters = config?.persistKey
|
||||
? useSessionStorage<string[]>(`content-filters:${config.persistKey}`, [])
|
||||
: ref<string[]>([])
|
||||
@@ -34,12 +38,15 @@ export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFil
|
||||
|
||||
if (config?.showTypeFilters) {
|
||||
const frequency = items.value.reduce((map: Record<string, number>, item) => {
|
||||
map[item.project_type] = (map[item.project_type] || 0) + 1
|
||||
const normalized = normalizeProjectType(item.project_type)
|
||||
map[normalized] = (map[normalized] || 0) + 1
|
||||
return map
|
||||
}, {})
|
||||
const types = Object.keys(frequency).sort((a, b) => frequency[b] - frequency[a])
|
||||
for (const type of types) {
|
||||
const label = config.formatProjectType ? config.formatProjectType(type) + 's' : type + 's'
|
||||
const msg =
|
||||
commonProjectTypeCategoryMessages[type as keyof typeof commonProjectTypeCategoryMessages]
|
||||
const label = msg ? formatMessage(msg) : type.charAt(0).toUpperCase() + type.slice(1) + 's'
|
||||
options.push({ id: type, label })
|
||||
}
|
||||
}
|
||||
@@ -89,7 +96,10 @@ export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFil
|
||||
const activeAttributes = selectedFilters.value.filter((f) => attributeFilters.has(f))
|
||||
|
||||
return source.filter((item) => {
|
||||
if (typeFilters.length > 0 && !typeFilters.includes(item.project_type)) {
|
||||
if (
|
||||
typeFilters.length > 0 &&
|
||||
!typeFilters.includes(normalizeProjectType(item.project_type))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ export { default as ContentCardTable } from './components/ContentCardTable.vue'
|
||||
export { default as ContentModpackCard } from './components/ContentModpackCard.vue'
|
||||
export { default as ConfirmBulkUpdateModal } from './components/modals/ConfirmBulkUpdateModal.vue'
|
||||
export { default as ConfirmDeletionModal } from './components/modals/ConfirmDeletionModal.vue'
|
||||
export { default as ConfirmLeaveModal } from './components/modals/ConfirmLeaveModal.vue'
|
||||
export { default as ConfirmModpackUpdateModal } from './components/modals/ConfirmModpackUpdateModal.vue'
|
||||
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
|
||||
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
|
||||
@@ -22,3 +21,4 @@ export { default as ContentCardLayout } from './layout.vue'
|
||||
export { default as ContentPageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export * from './types'
|
||||
export { default as ConfirmLeaveModal } from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { formatBytes, formatProjectType } from '@modrinth/utils'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
@@ -29,6 +29,7 @@ import EmptyState from '#ui/components/base/EmptyState.vue'
|
||||
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
|
||||
import ProgressBar from '#ui/components/base/ProgressBar.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { useDebugLogger } from '#ui/composables/debug-logger'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
@@ -50,6 +51,7 @@ import { injectContentManager } from './providers/content-manager'
|
||||
import type { ContentCardTableItem, ContentItem } from './types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const debug = useDebugLogger('ContentPageLayout')
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingContent: {
|
||||
@@ -228,7 +230,6 @@ const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useConten
|
||||
showUpdateFilter: ctx.hasUpdateSupport,
|
||||
showClientOnlyFilter: ctx.showClientOnlyFilter ?? false,
|
||||
isPackLocked: ctx.isPackLocked,
|
||||
formatProjectType,
|
||||
persistKey: ctx.filterPersistKey,
|
||||
},
|
||||
)
|
||||
@@ -267,7 +268,7 @@ const filteredItems = computed(() => {
|
||||
return applyFilters(searched)
|
||||
})
|
||||
const tableItems = computed<ContentCardTableItem[]>(() => {
|
||||
return filteredItems.value.map((item) => {
|
||||
const items = filteredItems.value.map((item) => {
|
||||
const base = ctx.mapToTableItem(item)
|
||||
return {
|
||||
...base,
|
||||
@@ -282,9 +283,35 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
|
||||
overflowOptions: ctx.getOverflowOptions?.(item),
|
||||
}
|
||||
})
|
||||
|
||||
const updatable = items.filter((i) => i.hasUpdate)
|
||||
if (updatable.length > 0) {
|
||||
debug('tableItems: items with hasUpdate=true', {
|
||||
count: updatable.length,
|
||||
ids: updatable.map((i) => i.id),
|
||||
isPackLocked: ctx.isPackLocked.value,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const hasOutdatedProjects = computed(() => ctx.items.value.some((p) => p.has_update))
|
||||
const hasOutdatedProjects = computed(() => {
|
||||
const outdated = ctx.items.value.filter((p) => p.has_update)
|
||||
if (outdated.length > 0) {
|
||||
debug('hasOutdatedProjects: raw items with has_update=true', {
|
||||
count: outdated.length,
|
||||
items: outdated.map((p) => ({
|
||||
id: p.id,
|
||||
fileName: p.file_name,
|
||||
title: p.project?.title,
|
||||
has_update: p.has_update,
|
||||
update_version_id: p.update_version_id,
|
||||
})),
|
||||
})
|
||||
}
|
||||
return outdated.length > 0
|
||||
})
|
||||
|
||||
// Deletion
|
||||
const pendingDeletionItems = ref<ContentItem[]>([])
|
||||
@@ -877,7 +904,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
|
||||
:count="pendingDeletionItems.length"
|
||||
:item-type="ctx.contentTypeLabel.value"
|
||||
:variant="ctx.deletionContext ?? 'instance'"
|
||||
:backup-tip="pendingDeletionItems.map((i) => i.project.title).join(', ')"
|
||||
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
|
||||
@delete="confirmDelete"
|
||||
/>
|
||||
<ConfirmBulkUpdateModal
|
||||
|
||||
@@ -25,15 +25,7 @@ export interface ContentModpackData {
|
||||
disabledText?: string
|
||||
}
|
||||
|
||||
export interface UploadState {
|
||||
isUploading: boolean
|
||||
currentFileName: string | null
|
||||
currentFileProgress: number
|
||||
uploadedBytes: number
|
||||
totalBytes: number
|
||||
completedFiles: number
|
||||
totalFiles: number
|
||||
}
|
||||
export type { UploadState } from '@modrinth/api-client'
|
||||
|
||||
export interface ContentManagerContext {
|
||||
// Data
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<Teleport to="#teleports">
|
||||
<Transition
|
||||
enter-active-class="transition duration-125 ease-out"
|
||||
enter-from-class="transform scale-75 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-125 ease-in"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-75 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="visible"
|
||||
ref="menuRef"
|
||||
class="experimental-styles-within fixed isolate z-[9999] flex w-fit min-w-[180px] flex-col gap-2 overflow-hidden rounded-2xl border border-solid border-surface-5 bg-bg-raised p-2 shadow-lg"
|
||||
:style="{ left: `${position.x}px`, top: `${position.y}px` }"
|
||||
role="menu"
|
||||
tabindex="-1"
|
||||
@mousedown.stop
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleCopyFilename"
|
||||
>
|
||||
<ClipboardCopyIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleCopyPath"
|
||||
>
|
||||
<ClipboardCopyIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="ctx.openInFolder" type="transparent">
|
||||
<button
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleOpenInFolder"
|
||||
>
|
||||
<FolderOpenIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.openInFolderButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="h-px w-full bg-surface-5" />
|
||||
<template v-for="(option, index) in menuOptions" :key="index">
|
||||
<div
|
||||
v-if="'divider' in option && option.divider && option.shown !== false"
|
||||
class="h-px w-full bg-surface-5"
|
||||
/>
|
||||
<ButtonStyled
|
||||
v-else-if="'id' in option && option.shown !== false"
|
||||
type="transparent"
|
||||
:color="option.color"
|
||||
>
|
||||
<button
|
||||
v-tooltip="option.tooltip"
|
||||
:disabled="option.disabled"
|
||||
class="w-full !justify-start !whitespace-nowrap"
|
||||
role="menuitem"
|
||||
@click="handleOptionClick(option)"
|
||||
>
|
||||
<slot :name="option.id" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ClipboardCopyIcon, FolderOpenIcon } from '@modrinth/assets'
|
||||
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
import type { FileContextMenuOption, FileItem } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const visible = ref(false)
|
||||
const menuRef = ref<HTMLElement>()
|
||||
const position = ref({ x: 0, y: 0 })
|
||||
const currentItem = ref<FileItem | null>(null)
|
||||
const menuOptions = ref<FileContextMenuOption[]>([])
|
||||
|
||||
function show(item: FileItem, x: number, y: number, options: typeof menuOptions.value) {
|
||||
currentItem.value = item
|
||||
menuOptions.value = options
|
||||
position.value = { x, y }
|
||||
visible.value = true
|
||||
|
||||
nextTick(() => {
|
||||
if (!menuRef.value) return
|
||||
const rect = menuRef.value.getBoundingClientRect()
|
||||
const padding = 10
|
||||
if (rect.right > window.innerWidth - padding) {
|
||||
position.value.x = Math.max(padding, x - rect.width)
|
||||
}
|
||||
if (rect.bottom > window.innerHeight - padding) {
|
||||
position.value.y = Math.max(padding, y - rect.height)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function hide() {
|
||||
visible.value = false
|
||||
currentItem.value = null
|
||||
}
|
||||
|
||||
function handleCopyFilename() {
|
||||
if (!currentItem.value) return
|
||||
navigator.clipboard.writeText(currentItem.value.name)
|
||||
addNotification({ title: formatMessage(commonMessages.copiedFilenameLabel), type: 'success' })
|
||||
hide()
|
||||
}
|
||||
|
||||
function getFullPath() {
|
||||
if (!currentItem.value) return ''
|
||||
const basePath = ctx.basePath?.value
|
||||
const itemPath = currentItem.value.path
|
||||
return basePath ? `${basePath}/${itemPath}`.replace(/\/+/g, '/') : itemPath
|
||||
}
|
||||
|
||||
function handleCopyPath() {
|
||||
if (!currentItem.value) return
|
||||
navigator.clipboard.writeText(getFullPath())
|
||||
addNotification({ title: formatMessage(commonMessages.copiedPathLabel), type: 'success' })
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleOpenInFolder() {
|
||||
if (!currentItem.value) return
|
||||
ctx.openInFolder?.(getFullPath())
|
||||
hide()
|
||||
}
|
||||
|
||||
function handleOptionClick(option: { action?: () => void }) {
|
||||
option.action?.()
|
||||
hide()
|
||||
}
|
||||
|
||||
function onClickOutside(event: MouseEvent) {
|
||||
if (menuRef.value && !menuRef.value.contains(event.target as Node)) {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
function onEscape(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('mousedown', onClickOutside)
|
||||
document.addEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('mousedown', onClickOutside)
|
||||
document.removeEventListener('keydown', onEscape)
|
||||
})
|
||||
|
||||
watch(visible, (v) => {
|
||||
if (!v) currentItem.value = null
|
||||
})
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -10,13 +10,13 @@
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('refetch')">
|
||||
<RefreshCwIcon class="h-5 w-5" />
|
||||
Try again
|
||||
{{ formatMessage(messages.tryAgain) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled>
|
||||
<button size="sm" @click="$emit('home')">
|
||||
<HomeIcon class="h-5 w-5" />
|
||||
Go to home folder
|
||||
{{ formatMessage(messages.goToHome) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@@ -26,7 +26,22 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { FileIcon, HomeIcon, RefreshCwIcon } from '@modrinth/assets'
|
||||
import { ButtonStyled } from '@modrinth/ui'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
tryAgain: {
|
||||
id: 'files.error.try-again',
|
||||
defaultMessage: 'Try again',
|
||||
},
|
||||
goToHome: {
|
||||
id: 'files.error.go-to-home',
|
||||
defaultMessage: 'Go to home folder',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<header
|
||||
class="@container flex select-none flex-col gap-4"
|
||||
:aria-label="formatMessage(messages.fileNavigation)"
|
||||
>
|
||||
<div v-if="!isEditing" class="flex items-center gap-2 @[800px]:hidden">
|
||||
<StyledInput
|
||||
:model-value="searchQuery"
|
||||
:icon="SearchIcon"
|
||||
type="search"
|
||||
name="search"
|
||||
autocomplete="off"
|
||||
:placeholder="formatMessage(messages.searchFiles)"
|
||||
class="!h-10"
|
||||
input-class="!h-10"
|
||||
wrapper-class="flex-1 min-w-0"
|
||||
@update:model-value="$emit('update:searchQuery', $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<nav
|
||||
:aria-label="formatMessage(messages.breadcrumbNavigation)"
|
||||
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="formatMessage(messages.backToHome)"
|
||||
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">{{ formatMessage(messages.home) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li class="m-0 -ml-2 min-w-0 flex-shrink p-0">
|
||||
<ol
|
||||
ref="breadcrumbOuter"
|
||||
class="m-0 flex min-w-0 flex-shrink items-center overflow-hidden p-0"
|
||||
:class="{ 'breadcrumb-fade-mask': isBreadcrumbOverflowing }"
|
||||
:style="
|
||||
isBreadcrumbOverflowing
|
||||
? { '--scroll-distance': `-${breadcrumbOverflowAmount}px` }
|
||||
: undefined
|
||||
"
|
||||
@mouseenter="onBreadcrumbMouseEnter"
|
||||
@mouseleave="onBreadcrumbMouseLeave"
|
||||
>
|
||||
<TransitionGroup
|
||||
ref="breadcrumbInner"
|
||||
name="breadcrumb"
|
||||
tag="span"
|
||||
class="relative flex w-fit items-center"
|
||||
:class="{ 'breadcrumbs-scroll': isBreadcrumbAnimating }"
|
||||
@animationiteration="onBreadcrumbAnimationIteration"
|
||||
>
|
||||
<li
|
||||
v-for="(segment, index) in breadcrumbs"
|
||||
:key="`${segment || index}-group`"
|
||||
class="relative flex shrink-0 items-center text-sm"
|
||||
>
|
||||
<div class="flex shrink-0 items-center">
|
||||
<ButtonStyled type="transparent">
|
||||
<button
|
||||
class="cursor-pointer whitespace-nowrap 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-base">
|
||||
<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="formatMessage(messages.searchFiles)"
|
||||
class="!h-10 hidden @[800px]:inline-flex"
|
||||
input-class="!h-10"
|
||||
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"
|
||||
:disabled="refreshing"
|
||||
@click="handleRefresh"
|
||||
>
|
||||
<RefreshCwIcon
|
||||
aria-hidden="true"
|
||||
class="h-5 w-5 transition-transform"
|
||||
:class="refreshing ? 'animate-spin' : ''"
|
||||
/>
|
||||
{{ formatMessage(commonMessages.refreshButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<ButtonStyled type="outlined">
|
||||
<OverflowMenu
|
||||
:dropdown-id="`create-new-${baseId}`"
|
||||
position="bottom"
|
||||
direction="left"
|
||||
:aria-label="formatMessage(messages.createNew)"
|
||||
: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, shown: showInstallFromUrl ?? false },
|
||||
{ id: 'upload-zip', shown: false, action: () => $emit('uploadZip') },
|
||||
{
|
||||
id: 'install-from-url',
|
||||
shown: showInstallFromUrl ?? false,
|
||||
action: () => $emit('unzipFromUrl', false),
|
||||
},
|
||||
{
|
||||
id: 'install-cf-pack',
|
||||
shown: showInstallFromUrl ?? false,
|
||||
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" /> {{ formatMessage(messages.newFile) }}
|
||||
</template>
|
||||
<template #directory>
|
||||
<FolderOpenIcon aria-hidden="true" /> {{ formatMessage(messages.newFolder) }}
|
||||
</template>
|
||||
<template #upload>
|
||||
<UploadIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFile) }}
|
||||
</template>
|
||||
<template #upload-zip>
|
||||
<FileArchiveIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFromZip) }}
|
||||
</template>
|
||||
<template #install-from-url>
|
||||
<LinkIcon aria-hidden="true" /> {{ formatMessage(messages.uploadFromZipUrl) }}
|
||||
</template>
|
||||
<template #install-cf-pack>
|
||||
<CurseForgeIcon aria-hidden="true" />
|
||||
{{ formatMessage(messages.installCurseForgePack) }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isEditingImage && isLogFile" class="flex gap-2">
|
||||
<Button
|
||||
v-tooltip="formatMessage(messages.shareToMclogs)"
|
||||
icon-only
|
||||
transparent
|
||||
:aria-label="formatMessage(messages.shareToMclogs)"
|
||||
@click="$emit('share')"
|
||||
>
|
||||
<ShareIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
ChevronRightIcon,
|
||||
CurseForgeIcon,
|
||||
DropdownIcon,
|
||||
FileArchiveIcon,
|
||||
FolderOpenIcon,
|
||||
HomeIcon,
|
||||
LinkIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
ShareIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import Button from '#ui/components/base/Button.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
fileNavigation: {
|
||||
id: 'files.navbar.file-navigation',
|
||||
defaultMessage: 'File navigation',
|
||||
},
|
||||
breadcrumbNavigation: {
|
||||
id: 'files.navbar.breadcrumb-navigation',
|
||||
defaultMessage: 'Breadcrumb navigation',
|
||||
},
|
||||
backToHome: {
|
||||
id: 'files.navbar.back-to-home',
|
||||
defaultMessage: 'Back to home',
|
||||
},
|
||||
home: {
|
||||
id: 'files.navbar.home',
|
||||
defaultMessage: 'Home',
|
||||
},
|
||||
searchFiles: {
|
||||
id: 'files.navbar.search-files',
|
||||
defaultMessage: 'Search files',
|
||||
},
|
||||
createNew: {
|
||||
id: 'files.navbar.create-new',
|
||||
defaultMessage: 'Create new...',
|
||||
},
|
||||
newFile: {
|
||||
id: 'files.navbar.new-file',
|
||||
defaultMessage: 'New file',
|
||||
},
|
||||
newFolder: {
|
||||
id: 'files.navbar.new-folder',
|
||||
defaultMessage: 'New folder',
|
||||
},
|
||||
uploadFile: {
|
||||
id: 'files.navbar.upload-file',
|
||||
defaultMessage: 'Upload file',
|
||||
},
|
||||
uploadFromZip: {
|
||||
id: 'files.navbar.upload-from-zip',
|
||||
defaultMessage: 'Upload from .zip file',
|
||||
},
|
||||
uploadFromZipUrl: {
|
||||
id: 'files.navbar.upload-from-zip-url',
|
||||
defaultMessage: 'Upload from .zip URL',
|
||||
},
|
||||
installCurseForgePack: {
|
||||
id: 'files.navbar.install-curseforge-pack',
|
||||
defaultMessage: 'Install CurseForge pack',
|
||||
},
|
||||
shareToMclogs: {
|
||||
id: 'files.navbar.share-to-mclogs',
|
||||
defaultMessage: 'Share to mclo.gs',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
breadcrumbs: string[]
|
||||
isEditing: boolean
|
||||
editingFileName?: string
|
||||
editingFilePath?: string
|
||||
isEditingImage?: boolean
|
||||
searchQuery: string
|
||||
showRefreshButton?: boolean
|
||||
showInstallFromUrl?: boolean
|
||||
baseId: string
|
||||
disabled?: boolean
|
||||
disabledTooltip?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
navigate: [index: number]
|
||||
navigateHome: []
|
||||
prefetchHome: []
|
||||
'update:searchQuery': [value: string]
|
||||
create: [type: 'file' | 'directory']
|
||||
upload: []
|
||||
uploadZip: []
|
||||
unzipFromUrl: [cf: boolean]
|
||||
refresh: []
|
||||
share: []
|
||||
}>()
|
||||
|
||||
const refreshing = ref(false)
|
||||
|
||||
function handleRefresh() {
|
||||
emit('refresh')
|
||||
refreshing.value = true
|
||||
setTimeout(() => {
|
||||
refreshing.value = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const breadcrumbOuter = ref<HTMLElement | null>(null)
|
||||
const breadcrumbInner = ref<{ $el: HTMLElement } | null>(null)
|
||||
const isBreadcrumbOverflowing = ref(false)
|
||||
const isBreadcrumbAnimating = ref(false)
|
||||
const breadcrumbOverflowAmount = ref(0)
|
||||
|
||||
let bcHovered = false
|
||||
let bcStopping = false
|
||||
|
||||
function checkBreadcrumbOverflow() {
|
||||
const inner = breadcrumbInner.value?.$el
|
||||
if (!breadcrumbOuter.value || !inner) return
|
||||
const overflow = inner.scrollWidth - breadcrumbOuter.value.clientWidth
|
||||
isBreadcrumbOverflowing.value = overflow > 0
|
||||
breadcrumbOverflowAmount.value = overflow + 12
|
||||
}
|
||||
|
||||
function onBreadcrumbMouseEnter() {
|
||||
bcHovered = true
|
||||
bcStopping = false
|
||||
if (isBreadcrumbOverflowing.value) {
|
||||
isBreadcrumbAnimating.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function onBreadcrumbMouseLeave() {
|
||||
bcHovered = false
|
||||
if (isBreadcrumbAnimating.value) {
|
||||
bcStopping = true
|
||||
}
|
||||
}
|
||||
|
||||
function onBreadcrumbAnimationIteration() {
|
||||
if (bcStopping && !bcHovered) {
|
||||
isBreadcrumbAnimating.value = false
|
||||
bcStopping = false
|
||||
}
|
||||
}
|
||||
|
||||
let bcResizeObserver: ResizeObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
checkBreadcrumbOverflow()
|
||||
bcResizeObserver = new ResizeObserver(checkBreadcrumbOverflow)
|
||||
if (breadcrumbOuter.value) bcResizeObserver.observe(breadcrumbOuter.value)
|
||||
const innerEl = breadcrumbInner.value?.$el
|
||||
if (innerEl) bcResizeObserver.observe(innerEl)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
bcResizeObserver?.disconnect()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.breadcrumbs,
|
||||
() => {
|
||||
requestAnimationFrame(checkBreadcrumbOverflow)
|
||||
},
|
||||
)
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.breadcrumb-fade-mask {
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
transparent,
|
||||
black 12px,
|
||||
black calc(100% - 12px),
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.breadcrumbs-scroll {
|
||||
animation: breadcrumb-scroll 10s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes breadcrumb-scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
35%,
|
||||
65% {
|
||||
transform: translateX(var(--scroll-distance));
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<Admonition v-if="ctx.uploadState?.value?.isUploading" type="info" class="mb-4">
|
||||
<template #icon>
|
||||
<UploadIcon class="h-6 w-6 flex-none text-brand-blue" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{
|
||||
ctx.uploadingLabel
|
||||
? ctx.uploadingLabel(
|
||||
ctx.uploadState.value.completedFiles,
|
||||
ctx.uploadState.value.totalFiles,
|
||||
)
|
||||
: formatMessage(messages.uploadingFiles, {
|
||||
completed: ctx.uploadState.value.completedFiles,
|
||||
total: ctx.uploadState.value.totalFiles,
|
||||
})
|
||||
}}
|
||||
<span v-if="ctx.uploadState.value.currentFileName" class="font-normal text-secondary">
|
||||
— {{ ctx.uploadState.value.currentFileName }}
|
||||
</span>
|
||||
</template>
|
||||
<span class="text-secondary">
|
||||
{{
|
||||
formatMessage(messages.uploadProgress, {
|
||||
uploaded: formatBytes(ctx.uploadState.value.uploadedBytes),
|
||||
total: formatBytes(ctx.uploadState.value.totalBytes),
|
||||
percent: Math.round(uploadOverallProgress * 100),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<template v-if="ctx.cancelUpload" #top-right-actions>
|
||||
<ButtonStyled type="outlined" color="blue">
|
||||
<button class="!border" @click="ctx.cancelUpload?.()">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #progress>
|
||||
<ProgressBar :progress="uploadOverallProgress" :max="1" color="blue" full-width />
|
||||
</template>
|
||||
</Admonition>
|
||||
</Transition>
|
||||
<TransitionGroup
|
||||
name="fs-op"
|
||||
enter-active-class="transition-all duration-300 ease-out overflow-hidden"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-40"
|
||||
leave-active-class="transition-all duration-200 ease-in overflow-hidden"
|
||||
leave-from-class="opacity-100 max-h-40"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<Admonition
|
||||
v-for="op in activeOperations"
|
||||
:key="`fs-op-${op.op}-${op.src}`"
|
||||
:type="op.state === 'done' ? 'success' : op.state?.startsWith('fail') ? 'critical' : 'info'"
|
||||
class="mb-4"
|
||||
>
|
||||
<template #icon="{ iconClass }">
|
||||
<PackageOpenIcon :class="iconClass" />
|
||||
</template>
|
||||
<template #header>
|
||||
{{
|
||||
formatMessage(messages.extracting, {
|
||||
source: op.src.includes('https://') ? formatMessage(messages.modpackFromUrl) : op.src,
|
||||
})
|
||||
}}
|
||||
<span v-if="op.state === 'done'" class="font-normal text-green">
|
||||
— {{ formatMessage(commonMessages.doneLabel) }}</span
|
||||
>
|
||||
<span v-else-if="op.state?.startsWith('fail')" class="font-normal text-red">
|
||||
— {{ formatMessage(messages.failed) }}</span
|
||||
>
|
||||
</template>
|
||||
<span class="text-secondary">
|
||||
{{
|
||||
formatMessage(messages.extracted, {
|
||||
size: 'bytes_processed' in op ? formatBytes(op.bytes_processed ?? 0) : '0 B',
|
||||
})
|
||||
}}
|
||||
<template v-if="'current_file' in op && op.current_file">
|
||||
— {{ op.current_file?.split('/')?.pop() }}
|
||||
</template>
|
||||
</span>
|
||||
<template v-if="op.id && ctx.dismissOperation" #top-right-actions>
|
||||
<ButtonStyled
|
||||
v-if="op.state !== 'done' && !op.state?.startsWith('fail')"
|
||||
type="outlined"
|
||||
color="blue"
|
||||
>
|
||||
<button class="!border" @click="ctx.dismissOperation?.(op.id!, 'cancel')">
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled
|
||||
v-if="op.state === 'done' || op.state?.startsWith('fail')"
|
||||
circular
|
||||
type="transparent"
|
||||
hover-color-fill="background"
|
||||
:color="op.state === 'done' ? 'green' : 'red'"
|
||||
>
|
||||
<button @click="ctx.dismissOperation?.(op.id!, 'dismiss')">
|
||||
<XIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #progress>
|
||||
<ProgressBar
|
||||
:progress="'progress' in op ? (op.progress ?? 0) : 0"
|
||||
:max="1"
|
||||
:color="op.state === 'done' ? 'green' : op.state?.startsWith('fail') ? 'red' : 'blue'"
|
||||
:waiting="op.state === 'queued' || !op.progress || op.progress === 0"
|
||||
full-width
|
||||
/>
|
||||
</template>
|
||||
</Admonition>
|
||||
</TransitionGroup>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PackageOpenIcon, UploadIcon, XIcon } from '@modrinth/assets'
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import ProgressBar from '#ui/components/base/ProgressBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
uploadingFiles: {
|
||||
id: 'files.operations.uploading-files',
|
||||
defaultMessage: 'Uploading files ({completed}/{total})',
|
||||
},
|
||||
uploadProgress: {
|
||||
id: 'files.operations.upload-progress',
|
||||
defaultMessage: '{uploaded} / {total} ({percent}%)',
|
||||
},
|
||||
extracting: {
|
||||
id: 'files.operations.extracting',
|
||||
defaultMessage: 'Extracting {source}',
|
||||
},
|
||||
modpackFromUrl: {
|
||||
id: 'files.operations.modpack-from-url',
|
||||
defaultMessage: 'modpack from URL',
|
||||
},
|
||||
failed: {
|
||||
id: 'files.operations.failed',
|
||||
defaultMessage: 'Failed',
|
||||
},
|
||||
extracted: {
|
||||
id: 'files.operations.extracted',
|
||||
defaultMessage: '{size} extracted',
|
||||
},
|
||||
})
|
||||
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const activeOperations = computed(() => ctx.activeOperations?.value ?? [])
|
||||
|
||||
const uploadOverallProgress = computed(() => {
|
||||
const state = ctx.uploadState?.value
|
||||
if (!state || !state.isUploading || state.totalFiles === 0) return 0
|
||||
return Math.min((state.completedFiles + state.currentFileProgress) / state.totalFiles, 1)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="sticky top-0 z-10 flex h-12 w-full select-none flex-row items-center justify-between bg-surface-3 pl-3 pr-4 font-medium transition-[border-radius] duration-100"
|
||||
:class="
|
||||
isStuck
|
||||
? 'rounded-none border-0 border-y border-solid border-surface-4 shadow-md before:pointer-events-none before:absolute before:inset-x-0 before:-top-4 before:h-5 before:bg-surface-3'
|
||||
: '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 border-0 bg-transparent p-0 font-semibold hover:text-primary"
|
||||
:class="sortField === 'name' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'name')"
|
||||
>
|
||||
<span>{{ formatMessage(messages.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 @[800px]:gap-12">
|
||||
<button
|
||||
class="hidden w-[100px] appearance-none items-center justify-start gap-1 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'size' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'size')"
|
||||
>
|
||||
<span class="ml-2">{{ formatMessage(messages.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 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'created' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'created')"
|
||||
>
|
||||
<span class="ml-2">{{ formatMessage(messages.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 border-0 bg-transparent p-0 font-semibold hover:text-primary @[800px]:flex"
|
||||
:class="sortField === 'modified' ? 'text-contrast' : 'text-secondary'"
|
||||
@click="$emit('sort', 'modified')"
|
||||
>
|
||||
<span class="ml-2">{{ formatMessage(messages.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="min-w-[51px] shrink-0 text-right font-semibold text-secondary">{{
|
||||
formatMessage(commonMessages.actionsLabel)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
|
||||
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileSortField } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
name: {
|
||||
id: 'files.table-header.name',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
size: {
|
||||
id: 'files.table-header.size',
|
||||
defaultMessage: 'Size',
|
||||
},
|
||||
created: {
|
||||
id: 'files.table-header.created',
|
||||
defaultMessage: 'Created',
|
||||
},
|
||||
modified: {
|
||||
id: 'files.table-header.modified',
|
||||
defaultMessage: 'Modified',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
sortField: FileSortField
|
||||
sortDesc: boolean
|
||||
allSelected: boolean
|
||||
someSelected: boolean
|
||||
isStuck: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
sort: [field: FileSortField]
|
||||
'toggle-all': []
|
||||
}>()
|
||||
</script>
|
||||
@@ -0,0 +1,360 @@
|
||||
<template>
|
||||
<li
|
||||
role="button"
|
||||
:class="[containerClasses, isDragSource ? 'opacity-50' : '']"
|
||||
tabindex="0"
|
||||
:data-file-path="path"
|
||||
:data-file-type="type"
|
||||
@click="selectItem"
|
||||
@contextmenu="openContextMenu"
|
||||
@keydown="(e) => e.key === 'Enter' && selectItem()"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<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 group-hover:text-contrast group-focus:text-contrast"
|
||||
/>
|
||||
</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 @[800px]:gap-12">
|
||||
<span class="hidden w-[100px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedSize }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedCreationDate }}
|
||||
</span>
|
||||
<span class="hidden w-[160px] text-nowrap text-sm text-secondary @[800px]:block">
|
||||
{{ formattedModifiedDate }}
|
||||
</span>
|
||||
<div class="flex min-w-[51px] shrink-0 items-center justify-end">
|
||||
<ButtonStyled circular type="transparent">
|
||||
<TeleportOverflowMenu :options="menuOptions">
|
||||
<MoreHorizontalIcon class="h-5 w-5 bg-transparent" />
|
||||
<template #copy-filename
|
||||
><ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFilenameButton) }}</template
|
||||
>
|
||||
<template #copy-full-path
|
||||
><ClipboardCopyIcon />
|
||||
{{ formatMessage(commonMessages.copyFullPathButton) }}</template
|
||||
>
|
||||
<template #open-in-folder
|
||||
><FolderOpenIcon /> {{ formatMessage(commonMessages.openInFolderButton) }}</template
|
||||
>
|
||||
<template #extract
|
||||
><PackageOpenIcon /> {{ formatMessage(commonMessages.extractButton) }}</template
|
||||
>
|
||||
<template #rename
|
||||
><EditIcon /> {{ formatMessage(commonMessages.renameButton) }}</template
|
||||
>
|
||||
<template #move
|
||||
><RightArrowIcon /> {{ formatMessage(commonMessages.moveButton) }}</template
|
||||
>
|
||||
<template #download
|
||||
><DownloadIcon />
|
||||
{{
|
||||
ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton)
|
||||
}}</template
|
||||
>
|
||||
<template #delete
|
||||
><TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||
>
|
||||
</TeleportOverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
BoxIcon,
|
||||
BracesIcon,
|
||||
ClipboardCopyIcon,
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderCogIcon,
|
||||
FolderOpenIcon,
|
||||
GlassesIcon,
|
||||
GlobeIcon,
|
||||
MoreHorizontalIcon,
|
||||
PackageOpenIcon,
|
||||
PaintbrushIcon,
|
||||
RightArrowIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Checkbox from '#ui/components/base/Checkbox.vue'
|
||||
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
|
||||
import { useFormatDateTime } from '#ui/composables/format-date-time'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { getFileExtensionIcon } from '#ui/utils/auto-icons'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import {
|
||||
getFileExtension,
|
||||
isEditableFile as isEditableFileExt,
|
||||
isImageFile,
|
||||
} from '#ui/utils/file-extensions'
|
||||
|
||||
import {
|
||||
fileDragActive,
|
||||
fileDragData,
|
||||
fileDragTarget,
|
||||
startFileDrag,
|
||||
wasRecentDrag,
|
||||
} from '../composables/file-drag-state'
|
||||
import { injectFileManager } from '../providers/file-manager'
|
||||
import type { FileItem } from '../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
itemCount: {
|
||||
id: 'files.row.item-count',
|
||||
defaultMessage: '{count, plural, one {# item} other {# items}}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<
|
||||
FileItem & {
|
||||
index: number
|
||||
isLast: boolean
|
||||
selected: boolean
|
||||
writeDisabled?: boolean
|
||||
writeDisabledTooltip?: string
|
||||
}
|
||||
>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(
|
||||
e: 'rename' | 'move' | 'download' | 'delete' | 'edit' | 'extract' | 'hover' | 'navigate',
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'>,
|
||||
): void
|
||||
(
|
||||
e: 'moveDirectTo',
|
||||
item: Pick<FileItem, 'name' | 'type' | 'path'> & { destination: string },
|
||||
): void
|
||||
(e: 'contextmenu', x: number, y: number): void
|
||||
(e: 'toggle-select'): void
|
||||
}>()
|
||||
|
||||
const isDropTarget = computed(
|
||||
() => fileDragActive.value && fileDragTarget.value === props.path && props.type === 'directory',
|
||||
)
|
||||
const isDragSource = computed(() => fileDragActive.value && fileDragData.value?.path === props.path)
|
||||
|
||||
const units = Object.freeze(['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'])
|
||||
|
||||
const formatDateTime = useFormatDateTime({
|
||||
year: '2-digit',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
})
|
||||
|
||||
const containerClasses = computed(() => {
|
||||
const dropTarget = isDropTarget.value
|
||||
return [
|
||||
'group m-0 flex w-full select-none items-center justify-between overflow-hidden border-0 border-t border-solid border-surface-4 pl-3 pr-4 py-3 focus:!outline-none',
|
||||
dropTarget
|
||||
? '!bg-brand-highlight'
|
||||
: props.selected
|
||||
? 'bg-surface-2.5'
|
||||
: props.index % 2 === 0
|
||||
? 'bg-surface-2'
|
||||
: 'bg-surface-1.5',
|
||||
props.isLast ? 'rounded-b-[20px]' : '',
|
||||
isEditableFile.value || props.type === 'directory' ? 'cursor-pointer hover:bg-surface-2.5' : '',
|
||||
'transition-colors duration-100 focus:!outline-none',
|
||||
]
|
||||
})
|
||||
|
||||
const fileExtension = computed(() => getFileExtension(props.name))
|
||||
|
||||
const isZip = computed(() => fileExtension.value === 'zip')
|
||||
|
||||
function getFullPath() {
|
||||
const basePath = ctx.basePath?.value
|
||||
return basePath ? `${basePath}/${props.path}`.replace(/\/+/g, '/') : props.path
|
||||
}
|
||||
|
||||
const menuOptions = computed(() => {
|
||||
const item = { name: props.name, type: props.type, path: props.path }
|
||||
const wd = props.writeDisabled
|
||||
const wdTooltip = props.writeDisabledTooltip
|
||||
return [
|
||||
{
|
||||
id: 'copy-filename',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(props.name)
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.copiedFilenameLabel),
|
||||
type: 'success',
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'copy-full-path',
|
||||
icon: ClipboardCopyIcon,
|
||||
action: () => {
|
||||
navigator.clipboard.writeText(getFullPath())
|
||||
addNotification({ title: formatMessage(commonMessages.copiedPathLabel), type: 'success' })
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'open-in-folder',
|
||||
icon: FolderOpenIcon,
|
||||
shown: !!ctx.openInFolder,
|
||||
action: () => ctx.openInFolder?.(getFullPath()),
|
||||
},
|
||||
{ divider: true },
|
||||
{
|
||||
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' || props.name === 'saves') return GlobeIcon
|
||||
if (props.name === 'mods') return BoxIcon
|
||||
if (props.name === 'resourcepacks') return PaintbrushIcon
|
||||
if (props.name === 'shaderpacks') return GlassesIcon
|
||||
if (props.name === 'datapacks') return BracesIcon
|
||||
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 formatMessage(messages.itemCount, { count: props.count ?? 0 })
|
||||
}
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
const isNavigating = ref(false)
|
||||
|
||||
function selectItem() {
|
||||
if (isNavigating.value || wasRecentDrag()) return
|
||||
isNavigating.value = true
|
||||
|
||||
const item = { name: props.name, type: props.type, path: props.path }
|
||||
if (props.type === 'directory') {
|
||||
emit('navigate', item)
|
||||
} else if (props.type === 'file' && isEditableFile.value) {
|
||||
emit('edit', item)
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isNavigating.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
function handlePointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return
|
||||
startFileDrag(
|
||||
{ name: props.name, type: props.type, path: props.path },
|
||||
e,
|
||||
(source, destination) => {
|
||||
emit('moveDirectTo', {
|
||||
name: source.name,
|
||||
type: source.type as FileItem['type'],
|
||||
path: source.path,
|
||||
destination,
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div
|
||||
ref="editorContainer"
|
||||
class="flex flex-col overflow-hidden rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
>
|
||||
<component
|
||||
:is="props.editorComponent"
|
||||
v-if="!isEditingImage && !isLoading && props.editorComponent"
|
||||
v-model:value="fileContent"
|
||||
:lang="editorLanguage"
|
||||
theme="modrinth"
|
||||
:print-margin="false"
|
||||
:style="{ height: editorHeight, fontSize: '0.875rem' }"
|
||||
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 items-center justify-center rounded-[20px] bg-bg-raised"
|
||||
:style="{ height: editorHeight }"
|
||||
>
|
||||
<SpinnerIcon class="h-8 w-8 animate-spin text-secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { type Component, computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { getEditorLanguage, getFileExtension, isImageFile } from '#ui/utils/file-extensions'
|
||||
|
||||
import { injectFileManager } from '../../providers/file-manager'
|
||||
import type { EditingFile } from '../../types'
|
||||
import FileImageViewer from './FileImageViewer.vue'
|
||||
|
||||
interface MclogsResponse {
|
||||
success: boolean
|
||||
url?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
file: EditingFile | null
|
||||
editorComponent: Component | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
failedToOpenTitle: {
|
||||
id: 'files.editor.failed-to-open-title',
|
||||
defaultMessage: 'Failed to open file',
|
||||
},
|
||||
failedToOpenText: {
|
||||
id: 'files.editor.failed-to-open-text',
|
||||
defaultMessage: 'Could not load file contents.',
|
||||
},
|
||||
fileSavedTitle: {
|
||||
id: 'files.editor.file-saved-title',
|
||||
defaultMessage: 'File saved',
|
||||
},
|
||||
fileSavedText: {
|
||||
id: 'files.editor.file-saved-text',
|
||||
defaultMessage: 'Your file has been saved.',
|
||||
},
|
||||
saveFailedTitle: {
|
||||
id: 'files.editor.save-failed-title',
|
||||
defaultMessage: 'Save failed',
|
||||
},
|
||||
saveFailedText: {
|
||||
id: 'files.editor.save-failed-text',
|
||||
defaultMessage: 'Could not save the file.',
|
||||
},
|
||||
logUrlCopiedTitle: {
|
||||
id: 'files.editor.log-url-copied-title',
|
||||
defaultMessage: 'Log URL copied',
|
||||
},
|
||||
logUrlCopiedText: {
|
||||
id: 'files.editor.log-url-copied-text',
|
||||
defaultMessage: 'Your log file URL has been copied to your clipboard.',
|
||||
},
|
||||
failedToShareTitle: {
|
||||
id: 'files.editor.failed-to-share-title',
|
||||
defaultMessage: 'Failed to share file',
|
||||
},
|
||||
failedToShareText: {
|
||||
id: 'files.editor.failed-to-share-text',
|
||||
defaultMessage: 'Could not upload to mclo.gs.',
|
||||
},
|
||||
})
|
||||
|
||||
const fileContent = ref('')
|
||||
const originalContent = ref('')
|
||||
const isEditingImage = ref(false)
|
||||
const imagePreview = ref<Blob | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const editorInstance = ref<unknown>(null)
|
||||
const editorContainer = ref<HTMLElement | null>(null)
|
||||
const editorHeight = ref('300px')
|
||||
|
||||
function updateEditorHeight() {
|
||||
if (editorContainer.value) {
|
||||
const top = editorContainer.value.getBoundingClientRect().top
|
||||
const padding = 24
|
||||
editorHeight.value = `${Math.max(300, window.innerHeight - top - padding)}px`
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(updateEditorHeight)
|
||||
window.addEventListener('resize', updateEditorHeight)
|
||||
})
|
||||
|
||||
const editorLanguage = computed(() => {
|
||||
const ext = getFileExtension(props.file?.name ?? '')
|
||||
return getEditorLanguage(ext)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.file,
|
||||
async (newFile) => {
|
||||
if (newFile) {
|
||||
await loadFileContent(newFile)
|
||||
nextTick(updateEditorHeight)
|
||||
} else {
|
||||
resetState()
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
async function loadFileContent(file: { name: 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 (isImageFile(extension)) {
|
||||
const content = await ctx.readFileAsBlob(normalizedPath)
|
||||
isEditingImage.value = true
|
||||
imagePreview.value = content
|
||||
} else {
|
||||
isEditingImage.value = false
|
||||
const content = await ctx.readFile(normalizedPath)
|
||||
fileContent.value = content
|
||||
originalContent.value = content
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching file content:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.failedToOpenTitle),
|
||||
text: formatMessage(messages.failedToOpenText),
|
||||
type: 'error',
|
||||
})
|
||||
emit('close')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const hasUnsavedChanges = computed(
|
||||
() => !isEditingImage.value && !isLoading.value && fileContent.value !== originalContent.value,
|
||||
)
|
||||
|
||||
function revertChanges() {
|
||||
fileContent.value = originalContent.value
|
||||
}
|
||||
|
||||
function resetState() {
|
||||
fileContent.value = ''
|
||||
originalContent.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 = false) {
|
||||
if (!props.file) return
|
||||
|
||||
try {
|
||||
const normalizedPath = props.file.path.startsWith('/') ? props.file.path : `/${props.file.path}`
|
||||
await ctx.writeFile(normalizedPath, fileContent.value)
|
||||
|
||||
originalContent.value = fileContent.value
|
||||
|
||||
if (exit) {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
addNotification({
|
||||
title: formatMessage(messages.fileSavedTitle),
|
||||
text: formatMessage(messages.fileSavedText),
|
||||
type: 'success',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error saving file content:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.saveFailedTitle),
|
||||
text: formatMessage(messages.saveFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function shareToMclogs() {
|
||||
if (ctx.shareToMclogs) {
|
||||
await ctx.shareToMclogs(fileContent.value)
|
||||
return
|
||||
}
|
||||
|
||||
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: formatMessage(messages.logUrlCopiedTitle),
|
||||
text: formatMessage(messages.logUrlCopiedText),
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
throw new Error(data.error)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error sharing file:', error)
|
||||
addNotification({
|
||||
title: formatMessage(messages.failedToShareTitle),
|
||||
text: formatMessage(messages.failedToShareText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
resetState()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', updateEditorHeight)
|
||||
editorInstance.value = null
|
||||
resetState()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
saveFileContent,
|
||||
shareToMclogs,
|
||||
close,
|
||||
isEditingImage,
|
||||
fileContent,
|
||||
hasUnsavedChanges,
|
||||
revertChanges,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<div
|
||||
class="relative flex h-[750px] items-center justify-center overflow-hidden rounded-[20px] bg-black"
|
||||
>
|
||||
<div v-if="state.hasError" class="flex flex-col items-center justify-center gap-4">
|
||||
<TriangleAlertIcon class="size-8 text-red" />
|
||||
<p class="m-0 text-secondary">
|
||||
{{ state.errorMessage || formatMessage(messages.invalidImage) }}
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
v-show="isReady"
|
||||
ref="imageRef"
|
||||
:src="imageObjectUrl"
|
||||
class="max-h-full max-w-full rounded-lg object-contain"
|
||||
:class="{ 'cursor-zoom-in': !zoomed, 'cursor-zoom-out': zoomed }"
|
||||
:alt="formatMessage(messages.viewedImageAlt)"
|
||||
@load="handleImageLoad"
|
||||
@error="handleImageError"
|
||||
@click="toggleZoom"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="isReady"
|
||||
class="absolute bottom-4 left-1/2 flex -translate-x-1/2 items-center gap-1 rounded-2xl bg-surface-3/80 p-1.5 backdrop-blur-sm"
|
||||
>
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.zoomIn)" @click="zoomIn">
|
||||
<ZoomInIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.zoomOut)" @click="zoomOut">
|
||||
<ZoomOutIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button v-tooltip="formatMessage(messages.resetZoom)" @click="resetZoom">
|
||||
<span class="px-1 text-sm tabular-nums">{{ Math.round(scale * 100) }}%</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TriangleAlertIcon, ZoomInIcon, ZoomOutIcon } from '@modrinth/assets'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
invalidImage: {
|
||||
id: 'files.image_viewer.invalid_image',
|
||||
defaultMessage: 'Invalid or empty image file.',
|
||||
},
|
||||
viewedImageAlt: {
|
||||
id: 'files.image_viewer.viewed_image_alt',
|
||||
defaultMessage: 'Viewed image',
|
||||
},
|
||||
zoomIn: {
|
||||
id: 'files.image_viewer.zoom_in',
|
||||
defaultMessage: 'Zoom in',
|
||||
},
|
||||
zoomOut: {
|
||||
id: 'files.image_viewer.zoom_out',
|
||||
defaultMessage: 'Zoom out',
|
||||
},
|
||||
resetZoom: {
|
||||
id: 'files.image_viewer.reset_zoom',
|
||||
defaultMessage: 'Reset zoom',
|
||||
},
|
||||
imageTooLarge: {
|
||||
id: 'files.image_viewer.image_too_large',
|
||||
defaultMessage: 'Image too large to view (max {maxDimension}x{maxDimension} pixels)',
|
||||
},
|
||||
loadFailed: {
|
||||
id: 'files.image_viewer.load_failed',
|
||||
defaultMessage: 'Failed to load image',
|
||||
},
|
||||
})
|
||||
|
||||
const MAX_IMAGE_DIMENSION = 4096
|
||||
|
||||
const props = defineProps<{
|
||||
imageBlob: Blob
|
||||
}>()
|
||||
|
||||
const state = ref({
|
||||
isLoading: true,
|
||||
hasError: false,
|
||||
errorMessage: '',
|
||||
})
|
||||
|
||||
const imageRef = ref<HTMLImageElement | null>(null)
|
||||
const imageObjectUrl = ref('')
|
||||
const scale = ref(1)
|
||||
const zoomed = ref(false)
|
||||
|
||||
const isReady = computed(() => !state.value.isLoading && !state.value.hasError)
|
||||
|
||||
function updateImageUrl(blob: Blob) {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
imageObjectUrl.value = URL.createObjectURL(blob)
|
||||
}
|
||||
|
||||
function handleImageLoad() {
|
||||
const img = imageRef.value
|
||||
if (img && (img.naturalWidth > MAX_IMAGE_DIMENSION || img.naturalHeight > MAX_IMAGE_DIMENSION)) {
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = formatMessage(messages.imageTooLarge, {
|
||||
maxDimension: MAX_IMAGE_DIMENSION,
|
||||
})
|
||||
}
|
||||
state.value.isLoading = false
|
||||
}
|
||||
|
||||
function handleImageError() {
|
||||
state.value.isLoading = false
|
||||
state.value.hasError = true
|
||||
state.value.errorMessage = formatMessage(messages.loadFailed)
|
||||
}
|
||||
|
||||
function toggleZoom() {
|
||||
if (zoomed.value) {
|
||||
resetZoom()
|
||||
} else {
|
||||
scale.value = 2
|
||||
zoomed.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale.value = Math.min(scale.value * 1.25, 5)
|
||||
zoomed.value = scale.value > 1
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale.value = Math.max(scale.value * 0.8, 0.1)
|
||||
zoomed.value = scale.value > 1
|
||||
}
|
||||
|
||||
function resetZoom() {
|
||||
scale.value = 1
|
||||
zoomed.value = false
|
||||
}
|
||||
|
||||
watch(scale, (s) => {
|
||||
if (imageRef.value) {
|
||||
imageRef.value.style.transform = s === 1 ? '' : `scale(${s})`
|
||||
imageRef.value.style.transition = 'transform 0.2s ease-out'
|
||||
}
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.imageBlob,
|
||||
(newBlob) => {
|
||||
if (!newBlob) return
|
||||
state.value.isLoading = true
|
||||
state.value.hasError = false
|
||||
scale.value = 1
|
||||
zoomed.value = false
|
||||
updateImageUrl(newBlob)
|
||||
},
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (props.imageBlob) updateImageUrl(props.imageBlob)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (imageObjectUrl.value) URL.revokeObjectURL(imageObjectUrl.value)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header, { type })" max-width="500px">
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(fileValidationMessages.nameLabel)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="createInput"
|
||||
v-model="itemName"
|
||||
:placeholder="
|
||||
formatMessage(
|
||||
type === 'file' ? messages.placeholderFile : messages.placeholderDirectory,
|
||||
)
|
||||
"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
<div v-if="submitted && error" class="text-sm text-red">{{ error }}</div>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error && submitted" @click="handleSubmit">
|
||||
<PlusIcon class="h-5 w-5" />
|
||||
{{ formatMessage(messages.createButton, { type }) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import { fileValidationMessages } from './file-validation-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.create-modal.header',
|
||||
defaultMessage: 'Create a {type, select, directory {folder} other {file}}',
|
||||
},
|
||||
placeholderFile: {
|
||||
id: 'files.create-modal.placeholder-file',
|
||||
defaultMessage: 'e.g. config.yml',
|
||||
},
|
||||
placeholderDirectory: {
|
||||
id: 'files.create-modal.placeholder-directory',
|
||||
defaultMessage: 'e.g. my-folder',
|
||||
},
|
||||
createButton: {
|
||||
id: 'files.create-modal.create-button',
|
||||
defaultMessage: 'Create {type, select, directory {folder} other {file}}',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
type: 'file' | 'directory'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [name: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const createInput = ref<HTMLInputElement | null>(null)
|
||||
const itemName = ref('')
|
||||
const submitted = ref(false)
|
||||
|
||||
const error = computed(() => {
|
||||
if (!itemName.value) {
|
||||
return formatMessage(fileValidationMessages.nameRequired)
|
||||
}
|
||||
if (props.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidFile)
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidDirectory)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('create', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
itemName.value = ''
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
createInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="danger" :header="formatMessage(messages.header)" max-width="500px">
|
||||
<Admonition type="critical" class="md:min-w-[400px]">
|
||||
<template #header>{{ formatMessage(messages.deletingName, { name: item?.name }) }}</template>
|
||||
{{ formatMessage(messages.deleteWarning, { type: item?.type }) }}
|
||||
</Admonition>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="handleSubmit">
|
||||
<TrashIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.deleteLabel) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { TrashIcon, 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 NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.delete-modal.header',
|
||||
defaultMessage: 'Delete file',
|
||||
},
|
||||
deletingName: {
|
||||
id: 'files.delete-modal.deleting-name',
|
||||
defaultMessage: 'Deleting "{name}"',
|
||||
},
|
||||
deleteWarning: {
|
||||
id: 'files.delete-modal.warning',
|
||||
defaultMessage:
|
||||
'{type, select, directory {This folder and all its contents will be permanently deleted. This action cannot be undone.} other {This file will be permanently deleted. This action cannot be undone.}}',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const handleSubmit = () => {
|
||||
emit('delete')
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { type: item?.type })"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.currentLocation)
|
||||
}}</span>
|
||||
<span class="text-secondary">{{ `${currentPath}/${item?.name}`.replace('//', '/') }}</span>
|
||||
</div>
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{
|
||||
formatMessage(messages.destinationPath)
|
||||
}}</span>
|
||||
<StyledInput
|
||||
ref="destinationInput"
|
||||
v-model="destination"
|
||||
:placeholder="formatMessage(messages.destinationPlaceholder)"
|
||||
wrapper-class="w-full"
|
||||
/>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleSubmit">
|
||||
<RightArrowIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.moveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { RightArrowIcon, XIcon } from '@modrinth/assets'
|
||||
import { nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.move-modal.header',
|
||||
defaultMessage: '{type, select, directory {Move folder} other {Move file}}',
|
||||
},
|
||||
currentLocation: {
|
||||
id: 'files.move-modal.current-location',
|
||||
defaultMessage: 'Current location',
|
||||
},
|
||||
destinationPath: {
|
||||
id: 'files.move-modal.destination-path',
|
||||
defaultMessage: 'Destination path',
|
||||
},
|
||||
destinationPlaceholder: {
|
||||
id: 'files.move-modal.destination-placeholder',
|
||||
defaultMessage: 'e.g. /my-folder',
|
||||
},
|
||||
})
|
||||
|
||||
const destinationInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | null
|
||||
currentPath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
move: [destination: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const destination = ref('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
const path = destination.value.replace('//', '/')
|
||||
const normalized = path.startsWith('/') ? path : `/${path}`
|
||||
emit('move', normalized)
|
||||
hide()
|
||||
}
|
||||
|
||||
const show = () => {
|
||||
destination.value = ''
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
destinationInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="formatMessage(messages.header, { name: item?.name })"
|
||||
max-width="500px"
|
||||
>
|
||||
<form class="space-y-6 md:min-w-[400px]" @submit.prevent="handleSubmit">
|
||||
<label class="flex flex-col gap-2">
|
||||
<span class="font-semibold text-contrast">{{ formatMessage(messages.newNameLabel) }}</span>
|
||||
<StyledInput ref="renameInput" v-model="itemName" wrapper-class="w-full" />
|
||||
<div v-if="submitted && error" class="text-sm text-red">{{ error }}</div>
|
||||
</label>
|
||||
</form>
|
||||
<template #actions>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button :disabled="!!error && submitted" @click="handleSubmit">
|
||||
<EditIcon class="h-5 w-5" />
|
||||
{{ formatMessage(commonMessages.renameButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { EditIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import type { FileItem } from '../../types'
|
||||
import { fileValidationMessages } from './file-validation-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.rename-modal.header',
|
||||
defaultMessage: 'Rename {name}',
|
||||
},
|
||||
newNameLabel: {
|
||||
id: 'files.rename-modal.new-name-label',
|
||||
defaultMessage: 'New name',
|
||||
},
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
item: Pick<FileItem, 'name' | 'type'> | 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 formatMessage(fileValidationMessages.nameRequired)
|
||||
}
|
||||
if (props.item?.type === 'file') {
|
||||
const validPattern = /^[a-zA-Z0-9-_.\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidFile)
|
||||
}
|
||||
} else {
|
||||
const validPattern = /^[a-zA-Z0-9-_\s]+$/
|
||||
if (!validPattern.test(itemName.value)) {
|
||||
return formatMessage(fileValidationMessages.nameInvalidDirectory)
|
||||
}
|
||||
}
|
||||
return ''
|
||||
})
|
||||
|
||||
const handleSubmit = () => {
|
||||
submitted.value = true
|
||||
if (!error.value) {
|
||||
emit('rename', itemName.value)
|
||||
hide()
|
||||
}
|
||||
}
|
||||
|
||||
const show = (item: { name: string; type: string }) => {
|
||||
itemName.value = item.name
|
||||
submitted.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
renameInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,89 @@
|
||||
<template>
|
||||
<NewModal ref="modal" fade="warning" :header="formatMessage(messages.header)" max-width="500px">
|
||||
<p class="m-0 text-secondary">
|
||||
{{ formatMessage(messages.body) }}
|
||||
</p>
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="handleCancel">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="handleDiscard">
|
||||
<TrashIcon />
|
||||
{{ formatMessage(messages.discard) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button @click="handleSave">
|
||||
<SaveIcon />
|
||||
{{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SaveIcon, TrashIcon, XIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.unsaved-changes-modal.header',
|
||||
defaultMessage: 'Unsaved changes',
|
||||
},
|
||||
body: {
|
||||
id: 'files.unsaved-changes-modal.body',
|
||||
defaultMessage:
|
||||
'You have unsaved changes that will be lost if you leave. Would you like to save before leaving?',
|
||||
},
|
||||
discard: {
|
||||
id: 'files.unsaved-changes-modal.discard',
|
||||
defaultMessage: 'Discard',
|
||||
},
|
||||
})
|
||||
|
||||
export type UnsavedChangesResult = 'cancel' | 'discard' | 'save'
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
let resolvePromise: ((value: UnsavedChangesResult) => void) | null = null
|
||||
|
||||
function prompt(): Promise<UnsavedChangesResult> {
|
||||
return new Promise((resolve) => {
|
||||
resolvePromise = resolve
|
||||
modal.value?.show()
|
||||
})
|
||||
}
|
||||
|
||||
function resolve(result: UnsavedChangesResult) {
|
||||
modal.value?.hide()
|
||||
resolvePromise?.(result)
|
||||
resolvePromise = null
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
resolve('cancel')
|
||||
}
|
||||
|
||||
function handleDiscard() {
|
||||
resolve('discard')
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
resolve('save')
|
||||
}
|
||||
|
||||
defineExpose({ prompt })
|
||||
</script>
|
||||
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<NewModal ref="modal" :header="formatMessage(messages.header)" :closable="true" no-padding>
|
||||
<div class="max-w-[500px]">
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<Admonition type="warning" :header="formatMessage(messages.warningHeader)">
|
||||
<span>
|
||||
<template v-if="hasMany">
|
||||
{{ formatMessage(messages.overwriteManyWarning) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ formatMessage(messages.overwriteWarning, { count: files.length }) }}
|
||||
</template>
|
||||
</span>
|
||||
</Admonition>
|
||||
|
||||
<div v-if="files.length" class="flex gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<MinusIcon />
|
||||
{{ formatMessage(messages.overwrittenCount, { count: files.length }) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="files.length"
|
||||
class="flex flex-col bg-surface-2 p-4 max-h-[272px] overflow-y-auto border-t border-b border-r-0 border-l-0 border-solid border-surface-5"
|
||||
>
|
||||
<div
|
||||
v-for="(file, index) in files"
|
||||
:key="file"
|
||||
class="grid grid-cols-[auto_auto_1fr] items-center min-h-10 h-10 gap-2"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-between">
|
||||
<div class="w-[1px] h-2"></div>
|
||||
<MinusIcon class="text-red" />
|
||||
<div
|
||||
:class="index === files.length - 1 ? 'bg-transparent' : 'bg-surface-5'"
|
||||
class="w-[1px] h-2 relative top-1"
|
||||
></div>
|
||||
</div>
|
||||
<span class="text-sm shrink-0 whitespace-nowrap">{{
|
||||
formatMessage(messages.overwrittenLabel)
|
||||
}}</span>
|
||||
<span
|
||||
v-tooltip="file"
|
||||
class="text-sm text-contrast font-medium whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ file }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex justify-end gap-2 pt-4">
|
||||
<ButtonStyled type="outlined">
|
||||
<button class="!border !border-surface-4" @click="hide">
|
||||
<XIcon />
|
||||
{{ formatMessage(commonMessages.cancelButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="handleProceed">
|
||||
<CheckIcon />
|
||||
{{ formatMessage(messages.overwriteButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, MinusIcon, XIcon } from '@modrinth/assets'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'files.conflict-modal.header',
|
||||
defaultMessage: 'Extract summary',
|
||||
},
|
||||
warningHeader: {
|
||||
id: 'files.conflict-modal.warning-header',
|
||||
defaultMessage: 'Files will be overwritten',
|
||||
},
|
||||
overwriteManyWarning: {
|
||||
id: 'files.conflict-modal.overwrite-many-warning',
|
||||
defaultMessage:
|
||||
'Over 100 files will be overwritten if you proceed with extraction; here are some of them.',
|
||||
},
|
||||
overwriteWarning: {
|
||||
id: 'files.conflict-modal.overwrite-warning',
|
||||
defaultMessage:
|
||||
'The following {count} files already exist on your server, and will be overwritten if you proceed with extraction.',
|
||||
},
|
||||
overwrittenCount: {
|
||||
id: 'files.conflict-modal.overwritten-count',
|
||||
defaultMessage: '{count} overwritten',
|
||||
},
|
||||
overwrittenLabel: {
|
||||
id: 'files.conflict-modal.overwritten-label',
|
||||
defaultMessage: 'Overwritten',
|
||||
},
|
||||
overwriteButton: {
|
||||
id: 'files.conflict-modal.overwrite-button',
|
||||
defaultMessage: 'Overwrite',
|
||||
},
|
||||
})
|
||||
|
||||
const path = ref('')
|
||||
const files = ref<string[]>([])
|
||||
|
||||
const emit = defineEmits<{
|
||||
proceed: [path: string]
|
||||
}>()
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
|
||||
const hasMany = computed(() => files.value.length > 100)
|
||||
|
||||
const show = (zipPath: string, conflictingFiles: string[]) => {
|
||||
path.value = zipPath
|
||||
files.value = conflictingFiles
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const handleProceed = () => {
|
||||
hide()
|
||||
emit('proceed', path.value)
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<NewModal
|
||||
ref="modal"
|
||||
:header="cf ? formatMessage(messages.cfHeader) : formatMessage(messages.zipHeader)"
|
||||
>
|
||||
<form class="flex flex-col gap-6 md:w-[700px]" @submit.prevent="handleSubmit">
|
||||
<!-- CurseForge stepper cards -->
|
||||
<div v-if="cf" class="flex gap-4">
|
||||
<div
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
class="flex flex-1 flex-col gap-2 overflow-clip rounded-[20px] bg-surface-2 p-3"
|
||||
>
|
||||
<span
|
||||
class="flex size-6 shrink-0 items-center justify-center rounded-full border border-solid border-surface-5 bg-surface-4 font-medium text-contrast"
|
||||
>
|
||||
{{ i + 1 }}
|
||||
</span>
|
||||
<div class="flex flex-col">
|
||||
<div class="font-semibold leading-snug text-contrast">
|
||||
{{ step.title }}
|
||||
</div>
|
||||
<div class="text-sm leading-relaxed text-secondary">
|
||||
{{ step.description }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- URL input -->
|
||||
<div class="flex flex-col gap-2.5">
|
||||
<label v-if="cf" class="text-base font-semibold text-contrast">{{
|
||||
formatMessage(messages.enterLink)
|
||||
}}</label>
|
||||
<div v-else class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.zipDescription) }}
|
||||
</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>
|
||||
|
||||
<!-- Inline backup creator -->
|
||||
<InlineBackupCreator
|
||||
:backup-name="formatMessage(messages.backupName)"
|
||||
hide-shift-click-hint
|
||||
@update:buttons-disabled="backupInProgress = $event"
|
||||
/>
|
||||
</form>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex w-full items-center justify-end gap-2">
|
||||
<ButtonStyled type="outlined">
|
||||
<button type="button" class="!border !border-surface-4" @click="hide">
|
||||
<XIcon />
|
||||
{{
|
||||
submitted
|
||||
? formatMessage(commonMessages.closeButton)
|
||||
: formatMessage(commonMessages.cancelButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button
|
||||
v-tooltip="error"
|
||||
:disabled="submitted || !!error || backupInProgress"
|
||||
type="submit"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
<SpinnerIcon v-if="submitted" class="animate-spin" />
|
||||
<DownloadIcon v-else />
|
||||
{{
|
||||
submitted
|
||||
? formatMessage(commonMessages.installingLabel)
|
||||
: formatMessage(messages.installButton)
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
FileTextIcon,
|
||||
LinkIcon,
|
||||
SearchIcon,
|
||||
SpinnerIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import StyledInput from '#ui/components/base/StyledInput.vue'
|
||||
import NewModal from '#ui/components/modal/NewModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers/api-client'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import InlineBackupCreator from '../../../content-tab/components/modals/InlineBackupCreator.vue'
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
cfHeader: {
|
||||
id: 'files.zip-url-modal.cf-header',
|
||||
defaultMessage: 'Install a CurseForge modpack',
|
||||
},
|
||||
zipHeader: {
|
||||
id: 'files.zip-url-modal.zip-header',
|
||||
defaultMessage: 'Uploading .zip contents from URL',
|
||||
},
|
||||
enterLink: {
|
||||
id: 'files.zip-url-modal.enter-link',
|
||||
defaultMessage: 'Enter link',
|
||||
},
|
||||
zipDescription: {
|
||||
id: 'files.zip-url-modal.zip-description',
|
||||
defaultMessage: 'Copy and paste the direct download URL of a .zip file.',
|
||||
},
|
||||
installButton: {
|
||||
id: 'files.zip-url-modal.install-button',
|
||||
defaultMessage: 'Install',
|
||||
},
|
||||
stepFindTitle: {
|
||||
id: 'files.zip-url-modal.step-find-title',
|
||||
defaultMessage: 'Find the modpack',
|
||||
},
|
||||
stepFindDescription: {
|
||||
id: 'files.zip-url-modal.step-find-description',
|
||||
defaultMessage: 'Browse CurseForge and locate the modpack you want.',
|
||||
},
|
||||
stepSelectTitle: {
|
||||
id: 'files.zip-url-modal.step-select-title',
|
||||
defaultMessage: 'Select a version',
|
||||
},
|
||||
stepSelectDescription: {
|
||||
id: 'files.zip-url-modal.step-select-description',
|
||||
defaultMessage: 'Go to the "Files" tab and pick the version to install.',
|
||||
},
|
||||
stepCopyTitle: {
|
||||
id: 'files.zip-url-modal.step-copy-title',
|
||||
defaultMessage: 'Copy the URL',
|
||||
},
|
||||
stepCopyDescription: {
|
||||
id: 'files.zip-url-modal.step-copy-description',
|
||||
defaultMessage: 'Copy the version page URL and paste it below.',
|
||||
},
|
||||
errorUrlRequired: {
|
||||
id: 'files.zip-url-modal.error-url-required',
|
||||
defaultMessage: 'URL is required.',
|
||||
},
|
||||
errorCfUrl: {
|
||||
id: 'files.zip-url-modal.error-cf-url',
|
||||
defaultMessage: 'URL must be a CurseForge modpack version URL.',
|
||||
},
|
||||
errorUrlInvalid: {
|
||||
id: 'files.zip-url-modal.error-url-invalid',
|
||||
defaultMessage: 'URL must be valid.',
|
||||
},
|
||||
cfNotFoundTitle: {
|
||||
id: 'files.zip-url-modal.cf-not-found-title',
|
||||
defaultMessage: 'CurseForge modpack not found',
|
||||
},
|
||||
cfNotFoundText: {
|
||||
id: 'files.zip-url-modal.cf-not-found-text',
|
||||
defaultMessage: 'Could not find CurseForge modpack at that URL.',
|
||||
},
|
||||
installFailedTitle: {
|
||||
id: 'files.zip-url-modal.install-failed-title',
|
||||
defaultMessage: 'Installation failed',
|
||||
},
|
||||
unknownError: {
|
||||
id: 'files.zip-url-modal.unknown-error',
|
||||
defaultMessage: 'An unknown error occurred',
|
||||
},
|
||||
backupName: {
|
||||
id: 'files.zip-url-modal.backup-name',
|
||||
defaultMessage: 'CurseForge modpack install',
|
||||
},
|
||||
})
|
||||
|
||||
const steps = [
|
||||
{
|
||||
icon: SearchIcon,
|
||||
title: formatMessage(messages.stepFindTitle),
|
||||
description: formatMessage(messages.stepFindDescription),
|
||||
},
|
||||
{
|
||||
icon: FileTextIcon,
|
||||
title: formatMessage(messages.stepSelectTitle),
|
||||
description: formatMessage(messages.stepSelectDescription),
|
||||
},
|
||||
{
|
||||
icon: LinkIcon,
|
||||
title: formatMessage(messages.stepCopyTitle),
|
||||
description: formatMessage(messages.stepCopyDescription),
|
||||
},
|
||||
]
|
||||
|
||||
const cf = ref(false)
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const url = ref('')
|
||||
const submitted = ref(false)
|
||||
const touched = ref(false)
|
||||
const backupInProgress = 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 formatMessage(messages.errorUrlRequired)
|
||||
}
|
||||
if (cf.value && !regex.test(trimmedUrl.value)) {
|
||||
return formatMessage(messages.errorCfUrl)
|
||||
} else if (!cf.value && !trimmedUrl.value.includes('/')) {
|
||||
return formatMessage(messages.errorUrlInvalid)
|
||||
}
|
||||
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: formatMessage(messages.cfNotFoundTitle),
|
||||
text: formatMessage(messages.cfNotFoundText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
submitted.value = false
|
||||
console.error('Error installing:', err)
|
||||
addNotification({
|
||||
title: formatMessage(messages.installFailedTitle),
|
||||
text: err instanceof Error ? err.message : formatMessage(messages.unknownError),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const show = (isCf: boolean) => {
|
||||
cf.value = isCf
|
||||
url.value = ''
|
||||
submitted.value = false
|
||||
touched.value = false
|
||||
backupInProgress.value = false
|
||||
modal.value?.show()
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
modal.value?.$el?.querySelector('input')?.focus()
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineMessages } from '#ui/composables/i18n'
|
||||
|
||||
export const fileValidationMessages = defineMessages({
|
||||
nameLabel: {
|
||||
id: 'files.validation.name-label',
|
||||
defaultMessage: 'Name',
|
||||
},
|
||||
nameRequired: {
|
||||
id: 'files.validation.name-required',
|
||||
defaultMessage: 'Name is required.',
|
||||
},
|
||||
nameInvalidFile: {
|
||||
id: 'files.validation.name-invalid-file',
|
||||
defaultMessage:
|
||||
'Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces.',
|
||||
},
|
||||
nameInvalidDirectory: {
|
||||
id: 'files.validation.name-invalid-directory',
|
||||
defaultMessage:
|
||||
'Name must contain only alphanumeric characters, dashes, underscores, or spaces.',
|
||||
},
|
||||
})
|
||||
@@ -16,7 +16,11 @@
|
||||
<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
|
||||
{{
|
||||
formatMessage(messages.dropToUpload, {
|
||||
type: type ? type.toLocaleLowerCase() : undefined,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -27,6 +31,10 @@
|
||||
import { UploadIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{
|
||||
filesDropped: [files: File[]]
|
||||
}>()
|
||||
@@ -36,15 +44,20 @@ defineProps<{
|
||||
type?: string
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
dropToUpload: {
|
||||
id: 'files.upload.drag-and-drop.drop-to-upload',
|
||||
defaultMessage: 'Drop {type, select, undefined {files} other {{type}s}} here to upload',
|
||||
},
|
||||
})
|
||||
|
||||
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
|
||||
}
|
||||
dragCounter.value++
|
||||
isDragging.value = true
|
||||
}
|
||||
|
||||
const handleDragOver = (event: DragEvent) => {
|
||||
@@ -64,9 +77,6 @@ const handleDrop = (event: DragEvent) => {
|
||||
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))
|
||||
@@ -12,9 +12,17 @@
|
||||
<FolderOpenIcon class="size-4" />
|
||||
<span>
|
||||
<span class="capitalize">
|
||||
{{ props.fileType ? props.fileType : 'File' }} uploads
|
||||
{{
|
||||
formatMessage(messages.fileUploads, {
|
||||
fileType: props.fileType ? props.fileType : formatMessage(messages.file),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
<span>{{ activeUploads.length > 0 ? ` - ${activeUploads.length} left` : '' }}</span>
|
||||
<span>{{
|
||||
activeUploads.length > 0
|
||||
? formatMessage(messages.uploadsLeft, { count: activeUploads.length })
|
||||
: ''
|
||||
}}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,18 +60,20 @@
|
||||
</div>
|
||||
<div class="flex min-w-[80px] items-center justify-end gap-2">
|
||||
<template v-if="item.status === 'completed'">
|
||||
<span>Done</span>
|
||||
<span>{{ formatMessage(commonMessages.doneLabel) }}</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-file-exists'">
|
||||
<span class="text-red">Failed - File already exists</span>
|
||||
<span class="text-red">{{ formatMessage(messages.failedFileExists) }}</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'error-generic'">
|
||||
<span class="text-red"
|
||||
>Failed - {{ item.error?.message || 'An unexpected error occured.' }}</span
|
||||
>
|
||||
<span class="text-red">{{
|
||||
formatMessage(messages.failedGeneric, {
|
||||
error: item.error?.message || formatMessage(messages.unexpectedError),
|
||||
})
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'incorrect-type'">
|
||||
<span class="text-red">Failed - Incorrect file type</span>
|
||||
<span class="text-red">{{ formatMessage(messages.failedIncorrectType) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="item.status === 'uploading'">
|
||||
@@ -75,11 +85,11 @@
|
||||
/>
|
||||
</div>
|
||||
<ButtonStyled color="red" type="transparent" @click="cancelUpload(item)">
|
||||
<button>Cancel</button>
|
||||
<button>{{ formatMessage(commonMessages.cancelButton) }}</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else-if="item.status === 'cancelled'">
|
||||
<span class="text-red">Cancelled</span>
|
||||
<span class="text-red">{{ formatMessage(messages.cancelled) }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ item.progress }}%</span>
|
||||
@@ -102,12 +112,61 @@
|
||||
|
||||
<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'
|
||||
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { injectModrinthClient } from '#ui/providers/api-client'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const client = injectModrinthClient()
|
||||
|
||||
const messages = defineMessages({
|
||||
file: {
|
||||
id: 'files.upload-dropdown.file',
|
||||
defaultMessage: 'File',
|
||||
},
|
||||
fileUploads: {
|
||||
id: 'files.upload-dropdown.file-uploads',
|
||||
defaultMessage: '{fileType} uploads',
|
||||
},
|
||||
uploadsLeft: {
|
||||
id: 'files.upload-dropdown.uploads-left',
|
||||
defaultMessage: ' - {count} left',
|
||||
},
|
||||
failedFileExists: {
|
||||
id: 'files.upload-dropdown.failed-file-exists',
|
||||
defaultMessage: 'Failed - File already exists',
|
||||
},
|
||||
failedGeneric: {
|
||||
id: 'files.upload-dropdown.failed-generic',
|
||||
defaultMessage: 'Failed - {error}',
|
||||
},
|
||||
unexpectedError: {
|
||||
id: 'files.upload-dropdown.unexpected-error',
|
||||
defaultMessage: 'An unexpected error occurred.',
|
||||
},
|
||||
failedIncorrectType: {
|
||||
id: 'files.upload-dropdown.failed-incorrect-type',
|
||||
defaultMessage: 'Failed - Incorrect file type',
|
||||
},
|
||||
cancelled: {
|
||||
id: 'files.upload-dropdown.cancelled',
|
||||
defaultMessage: 'Cancelled',
|
||||
},
|
||||
incorrectFileType: {
|
||||
id: 'files.upload-dropdown.incorrect-file-type',
|
||||
defaultMessage: 'Upload had incorrect file type',
|
||||
},
|
||||
failedToUpload: {
|
||||
id: 'files.upload-dropdown.failed-to-upload',
|
||||
defaultMessage: 'Failed to upload {fileName}',
|
||||
},
|
||||
})
|
||||
|
||||
interface UploadItem {
|
||||
file: File
|
||||
progress: number
|
||||
@@ -203,7 +262,7 @@ const cancelUpload = (item: UploadItem) => {
|
||||
}
|
||||
}
|
||||
|
||||
const badFileTypeMsg = 'Upload had incorrect file type'
|
||||
const badFileTypeMsg = formatMessage(messages.incorrectFileType)
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
const uploadItem: UploadItem = {
|
||||
@@ -283,8 +342,8 @@ const uploadFile = async (file: File) => {
|
||||
|
||||
if (error instanceof Error && error.message !== 'Upload cancelled') {
|
||||
addNotification({
|
||||
title: 'Upload failed',
|
||||
text: `Failed to upload ${file.name}`,
|
||||
title: formatMessage(commonMessages.uploadFailedLabel),
|
||||
text: formatMessage(messages.failedToUpload, { fileName: file.name }),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export interface FileDragData {
|
||||
name: string
|
||||
type: string
|
||||
path: string
|
||||
}
|
||||
|
||||
const activeDrag = ref<FileDragData | null>(null)
|
||||
const dragTarget = ref<string | null>(null)
|
||||
const ghostEl = ref<HTMLElement | null>(null)
|
||||
const pointerStartX = ref(0)
|
||||
const pointerStartY = ref(0)
|
||||
const dragStarted = ref(false)
|
||||
|
||||
const DRAG_THRESHOLD = 5
|
||||
|
||||
export const fileDragData = activeDrag
|
||||
export const fileDragTarget = dragTarget
|
||||
export const fileDragActive = dragStarted
|
||||
|
||||
function createGhost(name: string): HTMLElement {
|
||||
const el = document.createElement('div')
|
||||
el.className =
|
||||
'fixed z-[99999] flex items-center max-w-[500px] gap-3 rounded-lg bg-bg-raised p-3 shadow-lg pointer-events-none text-contrast font-bold truncate'
|
||||
el.textContent = name
|
||||
el.style.transform = 'translate(-50%, -100%)'
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
function findDropTarget(x: number, y: number): string | null {
|
||||
const el = document.elementFromPoint(x, y)
|
||||
if (!el) return null
|
||||
const row = (el as HTMLElement).closest('[data-file-type="directory"]') as HTMLElement | null
|
||||
return row?.dataset.filePath ?? null
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!activeDrag.value) return
|
||||
|
||||
if (!dragStarted.value) {
|
||||
const dx = e.clientX - pointerStartX.value
|
||||
const dy = e.clientY - pointerStartY.value
|
||||
if (Math.abs(dx) < DRAG_THRESHOLD && Math.abs(dy) < DRAG_THRESHOLD) return
|
||||
dragStarted.value = true
|
||||
ghostEl.value = createGhost(activeDrag.value.name)
|
||||
}
|
||||
|
||||
if (ghostEl.value) {
|
||||
ghostEl.value.style.left = `${e.clientX}px`
|
||||
ghostEl.value.style.top = `${e.clientY - 10}px`
|
||||
}
|
||||
|
||||
const target = findDropTarget(e.clientX, e.clientY)
|
||||
if (target !== dragTarget.value) {
|
||||
dragTarget.value = target
|
||||
}
|
||||
}
|
||||
|
||||
let clickSuppressed = false
|
||||
|
||||
export function wasRecentDrag(): boolean {
|
||||
return clickSuppressed
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
const wasDrag = dragStarted.value
|
||||
if (ghostEl.value) {
|
||||
ghostEl.value.remove()
|
||||
ghostEl.value = null
|
||||
}
|
||||
activeDrag.value = null
|
||||
dragTarget.value = null
|
||||
dragStarted.value = false
|
||||
document.removeEventListener('pointermove', onPointerMove)
|
||||
document.removeEventListener('pointerup', onPointerUp)
|
||||
if (wasDrag) {
|
||||
clickSuppressed = true
|
||||
requestAnimationFrame(() => {
|
||||
clickSuppressed = false
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let onDropCallback: ((source: FileDragData, destination: string) => void) | null = null
|
||||
|
||||
function onPointerUp() {
|
||||
if (dragStarted.value && activeDrag.value && dragTarget.value) {
|
||||
const src = activeDrag.value
|
||||
const dest = dragTarget.value
|
||||
const isSelf = dest === src.path
|
||||
const isChild = src.type === 'directory' && dest.startsWith(src.path + '/')
|
||||
if (!isSelf && !isChild) {
|
||||
onDropCallback?.(src, dest)
|
||||
}
|
||||
}
|
||||
cleanup()
|
||||
}
|
||||
|
||||
export function startFileDrag(
|
||||
data: FileDragData,
|
||||
e: PointerEvent,
|
||||
onDrop: (source: FileDragData, destination: string) => void,
|
||||
) {
|
||||
activeDrag.value = data
|
||||
pointerStartX.value = e.clientX
|
||||
pointerStartY.value = e.clientY
|
||||
dragStarted.value = false
|
||||
onDropCallback = onDrop
|
||||
|
||||
document.addEventListener('pointermove', onPointerMove)
|
||||
document.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { FileItem } from '../types'
|
||||
|
||||
export function useFileSearch(items: Ref<FileItem[]>) {
|
||||
const searchQuery = ref('')
|
||||
|
||||
const searchedItems = computed(() => {
|
||||
if (!searchQuery.value) return items.value
|
||||
|
||||
const query = searchQuery.value.toLowerCase()
|
||||
return items.value.filter((item) => item.name.toLowerCase().includes(query))
|
||||
})
|
||||
|
||||
return {
|
||||
searchQuery,
|
||||
searchedItems,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { FileItem } from '../types'
|
||||
|
||||
export function useFileSelection(items: Ref<FileItem[]>) {
|
||||
const selectedItems = ref<Set<string>>(new Set())
|
||||
|
||||
function toggleItemSelection(path: string) {
|
||||
const newSet = new Set(selectedItems.value)
|
||||
if (newSet.has(path)) {
|
||||
newSet.delete(path)
|
||||
} else {
|
||||
newSet.add(path)
|
||||
}
|
||||
selectedItems.value = newSet
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedItems.value = new Set(items.value.map((i) => i.path))
|
||||
}
|
||||
|
||||
function deselectAll() {
|
||||
selectedItems.value = new Set()
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
if (allSelected.value) {
|
||||
deselectAll()
|
||||
} else {
|
||||
selectAll()
|
||||
}
|
||||
}
|
||||
|
||||
const allSelected = computed(
|
||||
() => items.value.length > 0 && selectedItems.value.size === items.value.length,
|
||||
)
|
||||
|
||||
const someSelected = computed(
|
||||
() => selectedItems.value.size > 0 && selectedItems.value.size < items.value.length,
|
||||
)
|
||||
|
||||
return {
|
||||
selectedItems,
|
||||
toggleItemSelection,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
toggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { FileItem, FileSortField, FileViewFilter } from '../types'
|
||||
|
||||
export function useFileSorting(items: Ref<FileItem[]>) {
|
||||
const sortField = ref<FileSortField>('name')
|
||||
const sortDesc = ref(false)
|
||||
const viewFilter = ref<FileViewFilter>('all')
|
||||
|
||||
function handleSort(field: FileSortField) {
|
||||
if (sortField.value === field) {
|
||||
sortDesc.value = !sortDesc.value
|
||||
} else {
|
||||
sortField.value = field
|
||||
sortDesc.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetSort() {
|
||||
sortField.value = 'name'
|
||||
sortDesc.value = false
|
||||
viewFilter.value = 'all'
|
||||
}
|
||||
|
||||
const sortedItems = computed(() => {
|
||||
let result = [...items.value]
|
||||
|
||||
switch (viewFilter.value) {
|
||||
case 'filesOnly':
|
||||
result = result.filter((item) => item.type !== 'directory')
|
||||
break
|
||||
case 'foldersOnly':
|
||||
result = result.filter((item) => item.type === 'directory')
|
||||
break
|
||||
}
|
||||
|
||||
function compareItems(a: FileItem, b: FileItem) {
|
||||
if (viewFilter.value === 'all') {
|
||||
if (a.type === 'directory' && b.type !== 'directory') return -1
|
||||
if (a.type !== 'directory' && b.type === 'directory') return 1
|
||||
}
|
||||
|
||||
switch (sortField.value) {
|
||||
case 'modified':
|
||||
return sortDesc.value ? a.modified - b.modified : b.modified - a.modified
|
||||
case 'created':
|
||||
return sortDesc.value ? a.created - b.created : b.created - a.created
|
||||
case 'size': {
|
||||
const aValue =
|
||||
a.type === 'directory'
|
||||
? a.count !== undefined
|
||||
? a.count
|
||||
: 0
|
||||
: a.size !== undefined
|
||||
? a.size
|
||||
: 0
|
||||
const bValue =
|
||||
b.type === 'directory'
|
||||
? b.count !== undefined
|
||||
? b.count
|
||||
: 0
|
||||
: b.size !== undefined
|
||||
? b.size
|
||||
: 0
|
||||
return sortDesc.value ? aValue - bValue : bValue - aValue
|
||||
}
|
||||
default:
|
||||
return sortDesc.value ? b.name.localeCompare(a.name) : a.name.localeCompare(b.name)
|
||||
}
|
||||
}
|
||||
|
||||
result.sort(compareItems)
|
||||
return result
|
||||
})
|
||||
|
||||
return {
|
||||
sortField,
|
||||
sortDesc,
|
||||
viewFilter,
|
||||
sortedItems,
|
||||
handleSort,
|
||||
resetSort,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { Operation } from '../types'
|
||||
|
||||
export function useFileUndoRedo(
|
||||
renameItem: (path: string, newName: string) => Promise<void>,
|
||||
moveItem: (source: string, destination: string) => Promise<void>,
|
||||
refresh: () => void,
|
||||
notify: (title: string, text: string, type: 'success' | 'error') => void,
|
||||
) {
|
||||
const operationHistory = ref<Operation[]>([])
|
||||
const redoStack = ref<Operation[]>([])
|
||||
|
||||
function recordOperation(op: Operation) {
|
||||
redoStack.value = []
|
||||
operationHistory.value.push(op)
|
||||
}
|
||||
|
||||
async function undo() {
|
||||
const lastOperation = operationHistory.value.pop()
|
||||
if (!lastOperation) return
|
||||
|
||||
try {
|
||||
switch (lastOperation.type) {
|
||||
case 'move':
|
||||
await moveItem(
|
||||
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace('//', '/'),
|
||||
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace('//', '/'),
|
||||
)
|
||||
break
|
||||
case 'rename':
|
||||
await renameItem(
|
||||
`${lastOperation.path}/${lastOperation.newName}`.replace('//', '/'),
|
||||
lastOperation.oldName,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
redoStack.value.push(lastOperation)
|
||||
refresh()
|
||||
notify(
|
||||
`${lastOperation.type === 'move' ? 'Move' : 'Rename'} undone`,
|
||||
`${lastOperation.fileName} has been restored to its original ${lastOperation.type === 'move' ? 'location' : 'name'}`,
|
||||
'success',
|
||||
)
|
||||
} catch {
|
||||
notify('Undo failed', `Failed to undo the last ${lastOperation.type} operation`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function redo() {
|
||||
const lastOperation = redoStack.value.pop()
|
||||
if (!lastOperation) return
|
||||
|
||||
try {
|
||||
switch (lastOperation.type) {
|
||||
case 'move':
|
||||
await moveItem(
|
||||
`${lastOperation.sourcePath}/${lastOperation.fileName}`.replace('//', '/'),
|
||||
`${lastOperation.destinationPath}/${lastOperation.fileName}`.replace('//', '/'),
|
||||
)
|
||||
break
|
||||
case 'rename':
|
||||
await renameItem(
|
||||
`${lastOperation.path}/${lastOperation.oldName}`.replace('//', '/'),
|
||||
lastOperation.newName,
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
operationHistory.value.push(lastOperation)
|
||||
refresh()
|
||||
notify(
|
||||
`${lastOperation.type === 'move' ? 'Move' : 'Rename'} redone`,
|
||||
`${lastOperation.fileName} has been ${lastOperation.type === 'move' ? 'moved' : 'renamed'} again`,
|
||||
'success',
|
||||
)
|
||||
} catch {
|
||||
notify('Redo failed', `Failed to redo the last ${lastOperation.type} operation`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault()
|
||||
undo()
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key === 'z') {
|
||||
e.preventDefault()
|
||||
redo()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
operationHistory,
|
||||
redoStack,
|
||||
recordOperation,
|
||||
undo,
|
||||
redo,
|
||||
onKeydown,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export { useFileSearch } from './file-search'
|
||||
export { useFileSelection } from './file-selection'
|
||||
export { useFileSorting } from './file-sorting'
|
||||
export { useFileUndoRedo } from './file-undo-redo'
|
||||
3
packages/ui/src/layouts/shared/files-tab/index.ts
Normal file
3
packages/ui/src/layouts/shared/files-tab/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as FilePageLayout } from './layout.vue'
|
||||
export * from './providers'
|
||||
export * from './types'
|
||||
729
packages/ui/src/layouts/shared/files-tab/layout.vue
Normal file
729
packages/ui/src/layouts/shared/files-tab/layout.vue
Normal file
@@ -0,0 +1,729 @@
|
||||
<template>
|
||||
<slot name="modals" />
|
||||
<FileUnsavedChangesModal ref="unsavedChangesModal" />
|
||||
<FileCreateItemModal ref="createItemModal" :type="newItemType" @create="handleCreateNewItem" />
|
||||
<FileUploadConflictModal ref="uploadConflictModal" @proceed="handleExtractConfirm" />
|
||||
<FileUploadZipUrlModal v-if="ctx.showInstallFromUrl" ref="uploadZipUrlModal" />
|
||||
<FileRenameItemModal ref="renameItemModal" :item="selectedItem" @rename="handleRenameItem" />
|
||||
<FileMoveItemModal
|
||||
ref="moveItemModal"
|
||||
:item="selectedItem"
|
||||
:current-path="ctx.currentPath.value"
|
||||
@move="handleMoveItem"
|
||||
/>
|
||||
<FileDeleteItemModal ref="deleteItemModal" :item="selectedItem" @delete="handleDeleteItem" />
|
||||
<FileContextMenu ref="contextMenuRef">
|
||||
<template #extract
|
||||
><PackageOpenIcon class="size-5" />
|
||||
{{ formatMessage(commonMessages.extractButton) }}</template
|
||||
>
|
||||
<template #rename
|
||||
><EditIcon class="size-5" /> {{ formatMessage(commonMessages.renameButton) }}</template
|
||||
>
|
||||
<template #move
|
||||
><RightArrowIcon class="size-5" /> {{ formatMessage(commonMessages.moveButton) }}</template
|
||||
>
|
||||
<template #download
|
||||
><DownloadIcon class="size-5" />
|
||||
{{ ctx.downloadButtonLabel ?? formatMessage(commonMessages.downloadButton) }}</template
|
||||
>
|
||||
<template #delete
|
||||
><TrashIcon class="size-5" /> {{ formatMessage(commonMessages.deleteLabel) }}</template
|
||||
>
|
||||
</FileContextMenu>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="ctx.loading.value && items.length === 0"
|
||||
key="loading"
|
||||
class="mt-6 flex flex-col items-center justify-center gap-2 text-center text-secondary"
|
||||
>
|
||||
<SpinnerIcon class="animate-spin" />
|
||||
{{ formatMessage(messages.loadingFiles) }}
|
||||
</div>
|
||||
|
||||
<div v-else key="content" class="contents">
|
||||
<Admonition v-if="ctx.busyWarning?.value" type="warning" class="mb-5">
|
||||
<template #header>{{ ctx.busyWarning.value }}</template>
|
||||
{{ formatMessage(messages.busyWarning) }}
|
||||
</Admonition>
|
||||
<FileOperationAdmonitions />
|
||||
<div class="relative flex w-full flex-col">
|
||||
<div class="relative isolate flex w-full flex-col gap-4">
|
||||
<FileNavbar
|
||||
:breadcrumbs="breadcrumbSegments"
|
||||
:is-editing="isEditing"
|
||||
:editing-file-name="ctx.editingFile.value?.name"
|
||||
:editing-file-path="ctx.editingFile.value?.path"
|
||||
:is-editing-image="fileEditorRef?.isEditingImage"
|
||||
:search-query="searchQuery"
|
||||
:show-refresh-button="showRefreshButton"
|
||||
:show-install-from-url="ctx.showInstallFromUrl"
|
||||
:base-id="baseId"
|
||||
:disabled="isBusy"
|
||||
:disabled-tooltip="busyTooltip"
|
||||
@navigate="navigateToSegment"
|
||||
@navigate-home="() => navigateToSegment(-1)"
|
||||
@prefetch-home="handlePrefetchHome"
|
||||
@update:search-query="searchQuery = $event"
|
||||
@create="showCreateModal"
|
||||
@upload="initiateFileUpload"
|
||||
@upload-zip="() => {}"
|
||||
@unzip-from-url="showUnzipFromUrlModal"
|
||||
@refresh="ctx.refresh"
|
||||
@share="() => fileEditorRef?.shareToMclogs()"
|
||||
/>
|
||||
|
||||
<div v-if="!isEditing">
|
||||
<FileUploadDragAndDrop
|
||||
ref="fileUploadRef"
|
||||
class="@container relative flex flex-col overflow-clip rounded-[20px] border border-solid border-surface-4 shadow-sm"
|
||||
@files-dropped="handleDroppedFiles"
|
||||
>
|
||||
<FileTableHeader
|
||||
:sort-field="sortField"
|
||||
:sort-desc="sortDescValue"
|
||||
:all-selected="allSelected"
|
||||
:some-selected="someSelected"
|
||||
:is-stuck="isLabelBarStuck"
|
||||
@sort="handleSort"
|
||||
@toggle-all="toggleSelectAll"
|
||||
/>
|
||||
<div
|
||||
v-if="filteredItems.length > 0"
|
||||
ref="virtualListContainer"
|
||||
class="relative w-full"
|
||||
:style="{ minHeight: `${totalHeight}px`, overflowAnchor: 'none' }"
|
||||
>
|
||||
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
|
||||
<FileTableRow
|
||||
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 === filteredItems.length - 1"
|
||||
:selected="selectedItems.has(item.path)"
|
||||
:write-disabled="isBusy"
|
||||
:write-disabled-tooltip="busyTooltip"
|
||||
@extract="() => handleExtractItem(item)"
|
||||
@delete="() => showDeleteModal(item)"
|
||||
@rename="() => showRenameModal(item)"
|
||||
@download="() => handleDownload(item)"
|
||||
@move="() => showMoveModal(item)"
|
||||
@move-direct-to="handleDirectMove"
|
||||
@edit="() => handleEditFile(item)"
|
||||
@navigate="() => handleNavigateToFolder(item)"
|
||||
@hover="() => handleItemHover(item)"
|
||||
@contextmenu="(x, y) => handleContextMenu(item, x, y)"
|
||||
@toggle-select="() => toggleItemSelection(item.path)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0 && !ctx.error.value"
|
||||
class="flex h-full w-full items-center justify-center rounded-b-[20px] bg-surface-2 p-20"
|
||||
>
|
||||
<div class="flex flex-col items-center gap-4 text-center">
|
||||
<FolderOpenIcon class="h-16 w-16 text-secondary" />
|
||||
<h3 class="m-0 text-2xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.emptyFolderTitle) }}
|
||||
</h3>
|
||||
<p class="m-0 text-sm text-secondary">
|
||||
{{ formatMessage(messages.emptyFolderDescription) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<FileManagerError
|
||||
v-else-if="ctx.error.value"
|
||||
class="rounded-b-[20px]"
|
||||
:title="formatMessage(messages.errorTitle)"
|
||||
:message="formatMessage(messages.errorMessage)"
|
||||
@refetch="ctx.refresh"
|
||||
@home="navigateToSegment(-1)"
|
||||
/>
|
||||
</FileUploadDragAndDrop>
|
||||
</div>
|
||||
<FileEditor
|
||||
v-else
|
||||
ref="fileEditorRef"
|
||||
:file="ctx.editingFile.value"
|
||||
:editor-component="editorComponent"
|
||||
@close="handleEditorClose"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FloatingActionBar :shown="hasUnsavedChanges">
|
||||
<p class="m-0 text-sm font-semibold md:text-base">
|
||||
{{ formatMessage(messages.unsavedChanges) }}
|
||||
</p>
|
||||
<div class="ml-auto flex gap-2">
|
||||
<ButtonStyled type="transparent">
|
||||
<button @click="fileEditorRef?.revertChanges()">
|
||||
<HistoryIcon /> {{ formatMessage(commonMessages.resetButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="brand">
|
||||
<button @click="fileEditorRef?.saveFileContent(false)">
|
||||
<SaveIcon /> {{ formatMessage(commonMessages.saveButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
<FloatingActionBar :shown="selectedItems.size > 0">
|
||||
<div class="flex items-center gap-0.5">
|
||||
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
|
||||
{{ formatMessage(messages.selectedCount, { count: selectedItems.size }) }}
|
||||
</span>
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled type="transparent">
|
||||
<button class="!text-primary" @click="deselectAll">
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-0.5">
|
||||
<div class="mx-1 h-6 w-px bg-surface-5" />
|
||||
<ButtonStyled
|
||||
type="transparent"
|
||||
color="red"
|
||||
color-fill="text"
|
||||
hover-color-fill="background"
|
||||
>
|
||||
<button v-tooltip="busyTooltip" :disabled="isBusy" @click="showBulkDeleteModal">
|
||||
<TrashIcon />
|
||||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</FloatingActionBar>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
DownloadIcon,
|
||||
EditIcon,
|
||||
FolderOpenIcon,
|
||||
HistoryIcon,
|
||||
PackageOpenIcon,
|
||||
RightArrowIcon,
|
||||
SaveIcon,
|
||||
SpinnerIcon,
|
||||
TrashIcon,
|
||||
} from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import Admonition from '#ui/components/base/Admonition.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import FloatingActionBar from '#ui/components/base/FloatingActionBar.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { useStickyObserver } from '#ui/composables/sticky-observer'
|
||||
import { useVirtualScroll } from '#ui/composables/virtual-scroll'
|
||||
import { injectNotificationManager } from '#ui/providers/web-notifications'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
import { getFileExtension } from '#ui/utils/file-extensions'
|
||||
|
||||
import FileEditor from './components/editor/FileEditor.vue'
|
||||
import FileContextMenu from './components/FileContextMenu.vue'
|
||||
import FileManagerError from './components/FileManagerError.vue'
|
||||
import FileNavbar from './components/FileNavbar.vue'
|
||||
import FileOperationAdmonitions from './components/FileOperationAdmonitions.vue'
|
||||
import FileTableHeader from './components/FileTableHeader.vue'
|
||||
import FileTableRow from './components/FileTableRow.vue'
|
||||
import FileCreateItemModal from './components/modals/FileCreateItemModal.vue'
|
||||
import FileDeleteItemModal from './components/modals/FileDeleteItemModal.vue'
|
||||
import FileMoveItemModal from './components/modals/FileMoveItemModal.vue'
|
||||
import FileRenameItemModal from './components/modals/FileRenameItemModal.vue'
|
||||
import FileUnsavedChangesModal from './components/modals/FileUnsavedChangesModal.vue'
|
||||
import FileUploadConflictModal from './components/modals/FileUploadConflictModal.vue'
|
||||
import FileUploadZipUrlModal from './components/modals/FileUploadZipUrlModal.vue'
|
||||
import FileUploadDragAndDrop from './components/upload/FileUploadDragAndDrop.vue'
|
||||
import { useFileSearch } from './composables/file-search'
|
||||
import { useFileSelection } from './composables/file-selection'
|
||||
import { useFileSorting } from './composables/file-sorting'
|
||||
import { useFileUndoRedo } from './composables/file-undo-redo'
|
||||
import { injectFileManager } from './providers/file-manager'
|
||||
import type { FileContextMenuOption, FileItem } from './types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
loadingFiles: {
|
||||
id: 'files.layout.loading',
|
||||
defaultMessage: 'Loading files...',
|
||||
},
|
||||
busyWarning: {
|
||||
id: 'files.layout.busy-warning',
|
||||
defaultMessage: 'File operations are disabled while the operation is in progress.',
|
||||
},
|
||||
emptyFolderTitle: {
|
||||
id: 'files.layout.empty-folder-title',
|
||||
defaultMessage: 'This folder is empty',
|
||||
},
|
||||
emptyFolderDescription: {
|
||||
id: 'files.layout.empty-folder-description',
|
||||
defaultMessage: 'There are no files or folders.',
|
||||
},
|
||||
errorTitle: {
|
||||
id: 'files.layout.error-title',
|
||||
defaultMessage: 'Unable to load files',
|
||||
},
|
||||
errorMessage: {
|
||||
id: 'files.layout.error-message',
|
||||
defaultMessage: 'The folder may not exist.',
|
||||
},
|
||||
selectedCount: {
|
||||
id: 'files.layout.selected-count',
|
||||
defaultMessage: '{count} selected',
|
||||
},
|
||||
dryRunFailedTitle: {
|
||||
id: 'files.layout.dry-run-failed-title',
|
||||
defaultMessage: 'Dry run failed',
|
||||
},
|
||||
dryRunFailedText: {
|
||||
id: 'files.layout.dry-run-failed-text',
|
||||
defaultMessage: 'Error running dry run',
|
||||
},
|
||||
extractionStartedTitle: {
|
||||
id: 'files.layout.extraction-started-title',
|
||||
defaultMessage: 'Extraction started',
|
||||
},
|
||||
unsavedChanges: {
|
||||
id: 'files.layout.unsaved-changes',
|
||||
defaultMessage: 'You have unsaved changes.',
|
||||
},
|
||||
})
|
||||
|
||||
defineProps<{
|
||||
showDebugInfo?: boolean
|
||||
showRefreshButton?: boolean
|
||||
}>()
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const ctx = injectFileManager()
|
||||
|
||||
const editorComponent = shallowRef<Component | null>(null)
|
||||
import('vue3-ace-editor').then(async (mod) => {
|
||||
await Promise.all([import('#ui/utils/ace-theme'), import('#ui/utils/ace-mode-log.ts')])
|
||||
editorComponent.value = mod.VAceEditor
|
||||
})
|
||||
|
||||
const baseId = `files-${Math.random().toString(36).slice(2, 9)}`
|
||||
|
||||
const items = computed(() => ctx.items.value)
|
||||
const isEditing = computed(() => ctx.editingFile.value !== null)
|
||||
const isBusy = computed(() => ctx.isBusy?.value ?? false)
|
||||
const busyTooltip = computed(() => ctx.busyTooltip?.value)
|
||||
|
||||
const breadcrumbSegments = computed(() => {
|
||||
const path = ctx.currentPath.value
|
||||
if (typeof path === 'string') {
|
||||
return path.split('/').filter(Boolean)
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// Composables
|
||||
const { searchQuery, searchedItems } = useFileSearch(items)
|
||||
const {
|
||||
sortField,
|
||||
sortDesc: sortDescValue,
|
||||
handleSort,
|
||||
sortedItems: filteredItems,
|
||||
resetSort,
|
||||
} = useFileSorting(searchedItems)
|
||||
|
||||
const {
|
||||
selectedItems,
|
||||
toggleItemSelection,
|
||||
deselectAll,
|
||||
toggleSelectAll,
|
||||
allSelected,
|
||||
someSelected,
|
||||
} = useFileSelection(filteredItems)
|
||||
|
||||
const { recordOperation, onKeydown } = useFileUndoRedo(
|
||||
(path, newName) => ctx.renameItem(path, newName),
|
||||
(source, dest) => ctx.moveItem(source, dest),
|
||||
() => ctx.refresh(),
|
||||
(title, text, type) => addNotification({ title, text, type }),
|
||||
)
|
||||
|
||||
// Virtual scroll
|
||||
const {
|
||||
listContainer: virtualListContainer,
|
||||
totalHeight,
|
||||
visibleRange,
|
||||
visibleTop,
|
||||
visibleItems,
|
||||
} = useVirtualScroll(filteredItems, {
|
||||
itemHeight: 61,
|
||||
bufferSize: 5,
|
||||
})
|
||||
|
||||
// Sticky observer for the table header
|
||||
const fileUploadRef = ref<InstanceType<typeof FileUploadDragAndDrop>>()
|
||||
const fileUploadEl = computed(() => fileUploadRef.value?.$el as HTMLElement | null)
|
||||
const { isStuck: isLabelBarStuck } = useStickyObserver(fileUploadEl)
|
||||
|
||||
// Refs
|
||||
const fileEditorRef = ref<InstanceType<typeof FileEditor>>()
|
||||
const createItemModal = ref<InstanceType<typeof FileCreateItemModal>>()
|
||||
const renameItemModal = ref<InstanceType<typeof FileRenameItemModal>>()
|
||||
const moveItemModal = ref<InstanceType<typeof FileMoveItemModal>>()
|
||||
const deleteItemModal = ref<InstanceType<typeof FileDeleteItemModal>>()
|
||||
const uploadConflictModal = ref<InstanceType<typeof FileUploadConflictModal>>()
|
||||
const uploadZipUrlModal = ref<InstanceType<typeof FileUploadZipUrlModal>>()
|
||||
const contextMenuRef = ref<InstanceType<typeof FileContextMenu>>()
|
||||
|
||||
const newItemType = ref<'file' | 'directory'>('file')
|
||||
const selectedItem = ref<FileItem | null>(null)
|
||||
|
||||
const unsavedChangesModal = ref<InstanceType<typeof FileUnsavedChangesModal>>()
|
||||
|
||||
const hasUnsavedChanges = computed(() => fileEditorRef.value?.hasUnsavedChanges ?? false)
|
||||
|
||||
async function confirmDiscardChanges(): Promise<boolean> {
|
||||
if (!hasUnsavedChanges.value) return true
|
||||
const result = await unsavedChangesModal.value?.prompt()
|
||||
if (result === 'save') {
|
||||
await fileEditorRef.value?.saveFileContent(false)
|
||||
return true
|
||||
}
|
||||
return result === 'discard'
|
||||
}
|
||||
|
||||
// Navigation
|
||||
async function navigateToSegment(index: number) {
|
||||
const newPath = index === -1 ? '/' : breadcrumbSegments.value.slice(0, index + 1).join('/')
|
||||
|
||||
if (newPath === ctx.currentPath.value && !isEditing.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isEditing.value) {
|
||||
if (!(await confirmDiscardChanges())) return
|
||||
ctx.stopEditing()
|
||||
}
|
||||
|
||||
ctx.navigateTo(newPath)
|
||||
}
|
||||
|
||||
function handleNavigateToFolder(item: FileItem) {
|
||||
const currentPath = ctx.currentPath.value
|
||||
const newPath = currentPath.endsWith('/')
|
||||
? `${currentPath}${item.name}`
|
||||
: `${currentPath}/${item.name}`
|
||||
ctx.navigateTo(newPath)
|
||||
}
|
||||
|
||||
// Editing
|
||||
function handleEditFile(item: { name: string; type: string; path: string }) {
|
||||
ctx.startEditing({ name: item.name, path: item.path })
|
||||
}
|
||||
|
||||
async function handleEditorClose() {
|
||||
if (!(await confirmDiscardChanges())) return
|
||||
ctx.stopEditing()
|
||||
}
|
||||
|
||||
// CRUD handlers
|
||||
async function handleCreateNewItem(name: string) {
|
||||
await ctx.createItem(name, newItemType.value)
|
||||
}
|
||||
|
||||
async function handleRenameItem(newName: string) {
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
const path = `${ctx.currentPath.value}/${item.name}`.replace('//', '/')
|
||||
await ctx.renameItem(path, newName)
|
||||
recordOperation({
|
||||
type: 'rename',
|
||||
itemType: item.type,
|
||||
fileName: item.name,
|
||||
path: ctx.currentPath.value,
|
||||
oldName: item.name,
|
||||
newName,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleMoveItem(destination: string) {
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
const sourcePath = ctx.currentPath.value
|
||||
const source = `${sourcePath}/${item.name}`.replace('//', '/')
|
||||
const dest = `${destination}/${item.name}`.replace('//', '/')
|
||||
|
||||
await ctx.moveItem(source, dest)
|
||||
recordOperation({
|
||||
type: 'move',
|
||||
sourcePath,
|
||||
destinationPath: destination,
|
||||
fileName: item.name,
|
||||
itemType: item.type,
|
||||
})
|
||||
}
|
||||
|
||||
function handleDeleteItem() {
|
||||
const item = selectedItem.value
|
||||
if (!item) return
|
||||
|
||||
const path = `${ctx.currentPath.value}/${item.name}`.replace('//', '/')
|
||||
ctx.deleteItem(path, item.type === 'directory')
|
||||
}
|
||||
|
||||
function handleDirectMove(moveData: {
|
||||
name: string
|
||||
type: string
|
||||
path: string
|
||||
destination: string
|
||||
}) {
|
||||
if (isBusy.value) return
|
||||
const dest = `${moveData.destination}/${moveData.name}`.replace('//', '/')
|
||||
const sourcePath = moveData.path.substring(0, moveData.path.lastIndexOf('/'))
|
||||
|
||||
ctx.moveItem(moveData.path, dest).then(() => {
|
||||
recordOperation({
|
||||
type: 'move',
|
||||
sourcePath,
|
||||
destinationPath: moveData.destination,
|
||||
fileName: moveData.name,
|
||||
itemType: moveData.type,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Download
|
||||
async function handleDownload(item: FileItem) {
|
||||
if (item.type === 'file') {
|
||||
await ctx.downloadFile(item.path, item.name)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract
|
||||
async function handleExtractItem(item: { name: string; type: string; path: string }) {
|
||||
if (isBusy.value || !ctx.extractFile) return
|
||||
try {
|
||||
const dry = await ctx.extractFile(item.path, true, true)
|
||||
if (dry) {
|
||||
if (dry.conflicting_files.length === 0) {
|
||||
handleExtractConfirm(item.path)
|
||||
} else {
|
||||
uploadConflictModal.value?.show(item.path, dry.conflicting_files)
|
||||
}
|
||||
} else {
|
||||
addNotification({
|
||||
title: formatMessage(messages.dryRunFailedTitle),
|
||||
text: formatMessage(messages.dryRunFailedText),
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.extractFailedLabel),
|
||||
text: error instanceof Error ? error.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExtractConfirm(path: string) {
|
||||
if (!ctx.extractFile) return
|
||||
try {
|
||||
await ctx.extractFile(path, true, false)
|
||||
addNotification({ title: formatMessage(messages.extractionStartedTitle), type: 'success' })
|
||||
} catch (error) {
|
||||
addNotification({
|
||||
title: formatMessage(commonMessages.extractFailedLabel),
|
||||
text: error instanceof Error ? error.message : '',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Modal show helpers
|
||||
function showCreateModal(type: 'file' | 'directory') {
|
||||
if (isBusy.value) return
|
||||
newItemType.value = type
|
||||
createItemModal.value?.show()
|
||||
}
|
||||
|
||||
function showUnzipFromUrlModal(cf: boolean) {
|
||||
if (isBusy.value) return
|
||||
uploadZipUrlModal.value?.show(cf)
|
||||
}
|
||||
|
||||
function showRenameModal(item: FileItem) {
|
||||
if (isBusy.value) return
|
||||
selectedItem.value = item
|
||||
renameItemModal.value?.show(item)
|
||||
}
|
||||
|
||||
function showMoveModal(item: FileItem) {
|
||||
if (isBusy.value) return
|
||||
selectedItem.value = item
|
||||
moveItemModal.value?.show()
|
||||
}
|
||||
|
||||
function showDeleteModal(item: FileItem) {
|
||||
if (isBusy.value) return
|
||||
selectedItem.value = item
|
||||
deleteItemModal.value?.show()
|
||||
}
|
||||
|
||||
function showBulkDeleteModal() {
|
||||
if (isBusy.value) return
|
||||
if (selectedItems.value.size === 0) return
|
||||
|
||||
const itemsToDelete = Array.from(selectedItems.value)
|
||||
for (const path of itemsToDelete) {
|
||||
const item = items.value.find((i) => i.path === path)
|
||||
if (item) {
|
||||
ctx.deleteItem(path, item.type === 'directory')
|
||||
}
|
||||
}
|
||||
deselectAll()
|
||||
}
|
||||
|
||||
// Upload
|
||||
function handleDroppedFiles(files: File[]) {
|
||||
if (isEditing.value || isBusy.value) return
|
||||
ctx.uploadFiles(files)
|
||||
}
|
||||
|
||||
function initiateFileUpload() {
|
||||
if (isBusy.value) return
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.multiple = true
|
||||
input.onchange = () => {
|
||||
if (input.files) {
|
||||
ctx.uploadFiles(Array.from(input.files))
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
|
||||
// Prefetch
|
||||
let prefetchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
let prefetchHomeTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function handleItemHover(item: { type: string; path: string; name: string }) {
|
||||
if (prefetchTimeout) {
|
||||
clearTimeout(prefetchTimeout)
|
||||
prefetchTimeout = null
|
||||
}
|
||||
|
||||
if (item.type === 'directory') {
|
||||
prefetchTimeout = setTimeout(() => {
|
||||
const currentPath = ctx.currentPath.value
|
||||
const navPath = currentPath.endsWith('/')
|
||||
? `${currentPath}${item.name}`
|
||||
: `${currentPath}/${item.name}`
|
||||
ctx.prefetchDirectory?.(navPath)
|
||||
}, 150)
|
||||
} else {
|
||||
prefetchTimeout = setTimeout(() => {
|
||||
ctx.prefetchFile?.(item.path)
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
|
||||
function handlePrefetchHome() {
|
||||
if (prefetchHomeTimeout) {
|
||||
clearTimeout(prefetchHomeTimeout)
|
||||
prefetchHomeTimeout = null
|
||||
}
|
||||
prefetchHomeTimeout = setTimeout(() => {
|
||||
ctx.prefetchDirectory?.('/')
|
||||
}, 150)
|
||||
}
|
||||
|
||||
// Context menu
|
||||
function handleContextMenu(item: FileItem, x: number, y: number) {
|
||||
const wd = isBusy.value
|
||||
const wdTooltip = busyTooltip.value
|
||||
const isZip = getFileExtension(item.name) === 'zip'
|
||||
|
||||
const options: FileContextMenuOption[] = [
|
||||
{
|
||||
id: 'extract',
|
||||
shown: isZip && !!ctx.extractFile,
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => handleExtractItem(item),
|
||||
},
|
||||
{ divider: true, shown: isZip && !!ctx.extractFile },
|
||||
{
|
||||
id: 'rename',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showRenameModal(item),
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showMoveModal(item),
|
||||
},
|
||||
{
|
||||
id: 'download',
|
||||
action: () => handleDownload(item),
|
||||
shown: item.type !== 'directory',
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
disabled: wd,
|
||||
tooltip: wd ? wdTooltip : undefined,
|
||||
action: () => showDeleteModal(item),
|
||||
color: 'red',
|
||||
},
|
||||
]
|
||||
|
||||
contextMenuRef.value?.show(item, x, y, options)
|
||||
}
|
||||
|
||||
// Reset search/sort/selection on path change
|
||||
watch(
|
||||
() => ctx.currentPath.value,
|
||||
() => {
|
||||
searchQuery.value = ''
|
||||
resetSort()
|
||||
deselectAll()
|
||||
},
|
||||
)
|
||||
|
||||
// Keyboard shortcuts
|
||||
onMounted(() => {
|
||||
document.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 300ms ease-in-out,
|
||||
transform 300ms ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ComputedRef, Ref } from 'vue'
|
||||
|
||||
import { createContext } from '#ui/providers/create-context'
|
||||
|
||||
import type {
|
||||
EditingFile,
|
||||
ExtractDryRunResult,
|
||||
FileItem,
|
||||
FileOperation,
|
||||
UploadState,
|
||||
} from '../types'
|
||||
|
||||
export interface FileManagerContext {
|
||||
items: Ref<FileItem[]>
|
||||
loading: Ref<boolean>
|
||||
error: Ref<Error | null>
|
||||
|
||||
currentPath: Ref<string>
|
||||
navigateTo: (path: string) => void
|
||||
|
||||
editingFile: Ref<EditingFile | null>
|
||||
startEditing: (file: EditingFile) => void
|
||||
stopEditing: () => void
|
||||
|
||||
createItem: (name: string, type: 'file' | 'directory') => Promise<void>
|
||||
renameItem: (path: string, newName: string) => Promise<void>
|
||||
moveItem: (source: string, destination: string) => Promise<void>
|
||||
deleteItem: (path: string, recursive: boolean) => Promise<void>
|
||||
|
||||
readFile: (path: string) => Promise<string>
|
||||
readFileAsBlob: (path: string) => Promise<Blob>
|
||||
writeFile: (path: string, content: string) => Promise<void>
|
||||
downloadFile: (path: string, fileName: string) => Promise<void>
|
||||
|
||||
uploadFiles: (files: File[]) => void
|
||||
cancelUpload?: () => void
|
||||
uploadState?: Ref<UploadState> | ComputedRef<UploadState>
|
||||
|
||||
refresh: () => void
|
||||
|
||||
isBusy?: Ref<boolean> | ComputedRef<boolean>
|
||||
busyTooltip?: Ref<string | undefined> | ComputedRef<string | undefined>
|
||||
busyWarning?: Ref<string | null> | ComputedRef<string | null>
|
||||
|
||||
extractFile?: (
|
||||
path: string,
|
||||
override: boolean,
|
||||
dry: boolean,
|
||||
) => Promise<ExtractDryRunResult | void>
|
||||
activeOperations?: Ref<FileOperation[]> | ComputedRef<FileOperation[]>
|
||||
dismissOperation?: (id: string, action: 'dismiss' | 'cancel') => void
|
||||
|
||||
prefetchDirectory?: (path: string) => void
|
||||
prefetchFile?: (path: string) => void
|
||||
|
||||
showInstallFromUrl?: boolean
|
||||
basePath?: Ref<string> | ComputedRef<string>
|
||||
openInFolder?: (path: string) => void
|
||||
|
||||
downloadButtonLabel?: string
|
||||
uploadingLabel?: (completed: number, total: number) => string
|
||||
|
||||
canRestart?: boolean
|
||||
restartServer?: () => Promise<void>
|
||||
canShareToMclogs?: boolean
|
||||
shareToMclogs?: (content: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const [injectFileManager, provideFileManager] = createContext<FileManagerContext>(
|
||||
'FilePageLayout',
|
||||
'fileManagerContext',
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { FileManagerContext } from './file-manager'
|
||||
export { injectFileManager, provideFileManager } from './file-manager'
|
||||
69
packages/ui/src/layouts/shared/files-tab/types.ts
Normal file
69
packages/ui/src/layouts/shared/files-tab/types.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
export interface FileItem {
|
||||
name: string
|
||||
type: 'file' | 'directory' | 'symlink'
|
||||
path: string
|
||||
modified: number
|
||||
created: number
|
||||
size?: number
|
||||
count?: number
|
||||
target?: string
|
||||
}
|
||||
|
||||
export interface EditingFile {
|
||||
name: string
|
||||
path: string
|
||||
}
|
||||
|
||||
export type FileSortField = 'name' | 'size' | 'created' | 'modified'
|
||||
|
||||
export type FileViewFilter = 'all' | 'filesOnly' | 'foldersOnly'
|
||||
|
||||
export type FileContextMenuOption =
|
||||
| {
|
||||
id: string
|
||||
action?: () => void
|
||||
disabled?: boolean
|
||||
tooltip?: string
|
||||
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
|
||||
shown?: boolean
|
||||
}
|
||||
| { divider: true; shown?: boolean }
|
||||
|
||||
export interface FileOperation {
|
||||
id?: string
|
||||
op: string
|
||||
src: string
|
||||
state: string
|
||||
progress?: number
|
||||
bytes_processed?: number
|
||||
files_processed?: number
|
||||
current_file?: string
|
||||
}
|
||||
|
||||
export interface UndoableOperation {
|
||||
type: 'move' | 'rename'
|
||||
itemType: string
|
||||
fileName: string
|
||||
}
|
||||
|
||||
export interface MoveOperation extends UndoableOperation {
|
||||
type: 'move'
|
||||
sourcePath: string
|
||||
destinationPath: string
|
||||
}
|
||||
|
||||
export interface RenameOperation extends UndoableOperation {
|
||||
type: 'rename'
|
||||
path: string
|
||||
oldName: string
|
||||
newName: string
|
||||
}
|
||||
|
||||
export type Operation = MoveOperation | RenameOperation
|
||||
|
||||
export interface ExtractDryRunResult {
|
||||
modpack_name: string | null
|
||||
conflicting_files: string[]
|
||||
}
|
||||
|
||||
export type { UploadState } from '@modrinth/api-client'
|
||||
@@ -21,10 +21,10 @@ import Avatar from '#ui/components/base/Avatar.vue'
|
||||
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
|
||||
import Chips from '#ui/components/base/Chips.vue'
|
||||
import Combobox from '#ui/components/base/Combobox.vue'
|
||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import ConfirmLeaveModal from '../content-tab/components/modals/ConfirmLeaveModal.vue'
|
||||
import ConfirmModpackUpdateModal from '../content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||
import ConfirmReinstallModal from '../content-tab/components/modals/ConfirmReinstallModal.vue'
|
||||
import ConfirmRepairModal from '../content-tab/components/modals/ConfirmRepairModal.vue'
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import ConfirmLeaveModal from '#ui/components/modal/ConfirmLeaveModal.vue'
|
||||
import { defineMessages, useVIntl } from '#ui/composables/i18n'
|
||||
import {
|
||||
injectModrinthClient,
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
} from '#ui/providers'
|
||||
import { commonMessages } from '#ui/utils/common-messages'
|
||||
|
||||
import ConfirmLeaveModal from '../../../shared/content-tab/components/modals/ConfirmLeaveModal.vue'
|
||||
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
|
||||
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
|
||||
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/ContentUpdaterModal.vue'
|
||||
@@ -78,9 +78,17 @@ const messages = defineMessages({
|
||||
id: 'hosting.content.failed-to-bulk-update',
|
||||
defaultMessage: 'Failed to update content',
|
||||
},
|
||||
copyLink: {
|
||||
id: 'hosting.content.copy-link',
|
||||
defaultMessage: 'Copy link',
|
||||
})
|
||||
|
||||
const leaveMessages = defineMessages({
|
||||
uploadInProgress: {
|
||||
id: 'instances.confirm-leave-modal.upload-in-progress',
|
||||
defaultMessage: 'Upload in progress',
|
||||
},
|
||||
leavePageBody: {
|
||||
id: 'instances.confirm-leave-modal.body',
|
||||
defaultMessage:
|
||||
'Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost.',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -168,7 +176,7 @@ const modpack = computed<ContentModpackData | null>(() => {
|
||||
: `/user/${mp.owner.id}`,
|
||||
}
|
||||
: undefined,
|
||||
categories: (project?.display_categories ?? []).map((name) => ({
|
||||
categories: (project?.categories ?? []).map((name) => ({
|
||||
name,
|
||||
icon: name,
|
||||
project_type: 'modpack',
|
||||
@@ -824,7 +832,7 @@ function getOverflowOptions(item: ContentItem) {
|
||||
|
||||
if (item.project?.slug) {
|
||||
options.push({
|
||||
id: formatMessage(messages.copyLink),
|
||||
id: formatMessage(commonMessages.copyLinkButton),
|
||||
icon: ClipboardCopyIcon,
|
||||
action: async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
@@ -961,5 +969,10 @@ provideContentManager({
|
||||
@confirm="handleModpackUpdateConfirm"
|
||||
@cancel="handleModpackUpdateCancel"
|
||||
/>
|
||||
<ConfirmLeaveModal ref="confirmLeaveModal" />
|
||||
<ConfirmLeaveModal
|
||||
ref="confirmLeaveModal"
|
||||
:header="formatMessage(leaveMessages.uploadInProgress)"
|
||||
:body="formatMessage(leaveMessages.leavePageBody)"
|
||||
admonition-type="critical"
|
||||
/>
|
||||
</template>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -68,9 +68,18 @@
|
||||
"button.continue": {
|
||||
"defaultMessage": "Continue"
|
||||
},
|
||||
"button.copy-filename": {
|
||||
"defaultMessage": "Copy filename"
|
||||
},
|
||||
"button.copy-full-path": {
|
||||
"defaultMessage": "Copy full path"
|
||||
},
|
||||
"button.copy-id": {
|
||||
"defaultMessage": "Copy ID"
|
||||
},
|
||||
"button.copy-link": {
|
||||
"defaultMessage": "Copy link"
|
||||
},
|
||||
"button.copy-permalink": {
|
||||
"defaultMessage": "Copy permanent link"
|
||||
},
|
||||
@@ -80,6 +89,9 @@
|
||||
"button.decline": {
|
||||
"defaultMessage": "Decline"
|
||||
},
|
||||
"button.disable": {
|
||||
"defaultMessage": "Disable"
|
||||
},
|
||||
"button.download": {
|
||||
"defaultMessage": "Download"
|
||||
},
|
||||
@@ -89,6 +101,12 @@
|
||||
"button.edit": {
|
||||
"defaultMessage": "Edit"
|
||||
},
|
||||
"button.enable": {
|
||||
"defaultMessage": "Enable"
|
||||
},
|
||||
"button.extract": {
|
||||
"defaultMessage": "Extract"
|
||||
},
|
||||
"button.follow": {
|
||||
"defaultMessage": "Follow"
|
||||
},
|
||||
@@ -101,12 +119,18 @@
|
||||
"button.more-options": {
|
||||
"defaultMessage": "More options"
|
||||
},
|
||||
"button.move": {
|
||||
"defaultMessage": "Move"
|
||||
},
|
||||
"button.next": {
|
||||
"defaultMessage": "Next"
|
||||
},
|
||||
"button.open-folder": {
|
||||
"defaultMessage": "Open folder"
|
||||
},
|
||||
"button.open-in-folder": {
|
||||
"defaultMessage": "Open in folder"
|
||||
},
|
||||
"button.play": {
|
||||
"defaultMessage": "Play"
|
||||
},
|
||||
@@ -122,6 +146,9 @@
|
||||
"button.remove-image": {
|
||||
"defaultMessage": "Remove image"
|
||||
},
|
||||
"button.rename": {
|
||||
"defaultMessage": "Rename"
|
||||
},
|
||||
"button.repair": {
|
||||
"defaultMessage": "Repair"
|
||||
},
|
||||
@@ -152,6 +179,9 @@
|
||||
"button.show-all-versions": {
|
||||
"defaultMessage": "Show all versions"
|
||||
},
|
||||
"button.show-file": {
|
||||
"defaultMessage": "Show file"
|
||||
},
|
||||
"button.sign-in": {
|
||||
"defaultMessage": "Sign in"
|
||||
},
|
||||
@@ -221,6 +251,9 @@
|
||||
"content.confirm-modpack-update.admonition-body": {
|
||||
"defaultMessage": "Any mods or content you added on top of the modpack will be deleted."
|
||||
},
|
||||
"content.confirm-modpack-update.admonition-body-app": {
|
||||
"defaultMessage": "Any mods or content you added on top of the modpack will be preserved."
|
||||
},
|
||||
"content.confirm-modpack-update.admonition-header": {
|
||||
"defaultMessage": "{action, select, downgrade {Downgrade} other {Update}} warning"
|
||||
},
|
||||
@@ -296,9 +329,6 @@
|
||||
"content.modpack-card.dismiss-hint": {
|
||||
"defaultMessage": "Don't show again"
|
||||
},
|
||||
"content.modpack-card.updating": {
|
||||
"defaultMessage": "Updating..."
|
||||
},
|
||||
"content.page-layout.additional-content": {
|
||||
"defaultMessage": "Additional content"
|
||||
},
|
||||
@@ -401,18 +431,345 @@
|
||||
"content.selection-bar.bulk.updating-waiting": {
|
||||
"defaultMessage": "Updating {contentType}..."
|
||||
},
|
||||
"content.selection-bar.disable": {
|
||||
"defaultMessage": "Disable"
|
||||
},
|
||||
"content.selection-bar.enable": {
|
||||
"defaultMessage": "Enable"
|
||||
},
|
||||
"content.selection-bar.selected-count": {
|
||||
"defaultMessage": "{count} {contentType} selected"
|
||||
},
|
||||
"content.selection-bar.selected-count-simple": {
|
||||
"defaultMessage": "{count, number} selected"
|
||||
},
|
||||
"files.conflict-modal.header": {
|
||||
"defaultMessage": "Extract summary"
|
||||
},
|
||||
"files.conflict-modal.overwrite-button": {
|
||||
"defaultMessage": "Overwrite"
|
||||
},
|
||||
"files.conflict-modal.overwrite-many-warning": {
|
||||
"defaultMessage": "Over 100 files will be overwritten if you proceed with extraction; here are some of them."
|
||||
},
|
||||
"files.conflict-modal.overwrite-warning": {
|
||||
"defaultMessage": "The following {count} files already exist on your server, and will be overwritten if you proceed with extraction."
|
||||
},
|
||||
"files.conflict-modal.overwritten-count": {
|
||||
"defaultMessage": "{count} overwritten"
|
||||
},
|
||||
"files.conflict-modal.overwritten-label": {
|
||||
"defaultMessage": "Overwritten"
|
||||
},
|
||||
"files.conflict-modal.warning-header": {
|
||||
"defaultMessage": "Files will be overwritten"
|
||||
},
|
||||
"files.create-modal.create-button": {
|
||||
"defaultMessage": "Create {type, select, directory {folder} other {file}}"
|
||||
},
|
||||
"files.create-modal.header": {
|
||||
"defaultMessage": "Create a {type, select, directory {folder} other {file}}"
|
||||
},
|
||||
"files.create-modal.placeholder-directory": {
|
||||
"defaultMessage": "e.g. my-folder"
|
||||
},
|
||||
"files.create-modal.placeholder-file": {
|
||||
"defaultMessage": "e.g. config.yml"
|
||||
},
|
||||
"files.delete-modal.deleting-name": {
|
||||
"defaultMessage": "Deleting \"{name}\""
|
||||
},
|
||||
"files.delete-modal.header": {
|
||||
"defaultMessage": "Delete file"
|
||||
},
|
||||
"files.delete-modal.warning": {
|
||||
"defaultMessage": "{type, select, directory {This folder and all its contents will be permanently deleted. This action cannot be undone.} other {This file will be permanently deleted. This action cannot be undone.}}"
|
||||
},
|
||||
"files.editor.failed-to-open-text": {
|
||||
"defaultMessage": "Could not load file contents."
|
||||
},
|
||||
"files.editor.failed-to-open-title": {
|
||||
"defaultMessage": "Failed to open file"
|
||||
},
|
||||
"files.editor.failed-to-share-text": {
|
||||
"defaultMessage": "Could not upload to mclo.gs."
|
||||
},
|
||||
"files.editor.failed-to-share-title": {
|
||||
"defaultMessage": "Failed to share file"
|
||||
},
|
||||
"files.editor.file-saved-text": {
|
||||
"defaultMessage": "Your file has been saved."
|
||||
},
|
||||
"files.editor.file-saved-title": {
|
||||
"defaultMessage": "File saved"
|
||||
},
|
||||
"files.editor.log-url-copied-text": {
|
||||
"defaultMessage": "Your log file URL has been copied to your clipboard."
|
||||
},
|
||||
"files.editor.log-url-copied-title": {
|
||||
"defaultMessage": "Log URL copied"
|
||||
},
|
||||
"files.editor.save-failed-text": {
|
||||
"defaultMessage": "Could not save the file."
|
||||
},
|
||||
"files.editor.save-failed-title": {
|
||||
"defaultMessage": "Save failed"
|
||||
},
|
||||
"files.error.go-to-home": {
|
||||
"defaultMessage": "Go to home folder"
|
||||
},
|
||||
"files.error.try-again": {
|
||||
"defaultMessage": "Try again"
|
||||
},
|
||||
"files.image_viewer.image_too_large": {
|
||||
"defaultMessage": "Image too large to view (max {maxDimension}x{maxDimension} pixels)"
|
||||
},
|
||||
"files.image_viewer.invalid_image": {
|
||||
"defaultMessage": "Invalid or empty image file."
|
||||
},
|
||||
"files.image_viewer.load_failed": {
|
||||
"defaultMessage": "Failed to load image"
|
||||
},
|
||||
"files.image_viewer.reset_zoom": {
|
||||
"defaultMessage": "Reset zoom"
|
||||
},
|
||||
"files.image_viewer.viewed_image_alt": {
|
||||
"defaultMessage": "Viewed image"
|
||||
},
|
||||
"files.image_viewer.zoom_in": {
|
||||
"defaultMessage": "Zoom in"
|
||||
},
|
||||
"files.image_viewer.zoom_out": {
|
||||
"defaultMessage": "Zoom out"
|
||||
},
|
||||
"files.layout.busy-warning": {
|
||||
"defaultMessage": "File operations are disabled while the operation is in progress."
|
||||
},
|
||||
"files.layout.dry-run-failed-text": {
|
||||
"defaultMessage": "Error running dry run"
|
||||
},
|
||||
"files.layout.dry-run-failed-title": {
|
||||
"defaultMessage": "Dry run failed"
|
||||
},
|
||||
"files.layout.empty-folder-description": {
|
||||
"defaultMessage": "There are no files or folders."
|
||||
},
|
||||
"files.layout.empty-folder-title": {
|
||||
"defaultMessage": "This folder is empty"
|
||||
},
|
||||
"files.layout.error-message": {
|
||||
"defaultMessage": "The folder may not exist."
|
||||
},
|
||||
"files.layout.error-title": {
|
||||
"defaultMessage": "Unable to load files"
|
||||
},
|
||||
"files.layout.extraction-started-title": {
|
||||
"defaultMessage": "Extraction started"
|
||||
},
|
||||
"files.layout.loading": {
|
||||
"defaultMessage": "Loading files..."
|
||||
},
|
||||
"files.layout.selected-count": {
|
||||
"defaultMessage": "{count} selected"
|
||||
},
|
||||
"files.layout.unsaved-changes": {
|
||||
"defaultMessage": "You have unsaved changes."
|
||||
},
|
||||
"files.move-modal.current-location": {
|
||||
"defaultMessage": "Current location"
|
||||
},
|
||||
"files.move-modal.destination-path": {
|
||||
"defaultMessage": "Destination path"
|
||||
},
|
||||
"files.move-modal.destination-placeholder": {
|
||||
"defaultMessage": "e.g. /my-folder"
|
||||
},
|
||||
"files.move-modal.header": {
|
||||
"defaultMessage": "{type, select, directory {Move folder} other {Move file}}"
|
||||
},
|
||||
"files.navbar.back-to-home": {
|
||||
"defaultMessage": "Back to home"
|
||||
},
|
||||
"files.navbar.breadcrumb-navigation": {
|
||||
"defaultMessage": "Breadcrumb navigation"
|
||||
},
|
||||
"files.navbar.create-new": {
|
||||
"defaultMessage": "Create new..."
|
||||
},
|
||||
"files.navbar.file-navigation": {
|
||||
"defaultMessage": "File navigation"
|
||||
},
|
||||
"files.navbar.home": {
|
||||
"defaultMessage": "Home"
|
||||
},
|
||||
"files.navbar.install-curseforge-pack": {
|
||||
"defaultMessage": "Install CurseForge pack"
|
||||
},
|
||||
"files.navbar.new-file": {
|
||||
"defaultMessage": "New file"
|
||||
},
|
||||
"files.navbar.new-folder": {
|
||||
"defaultMessage": "New folder"
|
||||
},
|
||||
"files.navbar.search-files": {
|
||||
"defaultMessage": "Search files"
|
||||
},
|
||||
"files.navbar.share-to-mclogs": {
|
||||
"defaultMessage": "Share to mclo.gs"
|
||||
},
|
||||
"files.navbar.upload-file": {
|
||||
"defaultMessage": "Upload file"
|
||||
},
|
||||
"files.navbar.upload-from-zip": {
|
||||
"defaultMessage": "Upload from .zip file"
|
||||
},
|
||||
"files.navbar.upload-from-zip-url": {
|
||||
"defaultMessage": "Upload from .zip URL"
|
||||
},
|
||||
"files.operations.extracted": {
|
||||
"defaultMessage": "{size} extracted"
|
||||
},
|
||||
"files.operations.extracting": {
|
||||
"defaultMessage": "Extracting {source}"
|
||||
},
|
||||
"files.operations.failed": {
|
||||
"defaultMessage": "Failed"
|
||||
},
|
||||
"files.operations.modpack-from-url": {
|
||||
"defaultMessage": "modpack from URL"
|
||||
},
|
||||
"files.operations.upload-progress": {
|
||||
"defaultMessage": "{uploaded} / {total} ({percent}%)"
|
||||
},
|
||||
"files.operations.uploading-files": {
|
||||
"defaultMessage": "Uploading files ({completed}/{total})"
|
||||
},
|
||||
"files.rename-modal.header": {
|
||||
"defaultMessage": "Rename {name}"
|
||||
},
|
||||
"files.rename-modal.new-name-label": {
|
||||
"defaultMessage": "New name"
|
||||
},
|
||||
"files.row.item-count": {
|
||||
"defaultMessage": "{count, plural, one {# item} other {# items}}"
|
||||
},
|
||||
"files.table-header.created": {
|
||||
"defaultMessage": "Created"
|
||||
},
|
||||
"files.table-header.modified": {
|
||||
"defaultMessage": "Modified"
|
||||
},
|
||||
"files.table-header.name": {
|
||||
"defaultMessage": "Name"
|
||||
},
|
||||
"files.table-header.size": {
|
||||
"defaultMessage": "Size"
|
||||
},
|
||||
"files.unsaved-changes-modal.body": {
|
||||
"defaultMessage": "You have unsaved changes that will be lost if you leave. Would you like to save before leaving?"
|
||||
},
|
||||
"files.unsaved-changes-modal.discard": {
|
||||
"defaultMessage": "Discard"
|
||||
},
|
||||
"files.unsaved-changes-modal.header": {
|
||||
"defaultMessage": "Unsaved changes"
|
||||
},
|
||||
"files.upload-dropdown.cancelled": {
|
||||
"defaultMessage": "Cancelled"
|
||||
},
|
||||
"files.upload-dropdown.failed-file-exists": {
|
||||
"defaultMessage": "Failed - File already exists"
|
||||
},
|
||||
"files.upload-dropdown.failed-generic": {
|
||||
"defaultMessage": "Failed - {error}"
|
||||
},
|
||||
"files.upload-dropdown.failed-incorrect-type": {
|
||||
"defaultMessage": "Failed - Incorrect file type"
|
||||
},
|
||||
"files.upload-dropdown.failed-to-upload": {
|
||||
"defaultMessage": "Failed to upload {fileName}"
|
||||
},
|
||||
"files.upload-dropdown.file": {
|
||||
"defaultMessage": "File"
|
||||
},
|
||||
"files.upload-dropdown.file-uploads": {
|
||||
"defaultMessage": "{fileType} uploads"
|
||||
},
|
||||
"files.upload-dropdown.incorrect-file-type": {
|
||||
"defaultMessage": "Upload had incorrect file type"
|
||||
},
|
||||
"files.upload-dropdown.unexpected-error": {
|
||||
"defaultMessage": "An unexpected error occurred."
|
||||
},
|
||||
"files.upload-dropdown.uploads-left": {
|
||||
"defaultMessage": " - {count} left"
|
||||
},
|
||||
"files.upload.drag-and-drop.drop-to-upload": {
|
||||
"defaultMessage": "Drop {type, select, undefined {files} other {{type}s}} here to upload"
|
||||
},
|
||||
"files.validation.name-invalid-directory": {
|
||||
"defaultMessage": "Name must contain only alphanumeric characters, dashes, underscores, or spaces."
|
||||
},
|
||||
"files.validation.name-invalid-file": {
|
||||
"defaultMessage": "Name must contain only alphanumeric characters, dashes, underscores, dots, or spaces."
|
||||
},
|
||||
"files.validation.name-label": {
|
||||
"defaultMessage": "Name"
|
||||
},
|
||||
"files.validation.name-required": {
|
||||
"defaultMessage": "Name is required."
|
||||
},
|
||||
"files.zip-url-modal.backup-name": {
|
||||
"defaultMessage": "CurseForge modpack install"
|
||||
},
|
||||
"files.zip-url-modal.cf-header": {
|
||||
"defaultMessage": "Install a CurseForge modpack"
|
||||
},
|
||||
"files.zip-url-modal.cf-not-found-text": {
|
||||
"defaultMessage": "Could not find CurseForge modpack at that URL."
|
||||
},
|
||||
"files.zip-url-modal.cf-not-found-title": {
|
||||
"defaultMessage": "CurseForge modpack not found"
|
||||
},
|
||||
"files.zip-url-modal.enter-link": {
|
||||
"defaultMessage": "Enter link"
|
||||
},
|
||||
"files.zip-url-modal.error-cf-url": {
|
||||
"defaultMessage": "URL must be a CurseForge modpack version URL."
|
||||
},
|
||||
"files.zip-url-modal.error-url-invalid": {
|
||||
"defaultMessage": "URL must be valid."
|
||||
},
|
||||
"files.zip-url-modal.error-url-required": {
|
||||
"defaultMessage": "URL is required."
|
||||
},
|
||||
"files.zip-url-modal.install-button": {
|
||||
"defaultMessage": "Install"
|
||||
},
|
||||
"files.zip-url-modal.install-failed-title": {
|
||||
"defaultMessage": "Installation failed"
|
||||
},
|
||||
"files.zip-url-modal.step-copy-description": {
|
||||
"defaultMessage": "Copy the version page URL and paste it below."
|
||||
},
|
||||
"files.zip-url-modal.step-copy-title": {
|
||||
"defaultMessage": "Copy the URL"
|
||||
},
|
||||
"files.zip-url-modal.step-find-description": {
|
||||
"defaultMessage": "Browse CurseForge and locate the modpack you want."
|
||||
},
|
||||
"files.zip-url-modal.step-find-title": {
|
||||
"defaultMessage": "Find the modpack"
|
||||
},
|
||||
"files.zip-url-modal.step-select-description": {
|
||||
"defaultMessage": "Go to the \"Files\" tab and pick the version to install."
|
||||
},
|
||||
"files.zip-url-modal.step-select-title": {
|
||||
"defaultMessage": "Select a version"
|
||||
},
|
||||
"files.zip-url-modal.unknown-error": {
|
||||
"defaultMessage": "An unknown error occurred"
|
||||
},
|
||||
"files.zip-url-modal.zip-description": {
|
||||
"defaultMessage": "Copy and paste the direct download URL of a .zip file."
|
||||
},
|
||||
"files.zip-url-modal.zip-header": {
|
||||
"defaultMessage": "Uploading .zip contents from URL"
|
||||
},
|
||||
"form.label.address-line": {
|
||||
"defaultMessage": "Address line"
|
||||
},
|
||||
@@ -515,9 +872,6 @@
|
||||
"header.category.resolutions": {
|
||||
"defaultMessage": "Resolution"
|
||||
},
|
||||
"hosting.content.copy-link": {
|
||||
"defaultMessage": "Copy link"
|
||||
},
|
||||
"hosting.content.failed-to-bulk-delete": {
|
||||
"defaultMessage": "Failed to delete content"
|
||||
},
|
||||
@@ -710,15 +1064,6 @@
|
||||
"instances.confirm-leave-modal.body": {
|
||||
"defaultMessage": "Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost."
|
||||
},
|
||||
"instances.confirm-leave-modal.leave": {
|
||||
"defaultMessage": "Leave page"
|
||||
},
|
||||
"instances.confirm-leave-modal.stay": {
|
||||
"defaultMessage": "Stay on page"
|
||||
},
|
||||
"instances.confirm-leave-modal.title": {
|
||||
"defaultMessage": "Leave page?"
|
||||
},
|
||||
"instances.confirm-leave-modal.upload-in-progress": {
|
||||
"defaultMessage": "Upload in progress"
|
||||
},
|
||||
@@ -728,27 +1073,18 @@
|
||||
"instances.content-install.existing-tab": {
|
||||
"defaultMessage": "Existing instance"
|
||||
},
|
||||
"instances.content-install.game-version-label": {
|
||||
"defaultMessage": "Game version"
|
||||
},
|
||||
"instances.content-install.game-version-placeholder": {
|
||||
"defaultMessage": "Select game version"
|
||||
},
|
||||
"instances.content-install.header": {
|
||||
"defaultMessage": "Install project"
|
||||
},
|
||||
"instances.content-install.hide-snapshots": {
|
||||
"defaultMessage": "Hide snapshots"
|
||||
},
|
||||
"instances.content-install.install-button": {
|
||||
"defaultMessage": "Install"
|
||||
},
|
||||
"instances.content-install.installed-badge": {
|
||||
"defaultMessage": "Installed"
|
||||
},
|
||||
"instances.content-install.installing-label": {
|
||||
"defaultMessage": "Installing..."
|
||||
},
|
||||
"instances.content-install.instance-type": {
|
||||
"defaultMessage": "Instance type"
|
||||
},
|
||||
@@ -776,21 +1112,12 @@
|
||||
"instances.content-install.select-icon": {
|
||||
"defaultMessage": "Select icon"
|
||||
},
|
||||
"instances.content-install.show-all-versions": {
|
||||
"defaultMessage": "Show all versions"
|
||||
},
|
||||
"instances.modpack-content-modal.copy-link": {
|
||||
"defaultMessage": "Copy link"
|
||||
},
|
||||
"instances.modpack-content-modal.empty-description": {
|
||||
"defaultMessage": "This modpack does not include any additional content."
|
||||
},
|
||||
"instances.modpack-content-modal.empty-title": {
|
||||
"defaultMessage": "No content found"
|
||||
},
|
||||
"instances.modpack-content-modal.filter-all": {
|
||||
"defaultMessage": "All"
|
||||
},
|
||||
"instances.modpack-content-modal.header": {
|
||||
"defaultMessage": "Modpack content"
|
||||
},
|
||||
@@ -881,6 +1208,15 @@
|
||||
"label.content": {
|
||||
"defaultMessage": "Content"
|
||||
},
|
||||
"label.copied-filename": {
|
||||
"defaultMessage": "Copied filename"
|
||||
},
|
||||
"label.copied-path": {
|
||||
"defaultMessage": "Copied path"
|
||||
},
|
||||
"label.create-failed": {
|
||||
"defaultMessage": "Create failed"
|
||||
},
|
||||
"label.created-ago": {
|
||||
"defaultMessage": "Created {ago}"
|
||||
},
|
||||
@@ -890,6 +1226,9 @@
|
||||
"label.delete": {
|
||||
"defaultMessage": "Delete"
|
||||
},
|
||||
"label.delete-failed": {
|
||||
"defaultMessage": "Delete failed"
|
||||
},
|
||||
"label.delete-immediately": {
|
||||
"defaultMessage": "Delete immediately"
|
||||
},
|
||||
@@ -899,6 +1238,12 @@
|
||||
"label.details": {
|
||||
"defaultMessage": "Details"
|
||||
},
|
||||
"label.done": {
|
||||
"defaultMessage": "Done"
|
||||
},
|
||||
"label.download-failed": {
|
||||
"defaultMessage": "Download failed"
|
||||
},
|
||||
"label.email": {
|
||||
"defaultMessage": "Email"
|
||||
},
|
||||
@@ -908,6 +1253,9 @@
|
||||
"label.error": {
|
||||
"defaultMessage": "Error"
|
||||
},
|
||||
"label.extract-failed": {
|
||||
"defaultMessage": "Extract failed"
|
||||
},
|
||||
"label.filter-by": {
|
||||
"defaultMessage": "Filter by"
|
||||
},
|
||||
@@ -935,6 +1283,9 @@
|
||||
"label.modpack": {
|
||||
"defaultMessage": "Modpack"
|
||||
},
|
||||
"label.move-failed": {
|
||||
"defaultMessage": "Move failed"
|
||||
},
|
||||
"label.no": {
|
||||
"defaultMessage": "No"
|
||||
},
|
||||
@@ -980,6 +1331,9 @@
|
||||
"label.rejected": {
|
||||
"defaultMessage": "Rejected"
|
||||
},
|
||||
"label.rename-failed": {
|
||||
"defaultMessage": "Rename failed"
|
||||
},
|
||||
"label.rewards-program-terms-agreement": {
|
||||
"defaultMessage": "I agree to the <terms-link>Rewards Program Terms</terms-link>"
|
||||
},
|
||||
@@ -1028,6 +1382,12 @@
|
||||
"label.update-available": {
|
||||
"defaultMessage": "Update available"
|
||||
},
|
||||
"label.updating": {
|
||||
"defaultMessage": "Updating..."
|
||||
},
|
||||
"label.upload-failed": {
|
||||
"defaultMessage": "Upload failed"
|
||||
},
|
||||
"label.username": {
|
||||
"defaultMessage": "Username"
|
||||
},
|
||||
@@ -2818,5 +3178,20 @@
|
||||
},
|
||||
"ui.component.unsaved-changes-popup.body": {
|
||||
"defaultMessage": "You have unsaved changes."
|
||||
},
|
||||
"ui.confirm-leave-modal.body": {
|
||||
"defaultMessage": "You have unsaved changes that will be lost if you leave this page."
|
||||
},
|
||||
"ui.confirm-leave-modal.header": {
|
||||
"defaultMessage": "You have unsaved changes"
|
||||
},
|
||||
"ui.confirm-leave-modal.leave": {
|
||||
"defaultMessage": "Leave page"
|
||||
},
|
||||
"ui.confirm-leave-modal.stay": {
|
||||
"defaultMessage": "Stay on page"
|
||||
},
|
||||
"ui.confirm-leave-modal.title": {
|
||||
"defaultMessage": "Leave page?"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import Admonition from '../../components/base/Admonition.vue'
|
||||
import ButtonStyled from '../../components/base/ButtonStyled.vue'
|
||||
import ProgressBar from '../../components/base/ProgressBar.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Admonition',
|
||||
@@ -45,3 +47,92 @@ export const Success: Story = {
|
||||
body: 'Everything went smoothly.',
|
||||
},
|
||||
}
|
||||
|
||||
export const Dismissible: Story = {
|
||||
args: {
|
||||
type: 'info',
|
||||
header: 'Dismissible Notice',
|
||||
body: 'This admonition can be dismissed by clicking the X button.',
|
||||
dismissible: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const WithTopRightActions: Story = {
|
||||
render: () => ({
|
||||
components: { Admonition, ButtonStyled },
|
||||
template: /*html*/ `
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<Admonition type="info" header="Uploading files (2/5)">
|
||||
Uploading server files...
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled type="outlined" color="blue">
|
||||
<button class="!border">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
<Admonition type="critical" header="Extraction failed">
|
||||
Something went wrong while extracting the archive.
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled color="red">
|
||||
<button>Retry</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled circular type="transparent" hover-color-fill="background" color="red">
|
||||
<button>✕</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
<Admonition type="success" header="Extraction complete">
|
||||
All files have been extracted successfully.
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled circular type="transparent" hover-color-fill="background" color="green">
|
||||
<button>✕</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
</Admonition>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WithProgressBar: Story = {
|
||||
render: () => ({
|
||||
components: { Admonition, ButtonStyled, ProgressBar },
|
||||
template: /*html*/ `
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<Admonition type="info" header="Uploading files (2/5)">
|
||||
128 KB / 1.2 MB (45%)
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled type="outlined" color="blue">
|
||||
<button class="!border">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #progress>
|
||||
<ProgressBar :progress="0.45" :max="1" color="blue" full-width />
|
||||
</template>
|
||||
</Admonition>
|
||||
<Admonition type="info" header="Extracting modpack.zip">
|
||||
24 MB extracted — config/settings.yml
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled type="outlined" color="blue">
|
||||
<button class="!border">Cancel</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #progress>
|
||||
<ProgressBar :progress="0.7" :max="1" color="blue" full-width />
|
||||
</template>
|
||||
</Admonition>
|
||||
<Admonition type="success" header="Extraction complete — Done">
|
||||
56 MB extracted
|
||||
<template #top-right-actions>
|
||||
<ButtonStyled circular type="transparent" hover-color-fill="background" color="green">
|
||||
<button>✕</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template #progress>
|
||||
<ProgressBar :progress="1" :max="1" color="green" full-width />
|
||||
</template>
|
||||
</Admonition>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
97
packages/ui/src/stories/base/NavTabs.stories.ts
Normal file
97
packages/ui/src/stories/base/NavTabs.stories.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
import { getCurrentInstance, ref } from 'vue'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
import NavTabs from '../../components/base/NavTabs.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/', component: { template: '<div />' } }],
|
||||
})
|
||||
|
||||
const meta = {
|
||||
title: 'Base/NavTabs',
|
||||
component: NavTabs,
|
||||
decorators: [
|
||||
(story) => {
|
||||
return {
|
||||
components: { story },
|
||||
setup() {
|
||||
const app = getCurrentInstance()?.appContext.app
|
||||
if (app && !app.config.globalProperties.$router) {
|
||||
app.use(router)
|
||||
}
|
||||
},
|
||||
template: '<story />',
|
||||
}
|
||||
},
|
||||
],
|
||||
} satisfies Meta<typeof NavTabs>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
mode: 'local',
|
||||
activeIndex: 0,
|
||||
links: [
|
||||
{ label: 'Overview', href: '' },
|
||||
{ label: 'Gallery', href: 'gallery' },
|
||||
{ label: 'Changelog', href: 'changelog' },
|
||||
{ label: 'Versions', href: 'versions' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
export const LocalMode: StoryObj = {
|
||||
render: () => ({
|
||||
components: { NavTabs },
|
||||
setup() {
|
||||
const activeIndex = ref(0)
|
||||
const links = [
|
||||
{ label: 'Overview', href: '' },
|
||||
{ label: 'Gallery', href: 'gallery' },
|
||||
{ label: 'Changelog', href: 'changelog' },
|
||||
{ label: 'Versions', href: 'versions' },
|
||||
]
|
||||
function onTabClick(index: number) {
|
||||
activeIndex.value = index
|
||||
}
|
||||
return { activeIndex, links, onTabClick }
|
||||
},
|
||||
template: /* html */ `
|
||||
<NavTabs
|
||||
mode="local"
|
||||
:active-index="activeIndex"
|
||||
:links="links"
|
||||
@tab-click="onTabClick"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const TwoTabs: StoryObj = {
|
||||
render: () => ({
|
||||
components: { NavTabs },
|
||||
setup() {
|
||||
const activeIndex = ref(0)
|
||||
const links = [
|
||||
{ label: 'Details', href: '' },
|
||||
{ label: 'Settings', href: 'settings' },
|
||||
]
|
||||
function onTabClick(index: number) {
|
||||
activeIndex.value = index
|
||||
}
|
||||
return { activeIndex, links, onTabClick }
|
||||
},
|
||||
template: /* html */ `
|
||||
<NavTabs
|
||||
mode="local"
|
||||
:active-index="activeIndex"
|
||||
:links="links"
|
||||
@tab-click="onTabClick"
|
||||
/>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
86
packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts
Normal file
86
packages/ui/src/stories/modal/ConfirmLeaveModal.stories.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { StoryObj } from '@storybook/vue3-vite'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import ButtonStyled from '../../components/base/ButtonStyled.vue'
|
||||
import ConfirmLeaveModal from '../../components/modal/ConfirmLeaveModal.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Modal/ConfirmLeaveModal',
|
||||
component: ConfirmLeaveModal,
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof ConfirmLeaveModal>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { ConfirmLeaveModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ConfirmLeaveModal> | null>(null)
|
||||
const result = ref<string>('')
|
||||
async function openModal() {
|
||||
result.value = ''
|
||||
const confirmed = await modalRef.value?.prompt()
|
||||
result.value = confirmed ? 'User chose to leave' : 'User chose to stay'
|
||||
}
|
||||
return { modalRef, result, openModal }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="openModal">Trigger Leave Confirmation</button>
|
||||
</ButtonStyled>
|
||||
<p v-if="result" class="mt-4 text-secondary">{{ result }}</p>
|
||||
<ConfirmLeaveModal ref="modalRef" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const CustomMessages: Story = {
|
||||
render: () => ({
|
||||
components: { ConfirmLeaveModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ConfirmLeaveModal> | null>(null)
|
||||
const openModal = () => modalRef.value?.prompt()
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div>
|
||||
<ButtonStyled color="red">
|
||||
<button @click="openModal">Discard Draft?</button>
|
||||
</ButtonStyled>
|
||||
<ConfirmLeaveModal
|
||||
ref="modalRef"
|
||||
title="Discard draft?"
|
||||
header="Your draft will be lost"
|
||||
body="If you leave now, your draft will not be saved."
|
||||
stay-label="Keep editing"
|
||||
leave-label="Discard"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const WarningAdmonition: Story = {
|
||||
render: () => ({
|
||||
components: { ConfirmLeaveModal, ButtonStyled },
|
||||
setup() {
|
||||
const modalRef = ref<InstanceType<typeof ConfirmLeaveModal> | null>(null)
|
||||
const openModal = () => modalRef.value?.prompt()
|
||||
return { modalRef, openModal }
|
||||
},
|
||||
template: /* html */ `
|
||||
<div>
|
||||
<ButtonStyled color="orange">
|
||||
<button @click="openModal">Open Warning Variant</button>
|
||||
</ButtonStyled>
|
||||
<ConfirmLeaveModal
|
||||
ref="modalRef"
|
||||
admonition-type="warning"
|
||||
/>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/vue3-vite'
|
||||
|
||||
import BackupProgressAdmonition from '../../components/servers/backups/BackupProgressAdmonition.vue'
|
||||
|
||||
const meta = {
|
||||
title: 'Servers/BackupProgressAdmonition',
|
||||
component: BackupProgressAdmonition,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
},
|
||||
} satisfies Meta<typeof BackupProgressAdmonition>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const justNow = new Date().toISOString()
|
||||
const eightMinsAgo = new Date(Date.now() - 8 * 60 * 1000).toISOString()
|
||||
const fiveHoursAgo = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => ({
|
||||
components: { BackupProgressAdmonition },
|
||||
setup() {
|
||||
const now = new Date().toISOString()
|
||||
const mins8 = new Date(Date.now() - 8 * 60 * 1000).toISOString()
|
||||
const hours5 = new Date(Date.now() - 5 * 60 * 60 * 1000).toISOString()
|
||||
return { now, mins8, hours5 }
|
||||
},
|
||||
template: /*html*/ `
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem; max-width: 1020px;">
|
||||
<h3 style="margin: 0; color: var(--color-contrast);">Backup Creation</h3>
|
||||
<BackupProgressAdmonition type="create" state="ongoing" :progress="0" backup-name="World Backup 1" :created-at="now" />
|
||||
<BackupProgressAdmonition type="create" state="ongoing" :progress="0.33" backup-name="World Backup 1" :created-at="mins8" />
|
||||
<BackupProgressAdmonition type="create" state="failed" :progress="0" backup-name="World Backup 1" :created-at="hours5" />
|
||||
|
||||
<h3 style="margin: 1rem 0 0; color: var(--color-contrast);">Backup Restoration</h3>
|
||||
<BackupProgressAdmonition type="restore" state="ongoing" :progress="0" backup-name="World Backup 1" :created-at="now" />
|
||||
<BackupProgressAdmonition type="restore" state="ongoing" :progress="0.33" backup-name="World Backup 1" :created-at="mins8" />
|
||||
<BackupProgressAdmonition type="restore" state="done" :progress="1" backup-name="World Backup 1" :created-at="hours5" />
|
||||
<BackupProgressAdmonition type="restore" state="failed" :progress="0" backup-name="World Backup 1" :created-at="hours5" />
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
export const BackupQueued: Story = {
|
||||
args: {
|
||||
type: 'create',
|
||||
state: 'ongoing',
|
||||
progress: 0,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: justNow,
|
||||
},
|
||||
}
|
||||
|
||||
export const CreatingBackup: Story = {
|
||||
args: {
|
||||
type: 'create',
|
||||
state: 'ongoing',
|
||||
progress: 0.33,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: eightMinsAgo,
|
||||
},
|
||||
}
|
||||
|
||||
export const BackupFailed: Story = {
|
||||
args: {
|
||||
type: 'create',
|
||||
state: 'failed',
|
||||
progress: 0,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: fiveHoursAgo,
|
||||
},
|
||||
}
|
||||
|
||||
export const RestoreQueued: Story = {
|
||||
args: {
|
||||
type: 'restore',
|
||||
state: 'ongoing',
|
||||
progress: 0,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: justNow,
|
||||
},
|
||||
}
|
||||
|
||||
export const RestoringBackup: Story = {
|
||||
args: {
|
||||
type: 'restore',
|
||||
state: 'ongoing',
|
||||
progress: 0.33,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: eightMinsAgo,
|
||||
},
|
||||
}
|
||||
|
||||
export const RestoreSuccessful: Story = {
|
||||
args: {
|
||||
type: 'restore',
|
||||
state: 'done',
|
||||
progress: 1,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: fiveHoursAgo,
|
||||
},
|
||||
}
|
||||
|
||||
export const RestoreFailed: Story = {
|
||||
args: {
|
||||
type: 'restore',
|
||||
state: 'failed',
|
||||
progress: 0,
|
||||
backupName: 'World Backup 1',
|
||||
createdAt: fiveHoursAgo,
|
||||
},
|
||||
}
|
||||
71
packages/ui/src/utils/ace-mode-log.ts
Normal file
71
packages/ui/src/utils/ace-mode-log.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import ace from 'ace-builds'
|
||||
|
||||
ace['define'](
|
||||
'ace/mode/mclog_highlight_rules',
|
||||
['require', 'exports', 'ace/lib/oop', 'ace/mode/text_highlight_rules'],
|
||||
function (require: any, exports: any) {
|
||||
const oop = require('ace/lib/oop')
|
||||
const TextHighlightRules = require('ace/mode/text_highlight_rules').TextHighlightRules
|
||||
|
||||
const MclogHighlightRules = function (this: any) {
|
||||
this.$rules = {
|
||||
start: [
|
||||
{
|
||||
token: 'comment.timestamp',
|
||||
regex: /^\[\d\d:\d\d:\d\d\]/.source,
|
||||
},
|
||||
{
|
||||
token: 'invalid.error',
|
||||
regex: /\[.+?\/ERROR\]:?/.source,
|
||||
},
|
||||
{
|
||||
token: 'keyword.warn',
|
||||
regex: /\[.+?\/WARN\]:?/.source,
|
||||
},
|
||||
{
|
||||
token: 'string.info',
|
||||
regex: /\[.+?\/INFO\]:/.source,
|
||||
},
|
||||
{
|
||||
token: 'support.command',
|
||||
regex: /: \/.+/.source,
|
||||
},
|
||||
{
|
||||
token: 'comment.stacktrace',
|
||||
regex: /\tat\s.+/.source,
|
||||
},
|
||||
{
|
||||
token: 'entity.name.function',
|
||||
regex: /\w+?\[\/\d+?\.\d+?\.\d+?\.\d+?:\d+?\]/.source,
|
||||
},
|
||||
{
|
||||
token: 'storage.chat',
|
||||
regex: /\[CHAT\]/.source,
|
||||
},
|
||||
],
|
||||
}
|
||||
this.normalizeRules()
|
||||
}
|
||||
|
||||
oop.inherits(MclogHighlightRules, TextHighlightRules)
|
||||
exports.MclogHighlightRules = MclogHighlightRules
|
||||
},
|
||||
)
|
||||
|
||||
ace['define'](
|
||||
'ace/mode/mclog',
|
||||
['require', 'exports', 'ace/lib/oop', 'ace/mode/text', 'ace/mode/mclog_highlight_rules'],
|
||||
function (require: any, exports: any) {
|
||||
const oop = require('ace/lib/oop')
|
||||
const TextMode = require('ace/mode/text').Mode
|
||||
const MclogHighlightRules = require('ace/mode/mclog_highlight_rules').MclogHighlightRules
|
||||
|
||||
const Mode = function (this: any) {
|
||||
this.HighlightRules = MclogHighlightRules
|
||||
}
|
||||
|
||||
oop.inherits(Mode, TextMode)
|
||||
exports.Mode = Mode
|
||||
},
|
||||
)
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'ace-builds/esm-resolver'
|
||||
|
||||
import cssText from '@modrinth/assets/styles/ace.css?raw'
|
||||
import ace from 'ace-builds'
|
||||
|
||||
|
||||
@@ -470,10 +470,94 @@ export const commonMessages = defineMessages({
|
||||
id: 'label.plan-custom',
|
||||
defaultMessage: 'Custom',
|
||||
},
|
||||
copyLinkButton: {
|
||||
id: 'button.copy-link',
|
||||
defaultMessage: 'Copy link',
|
||||
},
|
||||
switchVersionButton: {
|
||||
id: 'button.switch-version',
|
||||
defaultMessage: 'Switch version',
|
||||
},
|
||||
updatingLabel: {
|
||||
id: 'label.updating',
|
||||
defaultMessage: 'Updating...',
|
||||
},
|
||||
deleteFailedLabel: {
|
||||
id: 'label.delete-failed',
|
||||
defaultMessage: 'Delete failed',
|
||||
},
|
||||
uploadFailedLabel: {
|
||||
id: 'label.upload-failed',
|
||||
defaultMessage: 'Upload failed',
|
||||
},
|
||||
renameFailedLabel: {
|
||||
id: 'label.rename-failed',
|
||||
defaultMessage: 'Rename failed',
|
||||
},
|
||||
moveFailedLabel: {
|
||||
id: 'label.move-failed',
|
||||
defaultMessage: 'Move failed',
|
||||
},
|
||||
createFailedLabel: {
|
||||
id: 'label.create-failed',
|
||||
defaultMessage: 'Create failed',
|
||||
},
|
||||
extractFailedLabel: {
|
||||
id: 'label.extract-failed',
|
||||
defaultMessage: 'Extract failed',
|
||||
},
|
||||
showFileButton: {
|
||||
id: 'button.show-file',
|
||||
defaultMessage: 'Show file',
|
||||
},
|
||||
enableButton: {
|
||||
id: 'button.enable',
|
||||
defaultMessage: 'Enable',
|
||||
},
|
||||
disableButton: {
|
||||
id: 'button.disable',
|
||||
defaultMessage: 'Disable',
|
||||
},
|
||||
renameButton: {
|
||||
id: 'button.rename',
|
||||
defaultMessage: 'Rename',
|
||||
},
|
||||
moveButton: {
|
||||
id: 'button.move',
|
||||
defaultMessage: 'Move',
|
||||
},
|
||||
extractButton: {
|
||||
id: 'button.extract',
|
||||
defaultMessage: 'Extract',
|
||||
},
|
||||
doneLabel: {
|
||||
id: 'label.done',
|
||||
defaultMessage: 'Done',
|
||||
},
|
||||
copyFilenameButton: {
|
||||
id: 'button.copy-filename',
|
||||
defaultMessage: 'Copy filename',
|
||||
},
|
||||
copyFullPathButton: {
|
||||
id: 'button.copy-full-path',
|
||||
defaultMessage: 'Copy full path',
|
||||
},
|
||||
copiedFilenameLabel: {
|
||||
id: 'label.copied-filename',
|
||||
defaultMessage: 'Copied filename',
|
||||
},
|
||||
copiedPathLabel: {
|
||||
id: 'label.copied-path',
|
||||
defaultMessage: 'Copied path',
|
||||
},
|
||||
openInFolderButton: {
|
||||
id: 'button.open-in-folder',
|
||||
defaultMessage: 'Open in folder',
|
||||
},
|
||||
downloadFailedLabel: {
|
||||
id: 'label.download-failed',
|
||||
defaultMessage: 'Download failed',
|
||||
},
|
||||
projectCreated: {
|
||||
id: 'project.about.details.created',
|
||||
defaultMessage: 'Created {date}',
|
||||
@@ -638,6 +722,14 @@ export const financialMessages = defineMessages({
|
||||
},
|
||||
})
|
||||
|
||||
const PROJECT_TYPE_ALIASES: Record<string, string> = {
|
||||
shaderpack: 'shader',
|
||||
}
|
||||
|
||||
export function normalizeProjectType(type: string): string {
|
||||
return PROJECT_TYPE_ALIASES[type] ?? type
|
||||
}
|
||||
|
||||
export const commonProjectTypeCategoryMessages = defineMessages({
|
||||
datapack: {
|
||||
id: 'project-type.datapack.category',
|
||||
|
||||
@@ -146,6 +146,8 @@ export function getEditorLanguage(ext: string): string {
|
||||
case 'cfg':
|
||||
case 'conf':
|
||||
return 'ini'
|
||||
case 'log':
|
||||
return 'mclog'
|
||||
default:
|
||||
return 'text'
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ export const SERVER_SORT_TYPES: SortType[] = [
|
||||
|
||||
const FILTER_FIELD_MAP: Record<string, string> = {
|
||||
server_content_type: 'minecraft_java_server.content.kind',
|
||||
server_game_version: 'minecraft_java_server.content.supported_game_versions',
|
||||
server_game_version: 'game_versions',
|
||||
server_status: 'minecraft_java_server.ping.data',
|
||||
server_region: 'minecraft_server.region',
|
||||
server_language: 'minecraft_server.languages',
|
||||
|
||||
Reference in New Issue
Block a user