5bfe2a0331
* feat: add file manager module - Complete file manager implementation with UI/UX - Add drive management functionality - Add file upload/download with progress tracking - Add stamp integration and handling - Add bulk operations and context menus Co-authored-by: Roland Seres <roland.seres90@gmail.com> Co-authored-by: nidishk <nidishkrishnan45@gmail.com>
600 lines
18 KiB
TypeScript
600 lines
18 KiB
TypeScript
import { ReactElement, useContext, useLayoutEffect, useMemo, useState, useRef, useEffect, useCallback } from 'react'
|
|
import './FileItem.scss'
|
|
import { GetIconElement } from '../../../utils/GetIconElement'
|
|
import { ContextMenu } from '../../ContextMenu/ContextMenu'
|
|
import { useContextMenu } from '../../../hooks/useContextMenu'
|
|
import { Context as SettingsContext } from '../../../../../providers/Settings'
|
|
import { ActionTag, DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
|
|
import { GetInfoModal } from '../../GetInfoModal/GetInfoModal'
|
|
import { VersionHistoryModal } from '../../VersionHistoryModal/VersionHistoryModal'
|
|
import { DeleteFileModal } from '../../DeleteFileModal/DeleteFileModal'
|
|
import { RenameFileModal } from '../../RenameFileModal/RenameFileModal'
|
|
import { buildGetInfoGroups } from '../../../utils/infoGroups'
|
|
import type { FilePropertyGroup } from '../../../utils/infoGroups'
|
|
import { useView } from '../../../../../pages/filemanager/ViewContext'
|
|
import type { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib'
|
|
import { Context as FMContext } from '../../../../../providers/FileManager'
|
|
import { DestroyDriveModal } from '../../DestroyDriveModal/DestroyDriveModal'
|
|
import { ConfirmModal } from '../../ConfirmModal/ConfirmModal'
|
|
|
|
import { capitalizeFirstLetter, Dir, formatBytes, isTrashed, safeSetState } from '../../../utils/common'
|
|
import { FileAction } from '../../../constants/transfers'
|
|
import { startDownloadingQueue, createDownloadAbort } from '../../../utils/download'
|
|
import { computeContextMenuPosition } from '../../../utils/ui'
|
|
import { getUsableStamps, handleDestroyDrive } from '../../../utils/bee'
|
|
import { PostageBatch } from '@ethersphere/bee-js'
|
|
|
|
interface FileItemProps {
|
|
fileInfo: FileInfo
|
|
onDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
|
|
showDriveColumn?: boolean
|
|
driveName: string
|
|
selected?: boolean
|
|
onToggleSelected?: (fi: FileInfo, checked: boolean) => void
|
|
bulkSelectedCount?: number
|
|
onBulk: {
|
|
download?: () => void
|
|
restore?: () => void
|
|
forget?: () => void
|
|
destroy?: () => void
|
|
delete?: () => void
|
|
}
|
|
setErrorMessage?: (error: string) => void
|
|
}
|
|
|
|
export function FileItem({
|
|
fileInfo,
|
|
onDownload,
|
|
showDriveColumn,
|
|
driveName,
|
|
selected = false,
|
|
onToggleSelected,
|
|
bulkSelectedCount,
|
|
onBulk,
|
|
setErrorMessage,
|
|
}: FileItemProps): ReactElement {
|
|
const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu<HTMLDivElement>()
|
|
const { fm, currentDrive, files, drives, setShowError } = useContext(FMContext)
|
|
const { beeApi } = useContext(SettingsContext)
|
|
const { view } = useView()
|
|
|
|
const [driveStamp, setDriveStamp] = useState<PostageBatch | undefined>(undefined)
|
|
const [safePos, setSafePos] = useState(pos)
|
|
const [dropDir, setDropDir] = useState<Dir>(Dir.Down)
|
|
const [showGetInfoModal, setShowGetInfoModal] = useState(false)
|
|
const [infoGroups, setInfoGroups] = useState<FilePropertyGroup[] | null>(null)
|
|
const [showVersionHistory, setShowVersionHistory] = useState(false)
|
|
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
|
const [showRenameModal, setShowRenameModal] = useState(false)
|
|
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
|
|
const [destroyDrive, setDestroyDrive] = useState<DriveInfo | null>(null)
|
|
const [confirmForget, setConfirmForget] = useState(false)
|
|
|
|
const isMountedRef = useRef(true)
|
|
const rafIdRef = useRef<number | null>(null)
|
|
|
|
const size = formatBytes(fileInfo.customMetadata?.size)
|
|
const dateMod = new Date(fileInfo.timestamp || 0).toLocaleDateString()
|
|
const isTrashedFile = isTrashed(fileInfo)
|
|
const statusLabel = isTrashedFile ? 'Trash' : 'Active'
|
|
|
|
useEffect(() => {
|
|
isMountedRef.current = true
|
|
|
|
const getStamps = async () => {
|
|
const stamps = await getUsableStamps(beeApi)
|
|
const driveStamp = stamps.find(s =>
|
|
drives.some(d => d.batchId.toString() === s.batchID.toString() && d.id === fileInfo.driveId),
|
|
)
|
|
|
|
safeSetState(isMountedRef, setDriveStamp)(driveStamp)
|
|
}
|
|
|
|
getStamps()
|
|
|
|
return () => {
|
|
isMountedRef.current = false
|
|
|
|
if (rafIdRef.current !== null) {
|
|
cancelAnimationFrame(rafIdRef.current)
|
|
}
|
|
}
|
|
}, [beeApi, drives, fileInfo.driveId])
|
|
|
|
const openGetInfo = useCallback(async () => {
|
|
if (!fm || !isMountedRef.current) return
|
|
|
|
const groups = await buildGetInfoGroups(fm, fileInfo, driveName, driveStamp)
|
|
setInfoGroups(groups)
|
|
setShowGetInfoModal(true)
|
|
}, [fm, fileInfo, driveName, driveStamp])
|
|
|
|
const takenNames = useMemo(() => {
|
|
if (!currentDrive || !files) return new Set<string>()
|
|
const wanted = currentDrive.batchId.toString()
|
|
const sameDrive = files.filter(fi => fi.batchId.toString() === wanted)
|
|
const out = new Set<string>()
|
|
sameDrive.forEach(fi => {
|
|
if (fi.topic.toString() !== fileInfo.topic.toString()) out.add(fi.name)
|
|
})
|
|
|
|
return out
|
|
}, [files, currentDrive, fileInfo.topic])
|
|
|
|
const handleItemContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
if (e.shiftKey) return
|
|
handleContextMenu(e)
|
|
}
|
|
|
|
// TODO: handleOpen shall only be available for images, videos etc... -> do not download 10GB into memory
|
|
const handleDownload = useCallback(
|
|
async (isNewWindow?: boolean) => {
|
|
if (!fm || !beeApi) return
|
|
|
|
handleCloseContext()
|
|
|
|
const rawSize = fileInfo.customMetadata?.size
|
|
const expectedSize = rawSize ? Number(rawSize) : undefined
|
|
|
|
createDownloadAbort(fileInfo.name)
|
|
|
|
await startDownloadingQueue(
|
|
fm,
|
|
[fileInfo],
|
|
[onDownload({ name: fileInfo.name, size: formatBytes(rawSize), expectedSize })],
|
|
isNewWindow,
|
|
)
|
|
},
|
|
[handleCloseContext, fm, beeApi, fileInfo, onDownload],
|
|
)
|
|
// TODO: refactor doTrash, doRecover, doForget to a single function with action param and remove switch case mybe
|
|
const doTrash = useCallback(async () => {
|
|
if (!fm) return
|
|
|
|
const withMeta: FileInfo = {
|
|
...fileInfo,
|
|
customMetadata: {
|
|
...(fileInfo.customMetadata ?? {}),
|
|
lifecycle: capitalizeFirstLetter(ActionTag.Trashed),
|
|
lifecycleAt: new Date().toISOString(),
|
|
},
|
|
}
|
|
|
|
await fm.trashFile(withMeta)
|
|
}, [fm, fileInfo])
|
|
|
|
const doRecover = useCallback(async () => {
|
|
if (!fm) return
|
|
|
|
const withMeta: FileInfo = {
|
|
...fileInfo,
|
|
customMetadata: {
|
|
...(fileInfo.customMetadata ?? {}),
|
|
lifecycle: capitalizeFirstLetter(ActionTag.Recovered),
|
|
lifecycleAt: new Date().toISOString(),
|
|
},
|
|
}
|
|
await fm.recoverFile(withMeta)
|
|
}, [fm, fileInfo])
|
|
|
|
const doForget = useCallback(async () => {
|
|
if (!fm) return
|
|
|
|
await fm.forgetFile(fileInfo)
|
|
}, [fm, fileInfo])
|
|
|
|
const showDestroyDrive = useCallback(() => {
|
|
setDestroyDrive(currentDrive || null)
|
|
setShowDestroyDriveModal(true)
|
|
}, [currentDrive])
|
|
|
|
const doRename = useCallback(
|
|
async (newName: string) => {
|
|
if (!fm || !currentDrive) return
|
|
|
|
if (takenNames.has(newName)) throw new Error('name-taken')
|
|
|
|
try {
|
|
await fm.upload(
|
|
currentDrive,
|
|
{
|
|
name: newName,
|
|
topic: fileInfo.topic,
|
|
file: {
|
|
reference: fileInfo.file.reference,
|
|
historyRef: fileInfo.file.historyRef,
|
|
},
|
|
customMetadata: fileInfo.customMetadata,
|
|
files: [],
|
|
},
|
|
{
|
|
actHistoryAddress: fileInfo.file.historyRef,
|
|
},
|
|
)
|
|
} catch (e: unknown) {
|
|
setErrorMessage?.(`Error renaming file ${fileInfo.name}`)
|
|
setShowError(true)
|
|
}
|
|
},
|
|
|
|
[fm, currentDrive, fileInfo, takenNames, setErrorMessage, setShowError],
|
|
)
|
|
|
|
const MenuItem = ({
|
|
disabled,
|
|
danger,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
disabled?: boolean
|
|
danger?: boolean
|
|
onClick?: () => void
|
|
children: React.ReactNode
|
|
}) => (
|
|
<div
|
|
className={`fm-context-item${danger ? ' red' : ''}`}
|
|
aria-disabled={disabled ? 'true' : 'false'}
|
|
style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
|
|
onClick={disabled ? undefined : onClick}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
|
|
const isBulk = (bulkSelectedCount ?? 0) > 1
|
|
|
|
const renderContextMenuItems = useCallback(() => {
|
|
const viewItem = (
|
|
<MenuItem disabled={isBulk} onClick={() => handleDownload(true)}>
|
|
View / Open
|
|
</MenuItem>
|
|
)
|
|
|
|
const downloadItem = isBulk ? (
|
|
<MenuItem onClick={onBulk.download}>Download</MenuItem>
|
|
) : (
|
|
<MenuItem onClick={() => handleDownload(false)}>Download</MenuItem>
|
|
)
|
|
|
|
const getInfoItem = (
|
|
<MenuItem
|
|
disabled={isBulk}
|
|
onClick={() => {
|
|
handleCloseContext()
|
|
openGetInfo()
|
|
}}
|
|
>
|
|
Get info
|
|
</MenuItem>
|
|
)
|
|
|
|
if (view === ViewType.File) {
|
|
return (
|
|
<>
|
|
{viewItem}
|
|
{downloadItem}
|
|
<MenuItem
|
|
disabled={isBulk}
|
|
onClick={() => {
|
|
handleCloseContext()
|
|
setShowRenameModal(true)
|
|
}}
|
|
>
|
|
Rename
|
|
</MenuItem>
|
|
<div className="fm-context-item-border" />
|
|
<MenuItem
|
|
disabled={isBulk}
|
|
onClick={() => {
|
|
handleCloseContext()
|
|
setShowVersionHistory(true)
|
|
}}
|
|
>
|
|
Version history
|
|
</MenuItem>
|
|
<MenuItem
|
|
danger
|
|
onClick={() => {
|
|
handleCloseContext()
|
|
|
|
if (isBulk) onBulk.delete?.()
|
|
else setShowDeleteModal(true)
|
|
}}
|
|
>
|
|
Delete
|
|
</MenuItem>
|
|
<div className="fm-context-item-border" />
|
|
{getInfoItem}
|
|
</>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{viewItem}
|
|
{downloadItem}
|
|
<div className="fm-context-item-border" />
|
|
{isBulk ? (
|
|
<>
|
|
<MenuItem danger onClick={onBulk.restore}>
|
|
Restore
|
|
</MenuItem>
|
|
<MenuItem danger onClick={onBulk.destroy}>
|
|
Destroy
|
|
</MenuItem>
|
|
<MenuItem danger onClick={onBulk.forget}>
|
|
Forget permanently
|
|
</MenuItem>
|
|
</>
|
|
) : (
|
|
<>
|
|
<MenuItem
|
|
danger
|
|
onClick={() => {
|
|
handleCloseContext()
|
|
doRecover()
|
|
}}
|
|
>
|
|
Restore
|
|
</MenuItem>
|
|
<MenuItem
|
|
danger
|
|
onClick={() => {
|
|
handleCloseContext()
|
|
|
|
const parentDrive = drives.find(d => d.id.toString() === fileInfo.driveId.toString())
|
|
|
|
if (parentDrive) {
|
|
setDestroyDrive(parentDrive)
|
|
setShowDestroyDriveModal(true)
|
|
} else if (currentDrive) {
|
|
setDestroyDrive(currentDrive)
|
|
setShowDestroyDriveModal(true)
|
|
} else {
|
|
setErrorMessage?.('Unable to resolve drive for this file.')
|
|
setShowError(true)
|
|
}
|
|
}}
|
|
>
|
|
Destroy
|
|
</MenuItem>
|
|
|
|
<MenuItem
|
|
danger
|
|
onClick={() => {
|
|
handleCloseContext()
|
|
setConfirmForget(true)
|
|
}}
|
|
>
|
|
Forget permanently
|
|
</MenuItem>
|
|
</>
|
|
)}
|
|
<div className="fm-context-item-border" />
|
|
{getInfoItem}
|
|
</>
|
|
)
|
|
}, [
|
|
isBulk,
|
|
view,
|
|
handleDownload,
|
|
handleCloseContext,
|
|
openGetInfo,
|
|
doRecover,
|
|
onBulk,
|
|
currentDrive,
|
|
drives,
|
|
fileInfo.driveId,
|
|
setErrorMessage,
|
|
setShowError,
|
|
])
|
|
|
|
useLayoutEffect(() => {
|
|
if (!showContext) return
|
|
|
|
if (rafIdRef.current !== null) {
|
|
cancelAnimationFrame(rafIdRef.current)
|
|
}
|
|
|
|
rafIdRef.current = requestAnimationFrame(() => {
|
|
const menu = contextRef.current
|
|
|
|
if (!menu) return
|
|
|
|
const menuRect = menu.getBoundingClientRect()
|
|
const containerEl = (menu.offsetParent as HTMLElement) ?? null
|
|
const containerRect = containerEl?.getBoundingClientRect() ?? null
|
|
|
|
const { safePos: s, dropDir: d } = computeContextMenuPosition({
|
|
clickPos: pos,
|
|
menuRect: menuRect,
|
|
viewport: { w: window.innerWidth, h: window.innerHeight },
|
|
margin: 8,
|
|
containerRect,
|
|
})
|
|
|
|
const topLeft = containerRect
|
|
? { x: Math.round(s.x - containerRect.left), y: Math.round(s.y - containerRect.top) }
|
|
: s
|
|
setSafePos(topLeft)
|
|
setDropDir(d)
|
|
|
|
rafIdRef.current = null
|
|
})
|
|
|
|
return () => {
|
|
if (rafIdRef.current !== null) {
|
|
cancelAnimationFrame(rafIdRef.current)
|
|
rafIdRef.current = null
|
|
}
|
|
}
|
|
}, [showContext, pos, contextRef])
|
|
|
|
if (!currentDrive || !fm || !beeApi) {
|
|
return <div className="fm-file-item-content">Error</div>
|
|
}
|
|
|
|
return (
|
|
<div className="fm-file-item-content" onContextMenu={handleItemContextMenu} onClick={handleCloseContext}>
|
|
<div className="fm-file-item-content-item fm-checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={selected}
|
|
onChange={e => onToggleSelected?.(fileInfo, e.target.checked)}
|
|
onClick={e => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
|
|
<div className="fm-file-item-content-item fm-name" onDoubleClick={() => handleDownload(true)}>
|
|
<GetIconElement icon={fileInfo.name} />
|
|
{fileInfo.name}
|
|
</div>
|
|
|
|
{showDriveColumn && (
|
|
<div className="fm-file-item-content-item fm-drive">
|
|
<span className="fm-drive-name">{driveName}</span>
|
|
<span className={`fm-pill ${isTrashedFile ? 'fm-pill--trash' : 'fm-pill--active'}`} title={statusLabel}>
|
|
{statusLabel}
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
<div className="fm-file-item-content-item fm-size">{size}</div>
|
|
<div className="fm-file-item-content-item fm-date-mod">{dateMod}</div>
|
|
|
|
{showContext && (
|
|
<div
|
|
ref={contextRef}
|
|
className="fm-file-item-context-menu"
|
|
style={{ top: safePos.y, left: safePos.x }}
|
|
data-drop={dropDir}
|
|
onMouseDown={e => e.stopPropagation()}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
<ContextMenu>{renderContextMenuItems()}</ContextMenu>
|
|
</div>
|
|
)}
|
|
|
|
{showGetInfoModal && infoGroups && (
|
|
<GetInfoModal
|
|
name={fileInfo.name}
|
|
properties={infoGroups}
|
|
onCancelClick={() => {
|
|
setShowGetInfoModal(false)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showVersionHistory && (
|
|
<VersionHistoryModal
|
|
fileInfo={fileInfo}
|
|
onCancelClick={() => {
|
|
setShowVersionHistory(false)
|
|
}}
|
|
onDownload={onDownload}
|
|
/>
|
|
)}
|
|
|
|
{showDeleteModal && (
|
|
<DeleteFileModal
|
|
name={fileInfo.name}
|
|
currentDriveName={currentDrive.name}
|
|
onCancelClick={() => {
|
|
setShowDeleteModal(false)
|
|
}}
|
|
onProceed={action => {
|
|
setShowDeleteModal(false)
|
|
switch (action) {
|
|
case FileAction.Trash:
|
|
doTrash()
|
|
break
|
|
case FileAction.Forget:
|
|
setConfirmForget(true)
|
|
break
|
|
case FileAction.Destroy:
|
|
showDestroyDrive()
|
|
break
|
|
default:
|
|
break
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showRenameModal && (
|
|
<RenameFileModal
|
|
currentName={fileInfo.name}
|
|
takenNames={(() => {
|
|
const sameDrive = files.filter(fi => fi.driveId.toString() === currentDrive.id.toString())
|
|
const names = sameDrive.map(fi => fi.name).filter(n => n && n !== fileInfo.name)
|
|
|
|
return new Set(names)
|
|
})()}
|
|
onCancelClick={() => {
|
|
setShowRenameModal(false)
|
|
}}
|
|
onProceed={async newName => {
|
|
try {
|
|
setShowRenameModal(false)
|
|
await doRename(newName)
|
|
} catch {
|
|
safeSetState(isMountedRef, setShowRenameModal)(true)
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{confirmForget && (
|
|
<ConfirmModal
|
|
title="Forget permanently?"
|
|
message={
|
|
<>
|
|
This removes <b title={fileInfo.name}>{fileInfo.name}</b> from your view.
|
|
<br />
|
|
The data remains on Swarm until the drive expires.
|
|
</>
|
|
}
|
|
confirmLabel="Forget"
|
|
cancelLabel="Cancel"
|
|
onConfirm={async () => {
|
|
await doForget()
|
|
|
|
safeSetState(isMountedRef, setConfirmForget)(false)
|
|
}}
|
|
onCancel={() => {
|
|
setConfirmForget(false)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{showDestroyDriveModal && destroyDrive && (
|
|
<DestroyDriveModal
|
|
drive={destroyDrive}
|
|
onCancelClick={() => {
|
|
setShowDestroyDriveModal(false)
|
|
setDestroyDrive(null)
|
|
}}
|
|
doDestroy={async () => {
|
|
setShowDestroyDriveModal(false)
|
|
|
|
await handleDestroyDrive(
|
|
beeApi,
|
|
fm,
|
|
destroyDrive,
|
|
() => {
|
|
setShowDestroyDriveModal(false)
|
|
setDestroyDrive(null)
|
|
},
|
|
e => {
|
|
setShowDestroyDriveModal(false)
|
|
setErrorMessage?.(`Error destroying drive: ${destroyDrive.name}: ${e}`)
|
|
setShowError(true)
|
|
},
|
|
)
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|