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

* fix: files.vue bugs before styling changes

* feat: move files tab to shared layout structure

* fix: qa

* fix: qa

* fix: bugs

* fix: lint

* fix: admonition cleanup with progress + actions

* fix: cleanup

* fix: modals

* fix: admon title

* fix: i18n standard

* fix: lint + i18n pass

* fix: remove transition

* fix: type errors

* feat: files tab in app

* fix: qa

* fix: backup item minmax

* fix: use ContentPageHeader for server panel

* fix: lint

* fix: lint

* fix: lint

* feat: page leave safety

* fix: lint

* fix: cargo fmt fix

* fix: blank in prod

* fix: content card table stuff

* Revert "fix: blank in prod"

This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace.

* fix: import

* feat: browse worlds/servers flow

* fix: worlds tab parity with content tab

* fix: perf bug + shader filter pill copy

* feat: singleplayer filter

* fix: ordering

* fix: breadcrumbs

* fix: lint

* fix: qa

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

* fix: lint

* fix: i18n + qa

* fix: conflict

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

* fix: qa

* fix: add + edit server modals

* fix: qa

* fix: security

* fix: devin flags

* fix: lint

* chore: change file to break build cache

* fix: admon

* fix: import path stuff

* feat: qa

* fix: fmt fmt idiot

---------

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

View File

@@ -1,3 +1,4 @@
export * from './shared/content-tab'
export * from './shared/files-tab'
export * from './shared/installation-settings'
export * from './wrapped'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@
<span class="text-lg font-extrabold text-contrast">{{
header ??
formatMessage(
isModpack.value
isModpack
? messages.switchModpackVersionHeader
: switchMode
? messages.switchVersionHeader

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,55 @@
<template>
<div class="flex h-full w-full items-center justify-center gap-6 p-20">
<FileIcon class="size-28" />
<div class="flex flex-col gap-2">
<h3 class="m-0 text-2xl font-bold text-red">{{ title }}</h3>
<p class="m-0 text-sm text-secondary">
{{ message }}
</p>
<div class="flex gap-2">
<ButtonStyled>
<button size="sm" @click="$emit('refetch')">
<RefreshCwIcon class="h-5 w-5" />
{{ formatMessage(messages.tryAgain) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button size="sm" @click="$emit('home')">
<HomeIcon class="h-5 w-5" />
{{ formatMessage(messages.goToHome) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { FileIcon, HomeIcon, RefreshCwIcon } from '@modrinth/assets'
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
message: string
}>()
defineEmits<{
refetch: []
home: []
}>()
</script>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.',
},
})

View File

@@ -0,0 +1,85 @@
<template>
<div
@dragenter.prevent="handleDragEnter"
@dragover.prevent="handleDragOver"
@dragleave.prevent="handleDragLeave"
@drop.prevent="handleDrop"
>
<slot />
<div
v-if="isDragging"
:class="[
'absolute inset-0 flex items-center justify-center rounded-2xl bg-black/60 text-contrast shadow',
overlayClass,
]"
>
<div class="text-center">
<UploadIcon class="mx-auto h-16 w-16 shadow-2xl" />
<p class="mt-2 text-xl">
{{
formatMessage(messages.dropToUpload, {
type: type ? type.toLocaleLowerCase() : undefined,
})
}}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
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[]]
}>()
defineProps<{
overlayClass?: string
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()
dragCounter.value++
isDragging.value = true
}
const handleDragOver = (event: DragEvent) => {
event.preventDefault()
}
const handleDragLeave = (event: DragEvent) => {
event.preventDefault()
dragCounter.value--
if (dragCounter.value === 0) {
isDragging.value = false
}
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragging.value = false
dragCounter.value = 0
const files = event.dataTransfer?.files
if (files) {
emit('filesDropped', Array.from(files))
}
}
</script>

View File

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

View File

@@ -0,0 +1,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)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export { default as FilePageLayout } from './layout.vue'
export * from './providers'
export * from './types'

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

View File

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

View File

@@ -0,0 +1,2 @@
export type { FileManagerContext } from './file-manager'
export { injectFileManager, provideFileManager } from './file-manager'

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

View File

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

View File

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