* 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,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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+130
@@ -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 "{currentDrive?.name}" that are trashed can be viewed here
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className="fm-drop-hint">Drag & drop files here into "{currentDrive?.name}"</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 "{currentDrive?.name}" is expired, no files can be found
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (listToRender.length === 0) {
|
||||
if (view === ViewType.Trash) {
|
||||
return (
|
||||
<div className="fm-drop-hint">
|
||||
Files from "{currentDrive?.name}" that are trashed can be viewed here
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className="fm-drop-hint">Drag & drop files here into "{currentDrive?.name}"</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
|
||||
+188
@@ -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>
|
||||
)
|
||||
}
|
||||
+121
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
+35
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user