Files
AstralRinth/packages/ui/src/layouts/shared/files-tab/components/FileTableRow.vue
T
Calum H. 381ea51cce 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>
2026-03-26 18:55:15 +00:00

361 lines
9.8 KiB
Vue

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