Feat: FileManager (#98) (#703)

* 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:
Bálint Ujvári
2025-11-12 11:26:00 +01:00
committed by GitHub
parent 1249c0df71
commit 5bfe2a0331
107 changed files with 21529 additions and 5578 deletions
@@ -0,0 +1,318 @@
.fm-file-browser-container {
display: flex;
flex-direction: column;
width: 100%;
height: calc(100vh - 120px);
background-color: white;
position: relative;
}
.fm-file-browser-content {
position: relative;
overflow: auto;
display: flex;
flex-direction: column;
flex: 1 1 auto;
}
.fm-file-browser-content-header {
display: grid;
padding: 12px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgb(226, 232, 240);
}
.fm-file-browser-content[data-search-mode='false'] .fm-file-browser-content-header {
grid-template-columns: 32px 2fr 1fr 1fr;
}
.fm-file-browser-content[data-search-mode='true'] .fm-file-browser-content-header {
grid-template-columns: 32px 2fr 1.1fr 1fr 1fr;
}
.fm-file-browser-content-header-item {
display: flex;
align-items: center;
justify-content: start;
gap: 8px;
font-weight: 700;
& input {
margin: 0;
width: 16px;
height: 16px;
accent-color: rgb(237, 129, 49);
}
}
.fm-file-browser-content-header-item.fm-checkbox {
display: flex;
align-items: center;
justify-content: center;
}
.fm-file-browser-content-header-item-icon {
width: 16px;
height: 16px;
color: rgb(237, 129, 49);
}
.fm-file-browser-content-body {
flex: 1 1 auto;
position: relative;
}
.fm-file-browser-footer {
display: flex;
align-items: center;
justify-content: flex-start;
max-height: 45px;
padding: 12px;
border-top: 1px solid #929292;
background-color: #ededed;
}
.fm-file-browser-footer > * {
flex: 0 0 auto;
}
.fm-file-browser-footer > :nth-child(1) {
margin-right: 8px;
}
.fm-file-browser-footer > :nth-child(3) {
margin-left: auto;
}
.fm-file-browser-footer {
column-gap: 12px;
}
.fm-file-browser-context-menu {
position: absolute;
z-index: 1000;
}
.fm-file-browser-context-menu[data-drop='up'] {
transform-origin: bottom left;
}
.fm-file-browser-context-menu[data-drop='up'] .caret {
transform: rotate(180deg);
bottom: -6px;
top: auto;
}
.fm-context-item {
margin: 4px;
padding: 8px;
&:hover {
background-color: #d1d1d1;
cursor: pointer;
}
&.red {
color: rgb(220, 38, 38);
}
}
.fm-context-item[aria-disabled="true"] {
opacity: 0.5;
cursor: not-allowed;
}
.fm-context-item-border {
border-bottom: 1px solid #d1d1d1;
}
.fm-upload-download-indicator {
text-decoration: underline;
}
.fm-drag-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1500;
background: rgba(255, 255, 255, 0.6);
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
}
.fm-drag-text {
font-size: 1.25rem;
font-weight: 600;
padding: 0.75rem 1rem;
background: rgba(255, 255, 255, 0.85);
border-radius: 8px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
.fm-drop-hint {
padding: 24px;
color: #6b7280;
}
.fm-context-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.fm-info {
font-weight: 600;
width: 16px;
height: 16px;
border-radius: 50%;
display: inline-grid;
place-items: center;
font-size: 11px;
line-height: 1;
border: 1px solid currentColor;
opacity: .6;
cursor: help;
}
.fm-info--inline {
position: relative;
}
.fm-info--inline::after {
content: attr(data-tip);
position: absolute;
left: calc(100% + 8px);
top: 50%;
transform: translateY(-50%);
max-width: 280px;
white-space: normal;
padding: 8px 10px;
border-radius: 6px;
background: rgba(17, 24, 39, 0.98);
color: #fff;
font-size: 12px;
line-height: 1.25;
letter-spacing: 0.1px;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity .08s ease, visibility .08s ease;
z-index: 2000;
}
.fm-info--inline::before {
content: "";
position: absolute;
left: calc(100% + 2px);
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-right-color: rgba(17, 24, 39, 0.98);
opacity: 0;
visibility: hidden;
transition: opacity .08s ease, visibility .08s ease;
z-index: 2001;
}
.fm-info--inline:hover::after,
.fm-info--inline:focus-visible::after,
.fm-info--inline:hover::before,
.fm-info--inline:focus-visible::before {
opacity: 1;
visibility: visible;
}
.fm-file-browser-context-menu {
overflow: visible;
}
.fm-refresh-overlay {
position: absolute;
inset: 0;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(4px);
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
}
.fm-refresh-content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: #374151;
font-size: 14px;
font-weight: 500;
}
.fm-refresh-text {
animation: fadeIn 0.3s ease-in-out;
}
.fm-file-browser-content-header-item {
background: none;
border: 0;
padding: 0;
font: inherit;
text-align: left;
cursor: pointer;
}
.fm-header-cell {
display: inline-flex;
align-items: center;
gap: 6px; /* space between sort button and the × bubble */
}
.fm-header-button {
background: none;
border: 0;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.fm-header-button[data-dir='asc'] .fm-file-browser-content-header-item-icon {
transform: rotate(180deg);
}
.fm-file-browser-content-header-item-icon.is-inactive {
opacity: 0.4;
}
.fm-sort-clear {
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 9999px;
border: 1px solid currentColor;
font-size: 12px;
line-height: 1;
padding: 0;
margin: 0;
background: transparent;
cursor: pointer;
opacity: 0.8;
}
.fm-sort-clear:hover,
.fm-sort-clear:focus-visible {
opacity: 1;
outline: none;
box-shadow: 0 0 0 2px rgba(237, 129, 49, 0.2);
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(2px); }
to { opacity: 1; transform: translateY(0); }
}
@@ -0,0 +1,496 @@
import { ReactElement, useEffect, useLayoutEffect, useRef, useState, useContext } from 'react'
import './FileBrowser.scss'
import { FileBrowserHeader } from './FileBrowserHeader/FileBrowserHeader'
import { FileBrowserContent } from './FileBrowserContent/FileBrowserContent'
import { useContextMenu } from '../../hooks/useContextMenu'
import { NotificationBar } from '../NotificationBar/NotificationBar'
import { FileAction, FileTransferType, TransferStatus, ViewType } from '../../constants/transfers'
import { FileProgressNotification } from '../FileProgressNotification/FileProgressNotification'
import { useView } from '../../../../pages/filemanager/ViewContext'
import { Context as FMContext } from '../../../../providers/FileManager'
import { useTransfers } from '../../hooks/useTransfers'
import { useSearch } from '../../../../pages/filemanager/SearchContext'
import { useFileFiltering } from '../../hooks/useFileFiltering'
import { useDragAndDrop } from '../../hooks/useDragAndDrop'
import { useBulkActions } from '../../hooks/useBulkActions'
import { SortKey, SortDir, useSorting } from '../../hooks/useSorting'
import { Point, Dir, safeSetState } from '../../utils/common'
import { computeContextMenuPosition } from '../../utils/ui'
import { FileBrowserTopBar } from './FileBrowserTopBar/FileBrowserTopBar'
import { handleDestroyDrive } from '../../utils/bee'
import { Context as SettingsContext } from '../../../../providers/Settings'
import { ErrorModal } from '../ErrorModal/ErrorModal'
import { FileBrowserModals } from './FileBrowserModals'
import { FileBrowserContextMenu } from './FileBrowserMenu/FileBrowserContextMenu'
import { FileInfo } from '@solarpunkltd/file-manager-lib'
const extractFilesFromClipboardEvent = (e: React.ClipboardEvent): File[] => {
const out: File[] = []
const items = e.clipboardData?.items ?? []
for (let i = 0; i < items.length; i++) {
const it = items[i]
if (it.kind === 'file') {
const f = it.getAsFile()
if (f) out.push(f)
}
}
return out
}
interface FileBrowserProps {
errorMessage?: string
setErrorMessage?: (error: string) => void
}
export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps): ReactElement {
const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu<HTMLDivElement>()
const { view, setActualItemView } = useView()
const { beeApi } = useContext(SettingsContext)
const { files, currentDrive, resync, drives, fm, showError, setShowError } = useContext(FMContext)
const {
uploadFiles,
isUploading,
uploadItems,
isDownloading,
downloadItems,
trackDownload,
conflictPortal,
cancelOrDismissUpload,
cancelOrDismissDownload,
dismissAllUploads,
dismissAllDownloads,
} = useTransfers({ setErrorMessage })
const { query, scope, includeActive, includeTrashed } = useSearch()
const [safePos, setSafePos] = useState<Point>(pos)
const [dropDir, setDropDir] = useState<Dir>(Dir.Down)
const legacyUploadRef = useRef<HTMLInputElement | null>(null)
const contentRef = useRef<HTMLDivElement | null>(null)
const bodyRef = useRef<HTMLDivElement | null>(null)
const isMountedRef = useRef(true)
const rafIdRef = useRef<number | null>(null)
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
const [confirmBulkForget, setConfirmBulkForget] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [pendingCancelUpload, setPendingCancelUpload] = useState<string | null>(null)
const q = query.trim().toLowerCase()
const isSearchMode = q.length > 0
const getDriveName = (fi: FileInfo): string => {
const match = drives.find(d => d.id.toString() === fi.driveId.toString())
return match?.name ?? ''
}
const openTopbarMenu = (anchorEl: HTMLElement) => {
const r = anchorEl.getBoundingClientRect()
const bodyRect = bodyRef.current?.getBoundingClientRect()
const clickX = Math.round(r.right - 6)
const minY = (bodyRect?.top ?? 0) + 8
const clickY = Math.max(Math.round(r.bottom + 6), minY)
const fakeEvt = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
preventDefault: () => {},
// eslint-disable-next-line @typescript-eslint/no-empty-function
stopPropagation: () => {},
clientX: clickX,
clientY: clickY,
} as React.MouseEvent<HTMLDivElement>
handleContextMenu(fakeEvt)
}
const { listToRender } = useFileFiltering({
files,
currentDrive: currentDrive || null,
view,
isSearchMode,
query: q,
scope,
includeActive,
includeTrashed,
})
const { sorted, sort, toggle, reset } = useSorting(listToRender, {
persist: false,
defaultState: { key: SortKey.Timestamp, dir: SortDir.Desc },
getDriveName,
})
const bulk = useBulkActions({
listToRender,
trackDownload,
})
const { isDragging, handleDragEnter, handleDragOver, handleDragLeave, handleDrop, handleOverlayDrop } =
useDragAndDrop({
onFilesDropped: uploadFiles,
currentDrive: currentDrive || null,
})
const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (files && files.length > 0) {
uploadFiles(files)
}
e.target.value = ''
}
const onContextUploadFile = () => {
const el = legacyUploadRef.current
if (!el) return
try {
if (typeof (el as HTMLInputElement).showPicker === 'function') {
;(el as HTMLInputElement).showPicker()
} else {
el.click()
}
} catch {
el.click()
}
requestAnimationFrame(() => handleCloseContext())
}
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
const files = extractFilesFromClipboardEvent(e)
if (files.length === 0) return
e.preventDefault()
uploadFiles(files)
}
const handleFileBrowserContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
const t = e.target as HTMLElement
if (t.closest('.fm-file-item-context-menu, .fm-file-browser-context-menu')) return
if (!e.shiftKey && t.closest('.fm-file-item-content')) return
e.preventDefault()
e.stopPropagation()
handleContextMenu(e)
}
const handleDeleteModalProceed = async (action: FileAction) => {
setShowBulkDeleteModal(false)
if (action === FileAction.Trash) {
return await bulk.bulkTrash(bulk.selectedFiles)
}
if (action === FileAction.Forget) {
return setConfirmBulkForget(true)
}
if (action === FileAction.Destroy) {
return setShowDestroyDriveModal(true)
}
}
const handleDestroyDriveConfirm = async () => {
if (!currentDrive) return
setShowDestroyDriveModal(false)
await handleDestroyDrive(
beeApi,
fm,
currentDrive,
() => {
setShowDestroyDriveModal(false)
},
e => {
setErrorMessage?.(`Error destroying drive: ${currentDrive.name}: ${e}`)
setShowError(true)
},
)
}
const handleUploadClose = (name: string) => {
const row = uploadItems.find(i => i.name === name)
if (row?.status === TransferStatus.Uploading) {
setPendingCancelUpload(name)
} else {
cancelOrDismissUpload(name)
}
}
const updateContextMenuPosition = () => {
const menu = contextRef.current
const body = bodyRef.current
if (!menu) return
const rect = menu.getBoundingClientRect()
const containerRect = body?.getBoundingClientRect() ?? null
const { safePos: sp, dropDir: dd } = computeContextMenuPosition({
clickPos: pos,
menuRect: rect,
viewport: { w: window.innerWidth, h: window.innerHeight },
margin: 8,
containerRect,
})
const topLeft = containerRect
? { x: Math.round(sp.x - containerRect.left), y: Math.round(sp.y - containerRect.top + 2) }
: sp
setSafePos(topLeft)
setDropDir(dd)
rafIdRef.current = null
}
useLayoutEffect(() => {
if (!showContext) return
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current)
}
rafIdRef.current = requestAnimationFrame(updateContextMenuPosition)
return () => {
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current)
rafIdRef.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [showContext, pos, contextRef])
useEffect(() => {
let title = currentDrive?.name || ''
if (isSearchMode) {
title = 'Search results'
if (scope === 'selected' && currentDrive?.name) {
title += `${currentDrive.name}`
}
}
setActualItemView?.(title)
}, [isSearchMode, scope, currentDrive, setActualItemView])
useEffect(() => {
if (!isSearchMode) {
bulk.clearAll()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isSearchMode])
useEffect(() => {
return () => {
isMountedRef.current = false
if (rafIdRef.current) {
cancelAnimationFrame(rafIdRef.current)
}
}
}, [])
const doRefresh = async () => {
handleCloseContext()
if (isRefreshing) return
setIsRefreshing(true)
try {
await resync()
} finally {
safeSetState(isMountedRef, setIsRefreshing)(false)
}
}
const showDeleteModal = showBulkDeleteModal && bulk.selectedFiles.length > 0 && view === ViewType.File
const showDragOverlay = isDragging && Boolean(currentDrive)
const fileCountText = bulk.selectedFiles.length === 1 ? 'file' : 'files'
return (
<>
{conflictPortal}
<input type="file" ref={legacyUploadRef} style={{ display: 'none' }} onChange={onFileSelected} multiple />
<input type="file" ref={bulk.fileInputRef} style={{ display: 'none' }} onChange={onFileSelected} multiple />
<div className="fm-file-browser-container" data-search-mode={isSearchMode ? 'true' : 'false'}>
<FileBrowserTopBar onOpenMenu={openTopbarMenu} canOpen={!isSearchMode && Boolean(currentDrive)} />
<div
className="fm-file-browser-content"
data-search-mode={isSearchMode ? 'true' : 'false'}
ref={contentRef}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onPaste={handlePaste}
onContextMenu={handleFileBrowserContextMenu}
>
<FileBrowserHeader
key={isSearchMode ? 'hdr-search' : 'hdr-normal'}
isSearchMode={isSearchMode}
bulk={bulk}
sortKey={sort.key}
sortDir={sort.dir}
onSortName={() => toggle(SortKey.Name)}
onSortSize={() => toggle(SortKey.Size)}
onSortDate={() => toggle(SortKey.Timestamp)}
onSortDrive={() => toggle(SortKey.Drive)}
onClearSort={reset}
/>
<div
className="fm-file-browser-content-body"
ref={bodyRef}
onMouseDown={e => {
if (e.button !== 0) return
handleCloseContext()
}}
>
<FileBrowserContent
key={isSearchMode ? `content-search` : `content-${currentDrive?.id.toString() ?? 'none'}`}
listToRender={sorted}
drives={drives}
currentDrive={currentDrive || null}
view={view}
isSearchMode={isSearchMode}
trackDownload={trackDownload}
selectedIds={bulk.selectedIds}
onToggleSelected={bulk.toggleOne}
bulkSelectedCount={bulk.selectedCount}
onBulk={{
download: () => bulk.bulkDownload(bulk.selectedFiles),
restore: () => bulk.bulkRestore(bulk.selectedFiles),
forget: () => bulk.bulkForget(bulk.selectedFiles),
destroy: () => setShowDestroyDriveModal(true),
delete: () => setShowBulkDeleteModal(true),
}}
setErrorMessage={setErrorMessage}
/>
{showError && (
<ErrorModal
label={errorMessage || 'An error occurred'}
onClick={() => {
setShowError(false)
setErrorMessage?.('')
return
}}
/>
)}
{showContext && (
<div
ref={contextRef}
className="fm-file-browser-context-menu fm-context-menu"
style={{ top: safePos.y, left: safePos.x }}
data-drop={dropDir}
onMouseDown={e => e.stopPropagation()}
onClick={e => e.stopPropagation()}
>
<FileBrowserContextMenu
drives={drives}
view={view}
selectedFilesCount={bulk.selectedFiles.length}
onRefresh={doRefresh}
enableRefresh={Boolean(fm?.adminStamp)}
onUploadFile={onContextUploadFile}
onBulkDownload={() => bulk.bulkDownload(bulk.selectedFiles)}
onBulkRestore={() => bulk.bulkRestore(bulk.selectedFiles)}
onBulkDelete={() => setShowBulkDeleteModal(true)}
onBulkDestroy={() => setShowDestroyDriveModal(true)}
onBulkForget={() => bulk.bulkForget(bulk.selectedFiles)}
/>
</div>
)}
</div>
{showDragOverlay && (
<div
className="fm-drag-overlay"
onDragOver={e => {
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
}}
onDrop={handleOverlayDrop}
>
<div className="fm-drag-text">Drop file(s) to upload</div>
</div>
)}
<FileBrowserModals
showDeleteModal={showDeleteModal}
selectedFiles={bulk.selectedFiles}
fileCountText={fileCountText}
currentDrive={currentDrive || null}
confirmBulkForget={confirmBulkForget}
showDestroyDriveModal={showDestroyDriveModal}
pendingCancelUpload={pendingCancelUpload}
onDeleteCancel={() => setShowBulkDeleteModal(false)}
onDeleteProceed={handleDeleteModalProceed}
onForgetConfirm={async () => {
await bulk.bulkForget(bulk.selectedFiles)
setConfirmBulkForget(false)
}}
onForgetCancel={() => setConfirmBulkForget(false)}
onDestroyCancel={() => setShowDestroyDriveModal(false)}
onDestroyConfirm={handleDestroyDriveConfirm}
onCancelUploadConfirm={() => {
if (pendingCancelUpload) {
cancelOrDismissUpload(pendingCancelUpload)
setPendingCancelUpload(null)
}
}}
onCancelUploadCancel={() => setPendingCancelUpload(null)}
/>
{isRefreshing && (
<div className="fm-refresh-overlay" aria-busy="true" aria-live="polite">
<div className="fm-refresh-content">
<div className="fm-mini-spinner" role="status" aria-label="Syncing…" />
<span className="fm-refresh-text">Syncing latest files</span>
</div>
</div>
)}
</div>
<div className="fm-file-browser-footer">
<FileProgressNotification
label="Uploading files"
type={FileTransferType.Upload}
open={isUploading}
count={uploadItems.length}
items={uploadItems}
onRowClose={handleUploadClose}
onCloseAll={() => dismissAllUploads()}
/>
<FileProgressNotification
label="Downloading files"
type={FileTransferType.Download}
open={isDownloading}
count={downloadItems.length}
items={downloadItems}
onRowClose={name => cancelOrDismissDownload(name)}
onCloseAll={() => dismissAllDownloads()}
/>
<NotificationBar setErrorMessage={setErrorMessage} />
</div>
</div>
</>
)
}
@@ -0,0 +1,130 @@
import { ReactElement, useCallback } from 'react'
import { FileItem } from '../FileItem/FileItem'
import { FileInfo, DriveInfo } from '@solarpunkltd/file-manager-lib'
import { DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
import { getFileId } from '../../../utils/common'
interface FileBrowserContentProps {
listToRender: FileInfo[]
drives: DriveInfo[]
currentDrive: DriveInfo | null
view: ViewType
isSearchMode: boolean
trackDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
selectedIds?: Set<string>
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 FileBrowserContent({
listToRender,
drives,
currentDrive,
view,
isSearchMode,
trackDownload,
selectedIds,
onToggleSelected,
bulkSelectedCount,
onBulk,
setErrorMessage,
}: FileBrowserContentProps): ReactElement {
const renderEmptyState = useCallback((): ReactElement => {
if (drives.length === 0) {
return <div className="fm-drop-hint">Create a drive to start using the file manager</div>
}
if (!currentDrive) {
return <div className="fm-drop-hint">Select a drive to upload or view its files</div>
}
if (view === ViewType.Trash) {
return (
<div className="fm-drop-hint">
Files from &quot;{currentDrive?.name}&quot; that are trashed can be viewed here
</div>
)
}
return <div className="fm-drop-hint">Drag &amp; drop files here into &quot;{currentDrive?.name}&quot;</div>
}, [drives, currentDrive, view])
const renderFileList = useCallback(
(filesToRender: FileInfo[], showDriveColumn = false): ReactElement[] => {
return filesToRender
.map(fi => {
const drive = drives.find(d => d.id.toString() === fi.driveId.toString())
return drive ? { fi, driveName: drive.name } : null
})
.filter((item): item is { fi: FileInfo; driveName: string } => item !== null)
.map(({ fi, driveName }) => {
const key = `${getFileId(fi)}::${fi.version ?? ''}::${showDriveColumn ? 'search' : 'normal'}`
return (
<FileItem
key={key}
fileInfo={fi}
onDownload={trackDownload}
showDriveColumn={showDriveColumn}
driveName={driveName}
selected={Boolean(selectedIds?.has(getFileId(fi)))}
onToggleSelected={onToggleSelected}
bulkSelectedCount={bulkSelectedCount}
onBulk={onBulk}
setErrorMessage={setErrorMessage}
/>
)
})
},
[trackDownload, drives, selectedIds, onToggleSelected, bulkSelectedCount, onBulk, setErrorMessage],
)
if (drives.length === 0) {
return renderEmptyState()
}
if (!isSearchMode) {
if (!currentDrive) {
return <div className="fm-drop-hint">Select a drive to upload or view its files</div>
}
if (view === ViewType.Expired) {
return (
<div className="fm-drop-hint">
The stamp for drive &quot;{currentDrive?.name}&quot; is expired, no files can be found
</div>
)
}
if (listToRender.length === 0) {
if (view === ViewType.Trash) {
return (
<div className="fm-drop-hint">
Files from &quot;{currentDrive?.name}&quot; that are trashed can be viewed here
</div>
)
}
return <div className="fm-drop-hint">Drag &amp; drop files here into &quot;{currentDrive?.name}&quot;</div>
}
return <>{renderFileList(listToRender)}</>
}
if (listToRender.length === 0) {
return <div className="fm-drop-hint">No results found.</div>
}
return <>{renderFileList(listToRender, true)}</>
}
export default FileBrowserContent
@@ -0,0 +1,188 @@
import { ReactElement } from 'react'
import DownIcon from 'remixicon-react/ArrowDownSLineIcon'
import { useBulkActions } from '../../../hooks/useBulkActions'
import { SortDir, SortKey } from '../../../hooks/useSorting'
import { capitalizeFirstLetter } from '../../../../../../src/modules/filemanager/utils/common'
interface FileBrowserHeaderProps {
isSearchMode: boolean
bulk: ReturnType<typeof useBulkActions>
sortKey: SortKey
sortDir: SortDir
onSortName: () => void
onSortSize: () => void
onSortDate: () => void
onSortDrive: () => void
onClearSort: () => void
}
enum AriaSortValue {
Ascending = 'ascending',
Descending = 'descending',
None = 'none',
}
const Arrow = ({ active, dir }: { active: boolean; dir: SortDir }) => {
let title: string | undefined
if (active) {
const sortValue = dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
title = capitalizeFirstLetter(sortValue)
} else {
title = undefined
}
return (
<div
className={'fm-file-browser-content-header-item-icon' + (active ? '' : ' is-inactive')}
aria-hidden={title ? 'false' : 'true'}
aria-label={title}
title={title}
>
<DownIcon size="16px" />
</div>
)
}
function HeaderCell({
label,
isActive,
dir,
onToggle,
onClear,
ariaSort,
'data-testid': testId,
}: {
label: string
isActive: boolean
dir: SortDir
onToggle: () => void
onClear: () => void
ariaSort: AriaSortValue
'data-testid'?: string
}) {
return (
<div className="fm-header-cell" role="columnheader" aria-sort={ariaSort} data-testid={testId}>
<button
type="button"
className="fm-header-button"
onClick={onToggle}
data-dir={isActive ? dir : undefined}
aria-label={
isActive
? `Sort by ${label.toLowerCase()}, currently ${
dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
}`
: `Sort by ${label.toLowerCase()}`
}
title={
isActive
? `Currently ${capitalizeFirstLetter(
dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending,
)}`
: 'Click to sort'
}
>
<span>{label}</span>
<Arrow active={isActive} dir={dir} />
</button>
{isActive && (
<button
type="button"
className="fm-sort-clear"
onClick={e => {
e.preventDefault()
e.stopPropagation()
onClear()
}}
aria-label="Reset sorting to default"
title="Clear sorting"
>
×
</button>
)}
</div>
)
}
export function FileBrowserHeader({
isSearchMode,
bulk,
sortKey,
sortDir,
onSortName,
onSortSize,
onSortDate,
onSortDrive,
onClearSort,
}: FileBrowserHeaderProps): ReactElement {
const ariaSort = (thisKey: SortKey): AriaSortValue => {
if (sortKey !== thisKey) return AriaSortValue.None
return sortDir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
}
return (
<div className="fm-file-browser-content-header" role="row">
<input
type="checkbox"
checked={bulk.allChecked}
ref={el => {
if (el) el.indeterminate = bulk.someChecked
}}
onChange={e => (e.target.checked ? bulk.selectAll() : bulk.clearAll())}
/>
<div className="fm-file-browser-content-header-item fm-name">
<HeaderCell
label="Name"
isActive={sortKey === SortKey.Name}
dir={sortDir}
onToggle={onSortName}
onClear={onClearSort}
ariaSort={ariaSort(SortKey.Name)}
data-testid="hdr-name"
/>
</div>
{isSearchMode && (
<div className="fm-file-browser-content-header-item fm-drive">
<HeaderCell
label="Drive"
isActive={sortKey === SortKey.Drive}
dir={sortDir}
onToggle={onSortDrive}
onClear={onClearSort}
ariaSort={ariaSort(SortKey.Drive)}
data-testid="hdr-drive"
/>
</div>
)}
<div className="fm-file-browser-content-header-item fm-size">
<HeaderCell
label="Size"
isActive={sortKey === SortKey.Size}
dir={sortDir}
onToggle={onSortSize}
onClear={onClearSort}
ariaSort={ariaSort(SortKey.Size)}
data-testid="hdr-size"
/>
</div>
<div className="fm-file-browser-content-header-item fm-date-mod">
<HeaderCell
label="Date mod."
isActive={sortKey === SortKey.Timestamp}
dir={sortDir}
onToggle={onSortDate}
onClear={onClearSort}
ariaSort={ariaSort(SortKey.Timestamp)}
data-testid="hdr-date"
/>
</div>
</div>
)
}
@@ -0,0 +1,121 @@
import { ContextMenu } from '../../ContextMenu/ContextMenu'
import { ReactElement } from 'react'
import '../FileBrowser.scss'
import { ViewType } from '../../../constants/transfers'
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
import { Tooltip } from '../../Tooltip/Tooltip'
interface FileBrowserContextMenuProps {
drives: DriveInfo[]
view: ViewType
selectedFilesCount: number
onRefresh: () => void
onUploadFile: () => void
onBulkDownload: () => void
onBulkRestore: () => void
onBulkDelete: () => void
onBulkDestroy: () => void
onBulkForget: () => void
enableRefresh?: boolean
}
export function FileBrowserContextMenu({
drives,
view,
selectedFilesCount,
onRefresh,
onUploadFile,
onBulkDownload,
onBulkRestore,
onBulkDelete,
onBulkDestroy,
onBulkForget,
enableRefresh,
}: FileBrowserContextMenuProps): ReactElement {
if (drives.length === 0) {
if (!enableRefresh) {
return <></>
}
return (
<ContextMenu>
<div className="fm-context-item" onClick={onRefresh}>
Refresh
</div>
</ContextMenu>
)
}
if (selectedFilesCount > 1) {
return (
<ContextMenu>
<div className="fm-context-item" onClick={onBulkDownload}>
Download
</div>
{view === ViewType.File ? (
<div className="fm-context-item red" onClick={onBulkDelete}>
Delete
</div>
) : (
<>
<div className="fm-context-item" onClick={onBulkRestore}>
Restore
</div>
<div className="fm-context-item red" onClick={onBulkDestroy}>
Destroy
</div>
<div className="fm-context-item red" onClick={onBulkForget}>
Forget permanently
</div>
</>
)}
</ContextMenu>
)
}
if (view === ViewType.Trash) {
return (
<ContextMenu>
<div className="fm-context-item" onClick={onRefresh}>
Refresh
</div>
</ContextMenu>
)
}
return (
<ContextMenu>
<div className="fm-context-item" style={{ display: 'none' }}>
New folder
</div>
<div className="fm-context-item" onClick={onUploadFile}>
Upload file(s)
</div>
<div className="fm-context-item" style={{ display: 'none' }}>
Upload folder
</div>
<div className="fm-context-item-border" />
<div
className="fm-context-item"
role="menuitem"
aria-disabled="true"
tabIndex={-1}
onMouseDown={e => e.stopPropagation()}
onClick={e => {
e.preventDefault()
e.stopPropagation()
}}
>
<span>
<Tooltip label="Tip: Use ⌘V / Ctrl+V or Browser → Edit → Paste." iconSize="14px" gapPx={6} disableMargin>
Paste
</Tooltip>
</span>
</div>
<div className="fm-context-item-border" />
<div className="fm-context-item" onClick={onRefresh}>
Refresh
</div>
</ContextMenu>
)
}
@@ -0,0 +1,95 @@
import { ReactElement } from 'react'
import type { FileInfo, DriveInfo } from '@solarpunkltd/file-manager-lib'
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
import { DeleteFileModal } from '../DeleteFileModal/DeleteFileModal'
import { DestroyDriveModal } from '../DestroyDriveModal/DestroyDriveModal'
import { FileAction } from '../../constants/transfers'
interface FileBrowserModalsProps {
showDeleteModal: boolean
selectedFiles: FileInfo[]
fileCountText: string
currentDrive: DriveInfo | null
confirmBulkForget: boolean
showDestroyDriveModal: boolean
pendingCancelUpload: string | null
onDeleteCancel: () => void
onDeleteProceed: (action: FileAction) => void
onForgetConfirm: () => Promise<void>
onForgetCancel: () => void
onDestroyCancel: () => void
onDestroyConfirm: () => Promise<void>
onCancelUploadConfirm: () => void
onCancelUploadCancel: () => void
}
export function FileBrowserModals({
showDeleteModal,
selectedFiles,
fileCountText,
currentDrive,
confirmBulkForget,
showDestroyDriveModal,
pendingCancelUpload,
onDeleteCancel,
onDeleteProceed,
onForgetConfirm,
onForgetCancel,
onDestroyCancel,
onDestroyConfirm,
onCancelUploadConfirm,
onCancelUploadCancel,
}: FileBrowserModalsProps): ReactElement {
return (
<>
{showDeleteModal && (
<DeleteFileModal
names={selectedFiles.map(f => f.name)}
currentDriveName={currentDrive?.name}
onCancelClick={onDeleteCancel}
onProceed={onDeleteProceed}
/>
)}
{confirmBulkForget && (
<ConfirmModal
title="Forget permanently?"
message={
<>
This removes <b>{selectedFiles.length}</b> {fileCountText} from your view.
<br />
The data remains on Swarm until the drive expires.
</>
}
confirmLabel="Forget"
cancelLabel="Cancel"
onConfirm={onForgetConfirm}
onCancel={onForgetCancel}
/>
)}
{showDestroyDriveModal && currentDrive && (
<DestroyDriveModal drive={currentDrive} onCancelClick={onDestroyCancel} doDestroy={onDestroyConfirm} />
)}
{pendingCancelUpload && (
<ConfirmModal
title="Cancel upload?"
message={
<>
Stopping now will cancel the network request. Data already transmitted cannot be reverted.{' '}
<b>We will try our best to clean up the transmitted data.</b>
<br />
To remove any (remaining) cancelled items from your browser view later, use{' '}
<i>Right-click Delete Forget</i>.
</>
}
confirmLabel="Cancel upload"
cancelLabel="Keep uploading"
onConfirm={onCancelUploadConfirm}
onCancel={onCancelUploadCancel}
/>
)}
</>
)
}
@@ -0,0 +1,52 @@
.fm-file-browser-top-bar {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background-color: rgb(237, 129, 49);
padding: 12px;
gap: 8px;
color: rgb(255, 255, 255);
font-weight: 700;
}
.fm-file-browser-container[data-search-mode="true"] .fm-file-browser-top-bar {
background-color: rgb(37, 99, 235);
}
.fm-file-browser-top-bar__title {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.fm-topbar-kebab {
appearance: none;
border: 0;
background: transparent;
color: rgb(255, 255, 255);
line-height: 1;
font-size: 12px;
font-weight: bold;
border-radius: 4px;
display: inline-grid;
place-items: center;
cursor: pointer;
transition: background-color .12s ease, opacity .12s ease;
}
.fm-topbar-kebab:hover,
.fm-topbar-kebab:focus-visible {
background: rgba(255,255,255,.12);
outline: none;
}
.fm-topbar-kebab:active {
background: rgba(255,255,255,.18);
}
.fm-topbar-kebab:disabled {
opacity: .5;
cursor: not-allowed;
background: transparent;
}
@@ -0,0 +1,35 @@
import { ReactElement } from 'react'
import './FileBrowserTopBar.scss'
import { useView } from '../../../../../pages/filemanager/ViewContext'
import { ViewType } from '../../../constants/transfers'
type Props = {
onOpenMenu?: (anchorEl: HTMLElement) => void
canOpen?: boolean
}
export function FileBrowserTopBar({ onOpenMenu, canOpen = true }: Props): ReactElement {
const { view, actualItemView } = useView()
const viewText = view === ViewType.Trash ? ' Trash' : ''
return (
<div className="fm-file-browser-top-bar">
<div className="fm-file-browser-top-bar__title">
{actualItemView}
{viewText}
</div>
{canOpen && (
<button
type="button"
className="fm-topbar-kebab"
aria-haspopup="menu"
aria-label="More actions"
onClick={e => onOpenMenu?.(e.currentTarget)}
>
</button>
)}
</div>
)
}
@@ -0,0 +1,104 @@
.fm-file-item-content {
position: relative;
display: grid;
padding: 12px;
& input {
margin: 0;
width: 16px;
height: 16px;
}
&:hover { background-color: #d1d1d1; }
}
.fm-file-browser-content[data-search-mode='false'] .fm-file-item-content {
grid-template-columns: 32px 2fr 1fr 1fr;
}
.fm-file-browser-content[data-search-mode='true'] .fm-file-item-content {
grid-template-columns: 32px 2fr 1.1fr 1fr 1fr;
}
.fm-file-item-content-item {
display: flex;
align-items: center;
justify-content: start;
gap: 6px;
& input {
accent-color: var(--fm-accent, rgb(237, 129, 49));
}
}
.fm-file-item-content-item.fm-checkbox {
display: flex;
align-items: center;
justify-content: center;
}
.fm-file-item-name,
.fm-file-item-content-item.fm-name {
font-weight: 400;
gap: 8px;
& svg {
width: 20px;
height: 20px;
color: var(--fm-accent, #ed8131);
}
}
.fm-file-item-content-item.fm-drive {
gap: 8px;
min-width: 160px;
max-width: 240px;
flex: 0 0 180px;
opacity: 0.9;
}
.fm-drive-name { opacity: 0.9; }
.fm-pill {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
line-height: 1;
border: 1px solid transparent;
white-space: nowrap;
}
.fm-pill--active {
background: #e0f2fe;
color: #075985;
border-color: #bae6fd;
}
.fm-pill--trash {
background: #fee2e2;
color: #b91c1c;
border-color: #fecaca;
}
.fm-file-browser-content[data-search-mode='true'] .fm-file-item-content::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--fm-accent, #2563eb);
border-radius: 2px;
}
.fm-file-item-context-menu {
position: absolute;
z-index: 1000;
}
.fm-file-item-context-menu[data-drop='up'] {
transform-origin: bottom left;
}
.fm-file-item-context-menu[data-drop='up'] .caret {
transform: rotate(180deg);
bottom: -6px;
top: auto;
}
@@ -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>
)
}