* 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>
This commit is contained in:
@@ -0,0 +1,599 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user