Fix: file-manager and swarm-desktop bugs (#714)
- drive capacity display with stamp polling - download/upload progress handling - overlay and tooltip issues - FileMaganger readme - ultra-light mode handling - account feed view page - download media files - remove not found syncing link - fix ultra light node wallet page - tooltip issues --------- Co-authored-by: Andrei Mitrea <andrei.mitrea.hq@gmail.com> Co-authored-by: nidishk <nidishkrishnan45@gmail.com> Co-authored-by: Ferenc Sárai <sarai.ferenc@gmail.com> Co-authored-by: Nándor Komlódi <nandor.komlodi@gmail.com> Co-authored-by: rolandlor <33499567+rolandlor@users.noreply.github.com>
This commit is contained in:
@@ -1,13 +1,17 @@
|
||||
import { ReactElement, useState, useMemo, useEffect, useRef, useContext } from 'react'
|
||||
import { ReactElement, useState, useMemo, useEffect, useContext, useCallback } from 'react'
|
||||
import './AdminStatusBar.scss'
|
||||
import { ProgressBar } from '../ProgressBar/ProgressBar'
|
||||
import { Tooltip } from '../Tooltip/Tooltip'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { UpgradeDriveModal } from '../UpgradeDriveModal/UpgradeDriveModal'
|
||||
import { calculateStampCapacityMetrics } from '../../utils/bee'
|
||||
import { DriveInfo, estimateDriveListMetadataSize } from '@solarpunkltd/file-manager-lib'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
|
||||
import { calculateStampCapacityMetrics } from '../../utils/bee'
|
||||
import { getHumanReadableFileSize } from '../../../../utils/file'
|
||||
import { UpgradeDriveModal } from '../UpgradeDriveModal/UpgradeDriveModal'
|
||||
import { UpgradeTimeoutModal } from '../UpgradeTimeoutModal/UpgradeTimeoutModal'
|
||||
import { FILE_MANAGER_EVENTS, POLLING_TIMEOUT_MS } from '../../constants/common'
|
||||
import { useStampPolling } from '../../hooks/useStampPolling'
|
||||
|
||||
interface AdminStatusBarProps {
|
||||
adminStamp: PostageBatch | null
|
||||
@@ -24,34 +28,61 @@ export function AdminStatusBar({
|
||||
isCreationInProgress,
|
||||
setErrorMessage,
|
||||
}: AdminStatusBarProps): ReactElement {
|
||||
const { setShowError, refreshStamp } = useContext(FMContext)
|
||||
const { drives, setShowError, refreshStamp } = useContext(FMContext)
|
||||
|
||||
const [isUpgradeDriveModalOpen, setIsUpgradeDriveModalOpen] = useState(false)
|
||||
const [isUpgradeTimeoutModalOpen, setIsUpgradeTimeoutModalOpen] = useState(false)
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
const [actualStamp, setActualStamp] = useState<PostageBatch | null>(adminStamp)
|
||||
const [showProgressModal, setShowProgressModal] = useState(true)
|
||||
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
const handleStampUpdated = useCallback((updatedStamp: PostageBatch) => {
|
||||
setActualStamp(updatedStamp)
|
||||
}, [])
|
||||
|
||||
const handlePollingStateChange = useCallback((_isPolling: boolean) => {
|
||||
// no-op
|
||||
}, [])
|
||||
|
||||
const { startPolling } = useStampPolling({
|
||||
onStampUpdated: handleStampUpdated,
|
||||
onPollingStateChange: handlePollingStateChange,
|
||||
refreshStamp,
|
||||
timeout: POLLING_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setShowProgressModal(isCreationInProgress || loading)
|
||||
}, [isCreationInProgress, loading, setShowProgressModal])
|
||||
|
||||
useEffect(() => {
|
||||
setActualStamp(adminStamp)
|
||||
}, [adminStamp, setActualStamp])
|
||||
if (!adminStamp || !actualStamp) {
|
||||
setActualStamp(adminStamp)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (actualStamp.batchID.toString() !== adminStamp.batchID.toString()) {
|
||||
setActualStamp(adminStamp)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const incomingSize = adminStamp.size.toBytes()
|
||||
const currentSize = actualStamp.size.toBytes()
|
||||
const incomingExpiry = adminStamp.duration.toEndDate().getTime()
|
||||
const currentExpiry = actualStamp.duration.toEndDate().getTime()
|
||||
|
||||
if (incomingSize > currentSize || incomingExpiry > currentExpiry) {
|
||||
setActualStamp(adminStamp)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [adminStamp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminDrive) return
|
||||
|
||||
const id = adminDrive.id.toString()
|
||||
const batchId = adminStamp?.batchID.toString() || ''
|
||||
|
||||
const onStart = (e: Event) => {
|
||||
const { driveId } = (e as CustomEvent).detail || {}
|
||||
@@ -61,43 +92,79 @@ export function AdminStatusBar({
|
||||
}
|
||||
}
|
||||
|
||||
const onEnd = async (e: Event) => {
|
||||
const { driveId, success, error } = (e as CustomEvent).detail || {}
|
||||
const onEnd = (e: Event) => {
|
||||
const { driveId, success, error, updatedStamp } = (e as CustomEvent).detail || {}
|
||||
|
||||
if (!success) {
|
||||
if (error) {
|
||||
setErrorMessage?.(error)
|
||||
}
|
||||
if (driveId !== id) return
|
||||
|
||||
if (!success && error) {
|
||||
setIsUpgrading(false)
|
||||
setErrorMessage?.(error)
|
||||
setShowError(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (driveId === id && batchId) {
|
||||
setIsUpgrading(false)
|
||||
if (updatedStamp) {
|
||||
setActualStamp(updatedStamp)
|
||||
}
|
||||
|
||||
const upgradedStamp = await refreshStamp(batchId)
|
||||
setIsUpgrading(false)
|
||||
}
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
const onTimeout = (e: Event) => {
|
||||
const { driveId } = (e as CustomEvent).detail || {}
|
||||
|
||||
if (upgradedStamp) {
|
||||
setActualStamp(upgradedStamp)
|
||||
}
|
||||
if (driveId === id) {
|
||||
setIsUpgradeTimeoutModalOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||
window.addEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_START, onStart as EventListener)
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, onEnd as EventListener)
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_TIMEOUT, onTimeout as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||
window.removeEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_START, onStart as EventListener)
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, onEnd as EventListener)
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_TIMEOUT, onTimeout as EventListener)
|
||||
}
|
||||
}, [adminDrive, adminStamp?.batchID, setErrorMessage, setShowError, refreshStamp, setIsUpgrading])
|
||||
}, [adminDrive, setErrorMessage, setShowError])
|
||||
|
||||
const { capacityPct, usedSize, totalSize } = useMemo(
|
||||
() => calculateStampCapacityMetrics(actualStamp, adminDrive),
|
||||
[actualStamp, adminDrive],
|
||||
)
|
||||
const handleTimeoutCancel = useCallback(() => {
|
||||
setIsUpgrading(false)
|
||||
setIsUpgradeTimeoutModalOpen(false)
|
||||
|
||||
// Restart polling to continue checking for capacity updates
|
||||
if (actualStamp) {
|
||||
startPolling(actualStamp)
|
||||
}
|
||||
}, [actualStamp, startPolling])
|
||||
|
||||
const { capacityPct, usedSize, totalSize } = useMemo(() => {
|
||||
if (!actualStamp) {
|
||||
return {
|
||||
capacityPct: 0,
|
||||
usedSize: '—',
|
||||
totalSize: '—',
|
||||
}
|
||||
}
|
||||
|
||||
const estimatedDlSizeBytes = estimateDriveListMetadataSize(drives) * drives.length
|
||||
const {
|
||||
capacityPct: reportedPct,
|
||||
usedBytes: reportedUsedBytes,
|
||||
stampSizeBytes,
|
||||
} = calculateStampCapacityMetrics(actualStamp, [], adminDrive?.redundancyLevel)
|
||||
const actualUsedSizeBytes = Math.max(reportedUsedBytes, estimatedDlSizeBytes)
|
||||
const actualPct = Math.max(reportedPct, (actualUsedSizeBytes / stampSizeBytes) * 100)
|
||||
|
||||
return {
|
||||
capacityPct: actualPct,
|
||||
usedSize: getHumanReadableFileSize(actualUsedSizeBytes),
|
||||
totalSize: getHumanReadableFileSize(stampSizeBytes),
|
||||
}
|
||||
}, [actualStamp, adminDrive, drives])
|
||||
|
||||
const expiresAt = useMemo(
|
||||
() => (actualStamp ? actualStamp.duration.toEndDate().toLocaleDateString() : '—'),
|
||||
@@ -109,22 +176,9 @@ export function AdminStatusBar({
|
||||
const statusVerb = isCreationInProgress ? 'Creating' : 'Loading'
|
||||
const statusText = statusVerb + ' admin drive, please do not reload'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`fm-admin-status-bar-container${blurCls}`} aria-busy={isBusy ? 'true' : 'false'}>
|
||||
<div className="fm-admin-status-bar-left">
|
||||
<div className="fm-drive-item-capacity">
|
||||
Capacity <ProgressBar value={capacityPct} width="150px" /> {usedSize} / {totalSize}
|
||||
</div>
|
||||
|
||||
<div>File Manager Available: Until: {expiresAt}</div>
|
||||
|
||||
<Tooltip
|
||||
label="The File Manager works only while your storage remains valid. If it expires, all catalogue metadata is
|
||||
permanently lost."
|
||||
/>
|
||||
</div>
|
||||
|
||||
const renderModalsAndOverlays = () => {
|
||||
return (
|
||||
<>
|
||||
{isUpgradeDriveModalOpen && actualStamp && adminDrive && (
|
||||
<UpgradeDriveModal
|
||||
stamp={actualStamp}
|
||||
@@ -134,13 +188,9 @@ export function AdminStatusBar({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="fm-admin-status-bar-upgrade-button"
|
||||
onClick={() => !isBusy && actualStamp && adminDrive && setIsUpgradeDriveModalOpen(true)}
|
||||
aria-disabled={isBusy ? 'true' : 'false'}
|
||||
>
|
||||
{isBusy ? 'Working…' : 'Manage'}
|
||||
</div>
|
||||
{isUpgradeTimeoutModalOpen && adminDrive && actualStamp && (
|
||||
<UpgradeTimeoutModal driveName={adminDrive.name} onOk={handleTimeoutCancel} />
|
||||
)}
|
||||
|
||||
{isUpgrading && (
|
||||
<div className="fm-drive-item-creating-overlay" aria-live="polite">
|
||||
@@ -159,6 +209,38 @@ export function AdminStatusBar({
|
||||
onMinimize={() => setShowProgressModal(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={`fm-admin-status-bar-container${blurCls}`} aria-busy={isBusy ? 'true' : 'false'}>
|
||||
<div className="fm-admin-status-bar-left">
|
||||
<div
|
||||
className={`fm-drive-item-capacity ${isUpgrading ? 'fm-drive-item-capacity-updating' : ''}`}
|
||||
title={isUpgrading ? 'Capacity is updating... This may take a few moments.' : ''}
|
||||
>
|
||||
Capacity <ProgressBar value={capacityPct} width="150px" /> {usedSize} / {totalSize}
|
||||
</div>
|
||||
|
||||
<div>File Manager Available: Until: {expiresAt}</div>
|
||||
|
||||
<Tooltip
|
||||
label="The File Manager works only while your storage remains valid. If it expires, all catalogue metadata is
|
||||
permanently lost."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{renderModalsAndOverlays()}
|
||||
|
||||
<div
|
||||
className="fm-admin-status-bar-upgrade-button"
|
||||
onClick={() => !isBusy && actualStamp && adminDrive && setIsUpgradeDriveModalOpen(true)}
|
||||
aria-disabled={isBusy ? 'true' : 'false'}
|
||||
>
|
||||
{isBusy ? 'Working…' : 'Manage'}
|
||||
</div>
|
||||
</div>
|
||||
{!showProgressModal && (loading || isCreationInProgress) && (
|
||||
<div className="fm-admin-status-bar-progress-pill-container">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Button } from '../Button/Button'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
interface ConfirmModalProps {
|
||||
title?: string
|
||||
title?: React.ReactNode
|
||||
message?: React.ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
|
||||
@@ -83,3 +83,9 @@
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.fm-error-text {
|
||||
color: var(--fm-error, #dc2626);
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactElement, useContext, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { BZZ, Duration, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js'
|
||||
import { BeeModes, BZZ, DAI, Duration, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js'
|
||||
import './CreateDriveModal.scss'
|
||||
import { CustomDropdown } from '../CustomDropdown/CustomDropdown'
|
||||
import { Button } from '../Button/Button'
|
||||
@@ -18,11 +18,12 @@ import { TOOLTIPS } from '../../constants/tooltips'
|
||||
|
||||
const minMarkValue = Math.min(...erasureCodeMarks.map(mark => mark.value))
|
||||
const maxMarkValue = Math.max(...erasureCodeMarks.map(mark => mark.value))
|
||||
const maxDriveNameLength = 40
|
||||
|
||||
interface CreateDriveModalProps {
|
||||
onCancelClick: () => void
|
||||
onDriveCreated: () => void
|
||||
onCreationStarted: () => void
|
||||
onCreationStarted: (driveName: string) => void
|
||||
onCreationError: (name: string) => void
|
||||
}
|
||||
// TODO: select existing batch id or create a new one - just like in InitialModal
|
||||
@@ -34,6 +35,7 @@ export function CreateDriveModal({
|
||||
}: CreateDriveModalProps): ReactElement {
|
||||
const [isCreateEnabled, setIsCreateEnabled] = useState(false)
|
||||
const [isBalanceSufficient, setIsBalanceSufficient] = useState(true)
|
||||
const [isxDaiBalanceSufficient, setIsxDaiBalanceSufficient] = useState(true)
|
||||
const [capacity, setCapacity] = useState(0)
|
||||
const [lifetimeIndex, setLifetimeIndex] = useState(-1)
|
||||
const [validityEndDate, setValidityEndDate] = useState(new Date())
|
||||
@@ -44,11 +46,19 @@ export function CreateDriveModal({
|
||||
const [cost, setCost] = useState('0')
|
||||
|
||||
const [sizeMarks, setSizeMarks] = useState<{ value: number; label: string }[]>([])
|
||||
const { walletBalance } = useContext(BeeContext)
|
||||
const { walletBalance, nodeInfo } = useContext(BeeContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { fm } = useContext(FMContext)
|
||||
const { fm, drives, expiredDrives, adminDrive } = useContext(FMContext)
|
||||
const currentFetch = useRef<Promise<void> | null>(null)
|
||||
const isMountedRef = useRef(true)
|
||||
const [duplicate, setDuplicate] = useState(false)
|
||||
|
||||
const trimmedName = driveName.trim()
|
||||
const allExistingDriveNames = new Set(
|
||||
[...(drives || []), ...(expiredDrives || []), ...(adminDrive ? [adminDrive] : [])].map(d => d.name.trim()),
|
||||
)
|
||||
const nameExists = trimmedName.length > 0 && allExistingDriveNames.has(trimmedName)
|
||||
const validationError = duplicate && nameExists ? 'Drive already exists. Please choose another name.' : ''
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -56,6 +66,12 @@ export function CreateDriveModal({
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (duplicate && !nameExists) {
|
||||
setDuplicate(false)
|
||||
}
|
||||
}, [duplicate, nameExists])
|
||||
|
||||
const handleCapacityChange = (value: number, index: number) => {
|
||||
setCapacityIndex(index)
|
||||
}
|
||||
@@ -85,31 +101,37 @@ export function CreateDriveModal({
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
setIsBalanceSufficient(true)
|
||||
setIsxDaiBalanceSufficient(true)
|
||||
|
||||
if ((walletBalance && cost.gte(walletBalance.bzzBalance)) || !walletBalance) {
|
||||
setIsBalanceSufficient(false)
|
||||
}
|
||||
setCost(cost.toSignificantDigits(2))
|
||||
|
||||
const zeroDAI = DAI.fromDecimalString('0')
|
||||
|
||||
if ((walletBalance && zeroDAI.eq(walletBalance.nativeTokenBalance)) || !walletBalance) {
|
||||
setIsxDaiBalanceSufficient(false)
|
||||
}
|
||||
},
|
||||
currentFetch,
|
||||
)
|
||||
|
||||
if (driveName && driveName.trim().length > 0) {
|
||||
setIsCreateEnabled(true)
|
||||
} else {
|
||||
setIsCreateEnabled(false)
|
||||
}
|
||||
const canCreate = Boolean(trimmedName) && !nameExists
|
||||
setIsCreateEnabled(canCreate)
|
||||
} else {
|
||||
setCost('0')
|
||||
setIsCreateEnabled(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [capacity, validityEndDate, beeApi, driveName, walletBalance])
|
||||
}, [capacity, validityEndDate, beeApi, walletBalance, nameExists, erasureCodeLevel, trimmedName])
|
||||
|
||||
useEffect(() => {
|
||||
setValidityEndDate(getExpiryDateByLifetime(lifetimeIndex))
|
||||
}, [lifetimeIndex])
|
||||
|
||||
const isUltraLightNode = nodeInfo?.beeMode === BeeModes.ULTRA_LIGHT
|
||||
const isCreateDriveDisabled = isUltraLightNode || !isCreateEnabled || !isBalanceSufficient || !isxDaiBalanceSufficient
|
||||
|
||||
return (
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window">
|
||||
@@ -125,7 +147,10 @@ export function CreateDriveModal({
|
||||
placeholder="My important files"
|
||||
value={driveName}
|
||||
onChange={e => setDriveName(e.target.value)}
|
||||
onBlur={() => setDuplicate(true)}
|
||||
maxLength={maxDriveNameLength}
|
||||
/>
|
||||
{validationError && <div className="fm-error-text">{validationError}</div>}
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="drive-initial-capacity" className="fm-input-label">
|
||||
@@ -140,7 +165,7 @@ export function CreateDriveModal({
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-modal-info-warning">
|
||||
Drive sizes shown above are system-calculated based on your current stamp configuration
|
||||
Drive sizes are calculated automatically from your current stamp configuration.
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="drive-desired-lifetime" className="fm-input-label">
|
||||
@@ -174,37 +199,57 @@ export function CreateDriveModal({
|
||||
<div className="fm-emphasized-text">Estimated Cost:</div>
|
||||
<div>
|
||||
{cost} BZZ {isBalanceSufficient ? '' : '(Insufficient balance)'}
|
||||
{isxDaiBalanceSufficient ? '' : ' (Insufficient xDAI balance)'}
|
||||
</div>
|
||||
|
||||
<Tooltip label={TOOLTIPS.DRIVE_ESTIMATED_COST} bottomTooltip={true} />
|
||||
</div>
|
||||
<div>(Based on current network conditions)</div>
|
||||
{isUltraLightNode && (
|
||||
<div>
|
||||
Creating a drive requires running a light node. Please{' '}
|
||||
<a
|
||||
href="https://docs.ethswarm.org/docs/desktop/configuration/#upgrading-from-an-ultra-light-to-a-light-node"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
upgrade
|
||||
</a>{' '}
|
||||
to continue.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-modal-window-footer">
|
||||
<Button
|
||||
label="Create drive"
|
||||
variant="primary"
|
||||
disabled={!isCreateEnabled || !isBalanceSufficient}
|
||||
disabled={isCreateDriveDisabled}
|
||||
onClick={async () => {
|
||||
if (isCreateEnabled && fm && beeApi && walletBalance) {
|
||||
onCreationStarted()
|
||||
if (!trimmedName || nameExists) {
|
||||
setDuplicate(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (isCreateEnabled && walletBalance && adminDrive) {
|
||||
onCreationStarted(driveName)
|
||||
onCancelClick()
|
||||
|
||||
await handleCreateDrive(
|
||||
await handleCreateDrive({
|
||||
beeApi,
|
||||
fm,
|
||||
Size.fromBytes(capacity),
|
||||
Duration.fromEndDate(validityEndDate),
|
||||
driveName,
|
||||
encryptionEnabled,
|
||||
erasureCodeLevel,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
() => onDriveCreated(), // onSuccess
|
||||
() => onCreationError(driveName), // onError
|
||||
)
|
||||
size: Size.fromBytes(capacity),
|
||||
duration: Duration.fromEndDate(validityEndDate),
|
||||
label: trimmedName,
|
||||
encryption: encryptionEnabled,
|
||||
redundancyLevel: erasureCodeLevel,
|
||||
adminRedundancy: adminDrive?.redundancyLevel,
|
||||
isAdmin: false,
|
||||
resetState: false,
|
||||
existingBatch: null,
|
||||
onSuccess: () => onDriveCreated(),
|
||||
onError: () => onCreationError(trimmedName),
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -58,9 +58,28 @@
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
overflow-y: scroll;
|
||||
animation: fadeIn 0.15s;
|
||||
|
||||
scrollbar-gutter: stable;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
li {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -8,8 +8,10 @@ import AlertIcon from 'remixicon-react/AlertLineIcon'
|
||||
import Radio from '@material-ui/core/Radio'
|
||||
import FormControlLabel from '@material-ui/core/FormControlLabel'
|
||||
import FormControl from '@material-ui/core/FormControl'
|
||||
import { Tooltip } from '../Tooltip/Tooltip'
|
||||
|
||||
import { FileAction } from '../../constants/transfers'
|
||||
import { TOOLTIPS } from '../../constants/tooltips'
|
||||
|
||||
interface DeleteFileModalProps {
|
||||
name?: string
|
||||
@@ -59,7 +61,10 @@ export function DeleteFileModal({
|
||||
control={<Radio checked={value === FileAction.Trash} onChange={() => setValue(FileAction.Trash)} />}
|
||||
label={
|
||||
<div className="fm-radio-label">
|
||||
<div className="fm-radio-label-header fm-main-font-color fm-line-height-fit">Move to Trash</div>
|
||||
<div className="fm-radio-label-header fm-main-font-color fm-line-height-fit">
|
||||
Move to Trash
|
||||
<Tooltip label={TOOLTIPS.FILE_OPERATION_TRASH} iconSize="14px" />
|
||||
</div>
|
||||
<div onClick={e => e.preventDefault()}>
|
||||
Moves {subjectNoun} to the trash. It will still take up space on{' '}
|
||||
{currentDriveName ?? 'this drive'} and expire along with it. You can restore it later.
|
||||
@@ -75,7 +80,10 @@ export function DeleteFileModal({
|
||||
control={<Radio checked={value === FileAction.Forget} onChange={() => setValue(FileAction.Forget)} />}
|
||||
label={
|
||||
<div className="fm-radio-label">
|
||||
<div className="fm-radio-label-header fm-main-font-color fm-line-height-fit">Forget</div>
|
||||
<div className="fm-radio-label-header fm-main-font-color fm-line-height-fit">
|
||||
Forget
|
||||
<Tooltip label={TOOLTIPS.FILE_OPERATION_FORGET} iconSize="14px" />
|
||||
</div>
|
||||
<div onClick={e => e.preventDefault()}>
|
||||
Removes {subjectNoun} from your view. The data will remain on Swarm until{' '}
|
||||
{currentDriveName ?? 'the drive'} expires. This action cannot be easily undone.
|
||||
|
||||
@@ -11,11 +11,46 @@ interface DestroyDriveModalProps {
|
||||
doDestroy: () => void | Promise<void>
|
||||
}
|
||||
|
||||
interface ProgressDestroyModalProps {
|
||||
drive: DriveInfo
|
||||
onMinimize: () => void
|
||||
}
|
||||
|
||||
export function ProgressDestroyModal({ drive, onMinimize }: ProgressDestroyModalProps): ReactElement {
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header fm-red-font">Destroying Drive</div>
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-body-destroy">
|
||||
<div className="fm-emphasized-text">Drive "{drive.name}" is being destroyed</div>
|
||||
<div>Please wait while the operation completes...</div>
|
||||
<div style={{ marginTop: '20px', textAlign: 'center' }}>
|
||||
<div className="fm-mini-spinner" style={{ display: 'inline-block', marginRight: '10px' }} />
|
||||
<span>Destroying drive...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-modal-window-footer">
|
||||
<Button label="Minimize" variant="secondary" onClick={onMinimize} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
|
||||
export function DestroyDriveModal({ drive, onCancelClick, doDestroy }: DestroyDriveModalProps): ReactElement {
|
||||
const [driveNameInput, setDriveNameInput] = useState('')
|
||||
const destroyDriveText = `DESTROY DRIVE ${drive.name}`
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
|
||||
const handleDestroy = () => {
|
||||
doDestroy()
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window">
|
||||
@@ -50,7 +85,7 @@ export function DestroyDriveModal({ drive, onCancelClick, doDestroy }: DestroyDr
|
||||
label="Destroy entire drive"
|
||||
variant="danger"
|
||||
disabled={destroyDriveText !== driveNameInput}
|
||||
onClick={() => doDestroy()}
|
||||
onClick={handleDestroy}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||
</div>
|
||||
|
||||
+10
-4
@@ -11,14 +11,16 @@ import AlertIcon from 'remixicon-react/AlertLineIcon'
|
||||
import { UpgradeDriveModal } from '../UpgradeDriveModal/UpgradeDriveModal'
|
||||
import { getDaysLeft } from '../../utils/common'
|
||||
|
||||
import { PostageBatch, Size } from '@ethersphere/bee-js'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
import { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { calculateStampCapacityMetrics } from '../../utils/bee'
|
||||
|
||||
const EXPIRING_ITEMS_PAGE_SIZE = 3
|
||||
|
||||
interface ExpiringNotificationModalProps {
|
||||
stamps: PostageBatch[]
|
||||
drives: DriveInfo[]
|
||||
files: FileInfo[]
|
||||
onCancelClick: () => void
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
@@ -26,6 +28,7 @@ interface ExpiringNotificationModalProps {
|
||||
export function ExpiringNotificationModal({
|
||||
stamps,
|
||||
drives,
|
||||
files,
|
||||
onCancelClick,
|
||||
setErrorMessage,
|
||||
}: ExpiringNotificationModalProps): ReactElement {
|
||||
@@ -71,6 +74,10 @@ export function ExpiringNotificationModal({
|
||||
|
||||
if (!drive) return null
|
||||
|
||||
const filesPerDrive = files.filter(fi => fi.driveId === drive.id.toString())
|
||||
|
||||
const { usedSize, stampSize } = calculateStampCapacityMetrics(stamp, filesPerDrive, drive.redundancyLevel)
|
||||
|
||||
if (daysLeft < 10) {
|
||||
daysClass = 'fm-red-font'
|
||||
} else if (daysLeft < 30) {
|
||||
@@ -89,8 +96,7 @@ export function ExpiringNotificationModal({
|
||||
{stamp.label} {drive.isAdmin && <Warning style={{ fontSize: '16px' }} />}
|
||||
</div>
|
||||
<div className="fm-expiring-notification-modal-section-left-value">
|
||||
{Size.fromBytes(stamp.size.toBytes() * stamp.usage).toFormattedString()} /{' '}
|
||||
{stamp.size.toFormattedString()}
|
||||
{usedSize} / {stampSize}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, useEffect, useLayoutEffect, useRef, useState, useContext } from 'react'
|
||||
import { ReactElement, useEffect, useLayoutEffect, useRef, useState, useContext, useMemo, useCallback } from 'react'
|
||||
import './FileBrowser.scss'
|
||||
import { FileBrowserHeader } from './FileBrowserHeader/FileBrowserHeader'
|
||||
import { FileBrowserContent } from './FileBrowserContent/FileBrowserContent'
|
||||
@@ -15,15 +15,47 @@ 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 { Point, Dir, safeSetState, getFileId } from '../../utils/common'
|
||||
import { computeContextMenuPosition } from '../../utils/ui'
|
||||
import { FileBrowserTopBar } from './FileBrowserTopBar/FileBrowserTopBar'
|
||||
import { handleDestroyDrive } from '../../utils/bee'
|
||||
import { handleDestroyAndForgetDrive } 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'
|
||||
import { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { ProgressDestroyModal } from '../DestroyDriveModal/DestroyDriveModal'
|
||||
|
||||
const renderDestroySpinner = (
|
||||
isDestroying: boolean,
|
||||
isProgressModalOpen: boolean,
|
||||
currentDrive: DriveInfo | undefined,
|
||||
setter: () => void,
|
||||
) => {
|
||||
if (isProgressModalOpen && isDestroying && currentDrive) {
|
||||
return <ProgressDestroyModal drive={currentDrive} onMinimize={setter} />
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const showDestroyModal = (isDestroying: boolean, setter: () => void) => {
|
||||
if (!isDestroying) return null
|
||||
|
||||
return (
|
||||
<div className="fm-refresh-overlay" aria-busy="true" aria-live="polite">
|
||||
<div
|
||||
className="fm-refresh-content"
|
||||
onClick={setter}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Click to show progress modal"
|
||||
>
|
||||
<div className="fm-mini-spinner" role="status" aria-label="Destroying drive…" />
|
||||
<span className="fm-refresh-text">Destroying drive…</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const extractFilesFromClipboardEvent = (e: React.ClipboardEvent): File[] => {
|
||||
const out: File[] = []
|
||||
@@ -50,7 +82,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
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 { files, adminDrive, currentDrive, resync, drives, fm, showError, setShowError } = useContext(FMContext)
|
||||
const {
|
||||
uploadFiles,
|
||||
isUploading,
|
||||
@@ -78,7 +110,10 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
|
||||
const [isDestroying, setIsDestroying] = useState(false)
|
||||
const [isProgressModalOpen, setIsProgressModalOpen] = useState(false)
|
||||
const [confirmBulkForget, setConfirmBulkForget] = useState(false)
|
||||
const [confirmBulkRestore, setConfirmBulkRestore] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [pendingCancelUpload, setPendingCancelUpload] = useState<string | null>(null)
|
||||
|
||||
@@ -125,6 +160,10 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
getDriveName,
|
||||
})
|
||||
|
||||
const sortedKey = sorted.map(f => getFileId(f)).join('|')
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const stableSorted = useMemo(() => sorted, [sortedKey])
|
||||
|
||||
const bulk = useBulkActions({
|
||||
listToRender,
|
||||
trackDownload,
|
||||
@@ -200,32 +239,50 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDestroyDriveConfirm = async () => {
|
||||
const handleDestroyDriveConfirm = useCallback(async () => {
|
||||
if (!currentDrive) return
|
||||
|
||||
setShowDestroyDriveModal(false)
|
||||
setIsProgressModalOpen(true)
|
||||
setIsDestroying(true)
|
||||
|
||||
await handleDestroyDrive(
|
||||
await handleDestroyAndForgetDrive({
|
||||
beeApi,
|
||||
fm,
|
||||
currentDrive,
|
||||
() => {
|
||||
drive: currentDrive,
|
||||
isDestroy: true,
|
||||
adminDrive,
|
||||
onSuccess: () => {
|
||||
setIsDestroying(false)
|
||||
setIsProgressModalOpen(false)
|
||||
setShowDestroyDriveModal(false)
|
||||
},
|
||||
e => {
|
||||
onError: e => {
|
||||
setIsDestroying(false)
|
||||
setIsProgressModalOpen(false)
|
||||
setShowDestroyDriveModal(false)
|
||||
setErrorMessage?.(`Error destroying drive: ${currentDrive.name}: ${e}`)
|
||||
setShowError(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
}, [
|
||||
beeApi,
|
||||
fm,
|
||||
currentDrive,
|
||||
adminDrive,
|
||||
setErrorMessage,
|
||||
setIsProgressModalOpen,
|
||||
setShowDestroyDriveModal,
|
||||
setShowError,
|
||||
])
|
||||
|
||||
const handleUploadClose = (name: string) => {
|
||||
const row = uploadItems.find(i => i.name === name)
|
||||
const handleUploadClose = (uuid: string) => {
|
||||
const row = uploadItems.find(i => i.uuid === uuid)
|
||||
|
||||
if (row?.status === TransferStatus.Uploading) {
|
||||
setPendingCancelUpload(name)
|
||||
setPendingCancelUpload(uuid)
|
||||
} else {
|
||||
cancelOrDismissUpload(name)
|
||||
cancelOrDismissUpload(uuid)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,6 +379,18 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
const showDragOverlay = isDragging && Boolean(currentDrive)
|
||||
const fileCountText = bulk.selectedFiles.length === 1 ? 'file' : 'files'
|
||||
|
||||
// Memoize onBulk object to prevent FileBrowserContent rerenders
|
||||
const onBulk = useMemo(
|
||||
() => ({
|
||||
download: () => bulk.bulkDownload(bulk.selectedFiles),
|
||||
restore: () => setConfirmBulkRestore(true),
|
||||
forget: () => bulk.bulkForget(bulk.selectedFiles),
|
||||
destroy: () => setShowDestroyDriveModal(true),
|
||||
delete: () => setShowBulkDeleteModal(true),
|
||||
}),
|
||||
[bulk],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{conflictPortal}
|
||||
@@ -364,7 +433,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
>
|
||||
<FileBrowserContent
|
||||
key={isSearchMode ? `content-search` : `content-${currentDrive?.id.toString() ?? 'none'}`}
|
||||
listToRender={sorted}
|
||||
listToRender={stableSorted}
|
||||
drives={drives}
|
||||
currentDrive={currentDrive || null}
|
||||
view={view}
|
||||
@@ -373,13 +442,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
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),
|
||||
}}
|
||||
onBulk={onBulk}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
{showError && (
|
||||
@@ -411,7 +474,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
enableRefresh={Boolean(fm?.adminStamp)}
|
||||
onUploadFile={onContextUploadFile}
|
||||
onBulkDownload={() => bulk.bulkDownload(bulk.selectedFiles)}
|
||||
onBulkRestore={() => bulk.bulkRestore(bulk.selectedFiles)}
|
||||
onBulkRestore={() => setConfirmBulkRestore(true)}
|
||||
onBulkDelete={() => setShowBulkDeleteModal(true)}
|
||||
onBulkDestroy={() => setShowDestroyDriveModal(true)}
|
||||
onBulkForget={() => bulk.bulkForget(bulk.selectedFiles)}
|
||||
@@ -439,6 +502,7 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
fileCountText={fileCountText}
|
||||
currentDrive={currentDrive || null}
|
||||
confirmBulkForget={confirmBulkForget}
|
||||
confirmBulkRestore={confirmBulkRestore}
|
||||
showDestroyDriveModal={showDestroyDriveModal}
|
||||
pendingCancelUpload={pendingCancelUpload}
|
||||
onDeleteCancel={() => setShowBulkDeleteModal(false)}
|
||||
@@ -448,6 +512,11 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
setConfirmBulkForget(false)
|
||||
}}
|
||||
onForgetCancel={() => setConfirmBulkForget(false)}
|
||||
onRestoreConfirm={async () => {
|
||||
await bulk.bulkRestore(bulk.selectedFiles)
|
||||
setConfirmBulkRestore(false)
|
||||
}}
|
||||
onRestoreCancel={() => setConfirmBulkRestore(false)}
|
||||
onDestroyCancel={() => setShowDestroyDriveModal(false)}
|
||||
onDestroyConfirm={handleDestroyDriveConfirm}
|
||||
onCancelUploadConfirm={() => {
|
||||
@@ -467,6 +536,10 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDestroyModal(isDestroying, () => setIsProgressModalOpen(true))}
|
||||
|
||||
{renderDestroySpinner(isDestroying, isProgressModalOpen, currentDrive, () => setIsProgressModalOpen(false))}
|
||||
</div>
|
||||
|
||||
<div className="fm-file-browser-footer">
|
||||
@@ -474,7 +547,6 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
label="Uploading files"
|
||||
type={FileTransferType.Upload}
|
||||
open={isUploading}
|
||||
count={uploadItems.length}
|
||||
items={uploadItems}
|
||||
onRowClose={handleUploadClose}
|
||||
onCloseAll={() => dismissAllUploads()}
|
||||
@@ -483,7 +555,6 @@ export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps)
|
||||
label="Downloading files"
|
||||
type={FileTransferType.Download}
|
||||
open={isDownloading}
|
||||
count={downloadItems.length}
|
||||
items={downloadItems}
|
||||
onRowClose={name => cancelOrDismissDownload(name)}
|
||||
onCloseAll={() => dismissAllDownloads()}
|
||||
|
||||
+4
-3
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, useCallback } from 'react'
|
||||
import { ReactElement, useCallback, memo } from 'react'
|
||||
import { FileItem } from '../FileItem/FileItem'
|
||||
import { FileInfo, DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
|
||||
@@ -24,7 +24,7 @@ interface FileBrowserContentProps {
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function FileBrowserContent({
|
||||
function FileBrowserContentInner({
|
||||
listToRender,
|
||||
drives,
|
||||
currentDrive,
|
||||
@@ -127,4 +127,5 @@ export function FileBrowserContent({
|
||||
return <>{renderFileList(listToRender, true)}</>
|
||||
}
|
||||
|
||||
export default FileBrowserContent
|
||||
// Memoize to prevent rerenders when parent FileBrowser rerenders due to upload/download progress
|
||||
export const FileBrowserContent = memo(FileBrowserContentInner)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { ReactElement } from 'react'
|
||||
import type { FileInfo, DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
|
||||
import { Tooltip } from '../Tooltip/Tooltip'
|
||||
import { DeleteFileModal } from '../DeleteFileModal/DeleteFileModal'
|
||||
import { DestroyDriveModal } from '../DestroyDriveModal/DestroyDriveModal'
|
||||
import { FileAction } from '../../constants/transfers'
|
||||
import { TOOLTIPS } from '../../constants/tooltips'
|
||||
|
||||
interface FileBrowserModalsProps {
|
||||
showDeleteModal: boolean
|
||||
@@ -11,12 +13,15 @@ interface FileBrowserModalsProps {
|
||||
fileCountText: string
|
||||
currentDrive: DriveInfo | null
|
||||
confirmBulkForget: boolean
|
||||
confirmBulkRestore: boolean
|
||||
showDestroyDriveModal: boolean
|
||||
pendingCancelUpload: string | null
|
||||
onDeleteCancel: () => void
|
||||
onDeleteProceed: (action: FileAction) => void
|
||||
onForgetConfirm: () => Promise<void>
|
||||
onForgetCancel: () => void
|
||||
onRestoreConfirm: () => Promise<void>
|
||||
onRestoreCancel: () => void
|
||||
onDestroyCancel: () => void
|
||||
onDestroyConfirm: () => Promise<void>
|
||||
onCancelUploadConfirm: () => void
|
||||
@@ -29,12 +34,15 @@ export function FileBrowserModals({
|
||||
fileCountText,
|
||||
currentDrive,
|
||||
confirmBulkForget,
|
||||
confirmBulkRestore,
|
||||
showDestroyDriveModal,
|
||||
pendingCancelUpload,
|
||||
onDeleteCancel,
|
||||
onDeleteProceed,
|
||||
onForgetConfirm,
|
||||
onForgetCancel,
|
||||
onRestoreConfirm,
|
||||
onRestoreCancel,
|
||||
onDestroyCancel,
|
||||
onDestroyConfirm,
|
||||
onCancelUploadConfirm,
|
||||
@@ -53,7 +61,12 @@ export function FileBrowserModals({
|
||||
|
||||
{confirmBulkForget && (
|
||||
<ConfirmModal
|
||||
title="Forget permanently?"
|
||||
title={
|
||||
<>
|
||||
Forget permanently?
|
||||
<Tooltip label={TOOLTIPS.FILE_OPERATION_FORGET} />
|
||||
</>
|
||||
}
|
||||
message={
|
||||
<>
|
||||
This removes <b>{selectedFiles.length}</b> {fileCountText} from your view.
|
||||
@@ -68,6 +81,26 @@ export function FileBrowserModals({
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmBulkRestore && (
|
||||
<ConfirmModal
|
||||
title={
|
||||
<>
|
||||
Restore from trash?
|
||||
<Tooltip label={TOOLTIPS.FILE_OPERATION_RESTORE_FROM_TRASH} />
|
||||
</>
|
||||
}
|
||||
message={
|
||||
<>
|
||||
This will restore <b>{selectedFiles.length}</b> {fileCountText} from trash.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Restore"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onRestoreConfirm}
|
||||
onCancel={onRestoreCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDestroyDriveModal && currentDrive && (
|
||||
<DestroyDriveModal drive={currentDrive} onCancelClick={onDestroyCancel} doDestroy={onDestroyConfirm} />
|
||||
)}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { ReactElement, useContext, useLayoutEffect, useMemo, useState, useRef, useEffect, useCallback } from 'react'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
import { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
|
||||
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 { DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
|
||||
import { GetInfoModal } from '../../GetInfoModal/GetInfoModal'
|
||||
import { VersionHistoryModal } from '../../VersionHistoryModal/VersionHistoryModal'
|
||||
import { DeleteFileModal } from '../../DeleteFileModal/DeleteFileModal'
|
||||
@@ -12,17 +15,19 @@ 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 { Tooltip } from '../../Tooltip/Tooltip'
|
||||
import { Dir, formatBytes, isTrashed, safeSetState, truncateNameMiddle } from '../../../utils/common'
|
||||
import { FileAction } from '../../../constants/transfers'
|
||||
import { TOOLTIPS } from '../../../constants/tooltips'
|
||||
import { startDownloadingQueue, createDownloadAbort } from '../../../utils/download'
|
||||
import { computeContextMenuPosition } from '../../../utils/ui'
|
||||
import { getUsableStamps, handleDestroyDrive } from '../../../utils/bee'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
import { getUsableStamps, handleDestroyAndForgetDrive, verifyDriveSpace } from '../../../utils/bee'
|
||||
import { guessMime } from '../../../utils/view'
|
||||
import { performFileOperation, FileOperation } from '../../../utils/fileOperations'
|
||||
import { uuidV4 } from '../../../../../utils'
|
||||
|
||||
interface FileItemProps {
|
||||
fileInfo: FileInfo
|
||||
@@ -54,7 +59,7 @@ export function FileItem({
|
||||
setErrorMessage,
|
||||
}: FileItemProps): ReactElement {
|
||||
const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu<HTMLDivElement>()
|
||||
const { fm, currentDrive, files, drives, setShowError } = useContext(FMContext)
|
||||
const { fm, adminDrive, currentDrive, files, drives, setShowError, refreshStamp } = useContext(FMContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { view } = useView()
|
||||
|
||||
@@ -69,6 +74,7 @@ export function FileItem({
|
||||
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
|
||||
const [destroyDrive, setDestroyDrive] = useState<DriveInfo | null>(null)
|
||||
const [confirmForget, setConfirmForget] = useState(false)
|
||||
const [confirmRestore, setConfirmRestore] = useState(false)
|
||||
|
||||
const isMountedRef = useRef(true)
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
@@ -78,6 +84,10 @@ export function FileItem({
|
||||
const isTrashedFile = isTrashed(fileInfo)
|
||||
const statusLabel = isTrashedFile ? 'Trash' : 'Active'
|
||||
|
||||
const latestFileInfo = useMemo(() => {
|
||||
return files.find(f => f.topic.toString() === fileInfo.topic.toString()) ?? fileInfo
|
||||
}, [files, fileInfo])
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
@@ -133,55 +143,57 @@ export function FileItem({
|
||||
|
||||
handleCloseContext()
|
||||
|
||||
const rawSize = fileInfo.customMetadata?.size
|
||||
const rawSize = latestFileInfo.customMetadata?.size
|
||||
const expectedSize = rawSize ? Number(rawSize) : undefined
|
||||
|
||||
createDownloadAbort(fileInfo.name)
|
||||
createDownloadAbort(latestFileInfo.name)
|
||||
|
||||
await startDownloadingQueue(
|
||||
fm,
|
||||
[fileInfo],
|
||||
[onDownload({ name: fileInfo.name, size: formatBytes(rawSize), expectedSize })],
|
||||
[latestFileInfo],
|
||||
[
|
||||
onDownload({
|
||||
uuid: uuidV4(),
|
||||
name: latestFileInfo.name,
|
||||
size: formatBytes(rawSize),
|
||||
expectedSize,
|
||||
driveName,
|
||||
}),
|
||||
],
|
||||
isNewWindow,
|
||||
)
|
||||
},
|
||||
[handleCloseContext, fm, beeApi, fileInfo, onDownload],
|
||||
[handleCloseContext, fm, beeApi, latestFileInfo, onDownload, driveName],
|
||||
)
|
||||
// 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(),
|
||||
},
|
||||
}
|
||||
const handleFileAction = useCallback(
|
||||
async (operation: FileOperation) => {
|
||||
if (!fm || !driveStamp || !currentDrive) return
|
||||
|
||||
await fm.trashFile(withMeta)
|
||||
}, [fm, fileInfo])
|
||||
await performFileOperation({
|
||||
fm,
|
||||
file: latestFileInfo,
|
||||
redundancyLevel: currentDrive.redundancyLevel,
|
||||
driveId: currentDrive.id.toString(),
|
||||
stamp: driveStamp,
|
||||
adminStamp: fm.adminStamp,
|
||||
adminRedundancy: adminDrive?.redundancyLevel,
|
||||
operation,
|
||||
onError: err => {
|
||||
setErrorMessage?.(err)
|
||||
setShowError(true)
|
||||
},
|
||||
onSuccess: () => {
|
||||
const stampToRefresh = operation === FileOperation.Forget ? fm.adminStamp : driveStamp
|
||||
|
||||
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])
|
||||
if (stampToRefresh) {
|
||||
refreshStamp(stampToRefresh.batchID.toString())
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
[fm, driveStamp, adminDrive, currentDrive, latestFileInfo, refreshStamp, setErrorMessage, setShowError],
|
||||
)
|
||||
|
||||
const showDestroyDrive = useCallback(() => {
|
||||
setDestroyDrive(currentDrive || null)
|
||||
@@ -190,34 +202,52 @@ export function FileItem({
|
||||
|
||||
const doRename = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!fm || !currentDrive) return
|
||||
if (!fm || !driveStamp || !currentDrive) {
|
||||
setErrorMessage?.('Invalid FM or Current Drive')
|
||||
setShowError(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (takenNames.has(newName)) throw new Error('name-taken')
|
||||
|
||||
try {
|
||||
verifyDriveSpace({
|
||||
fm,
|
||||
redundancyLevel: currentDrive.redundancyLevel,
|
||||
stamp: driveStamp,
|
||||
useInfoSize: true,
|
||||
driveId: currentDrive.id.toString(),
|
||||
cb: err => {
|
||||
throw new Error(err)
|
||||
},
|
||||
})
|
||||
|
||||
await fm.upload(
|
||||
currentDrive,
|
||||
{
|
||||
name: newName,
|
||||
topic: fileInfo.topic,
|
||||
topic: latestFileInfo.topic,
|
||||
file: {
|
||||
reference: fileInfo.file.reference,
|
||||
historyRef: fileInfo.file.historyRef,
|
||||
reference: latestFileInfo.file.reference,
|
||||
historyRef: latestFileInfo.file.historyRef,
|
||||
},
|
||||
customMetadata: fileInfo.customMetadata,
|
||||
customMetadata: latestFileInfo.customMetadata,
|
||||
files: [],
|
||||
},
|
||||
{
|
||||
actHistoryAddress: fileInfo.file.historyRef,
|
||||
actHistoryAddress: latestFileInfo.file.historyRef,
|
||||
},
|
||||
)
|
||||
|
||||
refreshStamp(driveStamp.batchID.toString())
|
||||
} catch (e: unknown) {
|
||||
setErrorMessage?.(`Error renaming file ${fileInfo.name}`)
|
||||
setErrorMessage?.(`Error renaming file ${latestFileInfo.name}`)
|
||||
setShowError(true)
|
||||
}
|
||||
},
|
||||
|
||||
[fm, currentDrive, fileInfo, takenNames, setErrorMessage, setShowError],
|
||||
[fm, driveStamp, currentDrive, latestFileInfo, takenNames, refreshStamp, setErrorMessage, setShowError],
|
||||
)
|
||||
|
||||
const MenuItem = ({
|
||||
@@ -332,7 +362,7 @@ export function FileItem({
|
||||
danger
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
doRecover()
|
||||
setConfirmRestore(true)
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
@@ -341,7 +371,7 @@ export function FileItem({
|
||||
danger
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
|
||||
// TODO: isn't parentDrive === currentDrive?
|
||||
const parentDrive = drives.find(d => d.id.toString() === fileInfo.driveId.toString())
|
||||
|
||||
if (parentDrive) {
|
||||
@@ -380,7 +410,6 @@ export function FileItem({
|
||||
handleDownload,
|
||||
handleCloseContext,
|
||||
openGetInfo,
|
||||
doRecover,
|
||||
onBulk,
|
||||
currentDrive,
|
||||
drives,
|
||||
@@ -434,6 +463,9 @@ export function FileItem({
|
||||
return <div className="fm-file-item-content">Error</div>
|
||||
}
|
||||
|
||||
const { mime } = guessMime(fileInfo.name, fileInfo.customMetadata)
|
||||
const mimeType = mime.split('/')[0]?.toLowerCase() || 'file'
|
||||
|
||||
return (
|
||||
<div className="fm-file-item-content" onContextMenu={handleItemContextMenu} onClick={handleCloseContext}>
|
||||
<div className="fm-file-item-content-item fm-checkbox">
|
||||
@@ -446,8 +478,8 @@ export function FileItem({
|
||||
</div>
|
||||
|
||||
<div className="fm-file-item-content-item fm-name" onDoubleClick={() => handleDownload(true)}>
|
||||
<GetIconElement icon={fileInfo.name} />
|
||||
{fileInfo.name}
|
||||
<GetIconElement icon={mimeType} />
|
||||
{truncateNameMiddle(fileInfo.name)}
|
||||
</div>
|
||||
|
||||
{showDriveColumn && (
|
||||
@@ -487,7 +519,7 @@ export function FileItem({
|
||||
|
||||
{showVersionHistory && (
|
||||
<VersionHistoryModal
|
||||
fileInfo={fileInfo}
|
||||
fileInfo={latestFileInfo}
|
||||
onCancelClick={() => {
|
||||
setShowVersionHistory(false)
|
||||
}}
|
||||
@@ -504,9 +536,10 @@ export function FileItem({
|
||||
}}
|
||||
onProceed={action => {
|
||||
setShowDeleteModal(false)
|
||||
|
||||
switch (action) {
|
||||
case FileAction.Trash:
|
||||
doTrash()
|
||||
handleFileAction(FileOperation.Trash)
|
||||
break
|
||||
case FileAction.Forget:
|
||||
setConfirmForget(true)
|
||||
@@ -546,7 +579,12 @@ export function FileItem({
|
||||
|
||||
{confirmForget && (
|
||||
<ConfirmModal
|
||||
title="Forget permanently?"
|
||||
title={
|
||||
<>
|
||||
Forget permanently?
|
||||
<Tooltip label={TOOLTIPS.FILE_OPERATION_FORGET} />
|
||||
</>
|
||||
}
|
||||
message={
|
||||
<>
|
||||
This removes <b title={fileInfo.name}>{fileInfo.name}</b> from your view.
|
||||
@@ -557,7 +595,7 @@ export function FileItem({
|
||||
confirmLabel="Forget"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={async () => {
|
||||
await doForget()
|
||||
await handleFileAction(FileOperation.Forget)
|
||||
|
||||
safeSetState(isMountedRef, setConfirmForget)(false)
|
||||
}}
|
||||
@@ -567,6 +605,32 @@ export function FileItem({
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmRestore && (
|
||||
<ConfirmModal
|
||||
title={
|
||||
<>
|
||||
Restore from trash?
|
||||
<Tooltip label={TOOLTIPS.FILE_OPERATION_RESTORE_FROM_TRASH} />
|
||||
</>
|
||||
}
|
||||
message={
|
||||
<>
|
||||
This will restore <b title={fileInfo.name}>{fileInfo.name}</b> from trash.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Restore"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={async () => {
|
||||
await handleFileAction(FileOperation.Recover)
|
||||
|
||||
safeSetState(isMountedRef, setConfirmRestore)(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setConfirmRestore(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDestroyDriveModal && destroyDrive && (
|
||||
<DestroyDriveModal
|
||||
drive={destroyDrive}
|
||||
@@ -577,20 +641,22 @@ export function FileItem({
|
||||
doDestroy={async () => {
|
||||
setShowDestroyDriveModal(false)
|
||||
|
||||
await handleDestroyDrive(
|
||||
await handleDestroyAndForgetDrive({
|
||||
beeApi,
|
||||
fm,
|
||||
destroyDrive,
|
||||
() => {
|
||||
drive: destroyDrive,
|
||||
adminDrive,
|
||||
isDestroy: true,
|
||||
onSuccess: () => {
|
||||
setShowDestroyDriveModal(false)
|
||||
setDestroyDrive(null)
|
||||
},
|
||||
e => {
|
||||
onError: e => {
|
||||
setShowDestroyDriveModal(false)
|
||||
setErrorMessage?.(`Error destroying drive: ${destroyDrive.name}: ${e}`)
|
||||
setShowError(true)
|
||||
},
|
||||
)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
+2
-16
@@ -3,26 +3,14 @@ import './FileProgressNotification.scss'
|
||||
import UpIcon from 'remixicon-react/ArrowUpSLineIcon'
|
||||
import DownIcon from 'remixicon-react/ArrowDownSLineIcon'
|
||||
import { FileProgressWindow } from '../FileProgressWindow/FileProgressWindow'
|
||||
import { FileTransferType, TransferStatus } from '../../constants/transfers'
|
||||
|
||||
type ProgressItem = {
|
||||
name: string
|
||||
size?: string
|
||||
percent?: number
|
||||
kind?: FileTransferType
|
||||
status?: TransferStatus
|
||||
driveName?: string
|
||||
etaSec?: number
|
||||
elapsedSec?: number
|
||||
}
|
||||
import { FileTransferType, TransferStatus, ProgressItem } from '../../constants/transfers'
|
||||
|
||||
interface FileProgressNotificationProps {
|
||||
label?: string
|
||||
type: FileTransferType
|
||||
open?: boolean
|
||||
count?: number
|
||||
items?: ProgressItem[]
|
||||
onRowClose?: (name: string) => void
|
||||
onRowClose?: (uuid: string) => void
|
||||
onCloseAll?: () => void
|
||||
}
|
||||
|
||||
@@ -30,7 +18,6 @@ export function FileProgressNotification({
|
||||
label,
|
||||
type,
|
||||
open,
|
||||
count,
|
||||
items,
|
||||
onRowClose,
|
||||
onCloseAll,
|
||||
@@ -88,7 +75,6 @@ export function FileProgressNotification({
|
||||
|
||||
{showFileProgressWindow && (
|
||||
<FileProgressWindow
|
||||
numberOfFiles={items && items.length ? undefined : count}
|
||||
items={items}
|
||||
type={type}
|
||||
onCancelClick={() => setShowFileProgressWindow(false)}
|
||||
|
||||
@@ -4,26 +4,15 @@ import ArrowDownIcon from 'remixicon-react/ArrowDownSLineIcon'
|
||||
import './FileProgressWindow.scss'
|
||||
import { GetIconElement } from '../../utils/GetIconElement'
|
||||
import { ProgressBar } from '../ProgressBar/ProgressBar'
|
||||
import { FileTransferType, TransferBarColor, TransferStatus } from '../../constants/transfers'
|
||||
import { capitalizeFirstLetter } from '../../utils/common'
|
||||
|
||||
type ProgressItem = {
|
||||
name: string
|
||||
percent?: number
|
||||
size?: string
|
||||
kind?: FileTransferType
|
||||
status?: TransferStatus
|
||||
driveName?: string
|
||||
etaSec?: number
|
||||
elapsedSec?: number
|
||||
}
|
||||
import { FileTransferType, TransferBarColor, TransferStatus, ProgressItem } from '../../constants/transfers'
|
||||
import { capitalizeFirstLetter, truncateNameMiddle } from '../../utils/common'
|
||||
import { guessMime } from '../../utils/view'
|
||||
|
||||
interface FileProgressWindowProps {
|
||||
numberOfFiles?: number
|
||||
items?: ProgressItem[]
|
||||
type: FileTransferType
|
||||
onCancelClick: () => void
|
||||
onRowClose?: (name: string) => void
|
||||
onRowClose?: (uuid: string) => void
|
||||
onCloseAll?: () => void
|
||||
}
|
||||
|
||||
@@ -48,7 +37,6 @@ const formatDuration = (sec?: number) => {
|
||||
}
|
||||
|
||||
export function FileProgressWindow({
|
||||
numberOfFiles,
|
||||
items,
|
||||
type,
|
||||
onCancelClick,
|
||||
@@ -57,11 +45,8 @@ export function FileProgressWindow({
|
||||
}: FileProgressWindowProps): ReactElement | null {
|
||||
const listRef = useRef<HTMLDivElement | null>(null)
|
||||
const firstRowRef = useRef<HTMLDivElement | null>(null)
|
||||
const count = items?.length ?? numberOfFiles ?? 0
|
||||
const rows: ProgressItem[] =
|
||||
items && items.length > 0
|
||||
? items
|
||||
: Array.from({ length: count }, (_, i) => ({ name: `Pending file ${i + 1}`, percent: 0, size: '' }))
|
||||
const count = items?.length ?? 0
|
||||
const rows: ProgressItem[] = items ?? []
|
||||
|
||||
const getTransferInfo = (item: ProgressItem, pct?: number) => {
|
||||
const transferType = capitalizeFirstLetter(item?.kind ?? type)
|
||||
@@ -92,6 +77,7 @@ export function FileProgressWindow({
|
||||
const listEl = listRef.current
|
||||
|
||||
if (!rowEl || !listEl) return
|
||||
|
||||
const rowH = rowEl.getBoundingClientRect().height
|
||||
const safeRowH = rowH > 0 ? rowH : 72
|
||||
listEl.style.maxHeight = `${safeRowH * 5}px`
|
||||
@@ -152,24 +138,27 @@ export function FileProgressWindow({
|
||||
|
||||
const centerDisplay = getCenterText() || '\u00A0'
|
||||
|
||||
const { mime } = guessMime(item.name)
|
||||
const mimeType = mime.split('/')[0].toLowerCase() || 'file'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fm-file-progress-window-file-item"
|
||||
key={`${item.name}`}
|
||||
key={item.uuid || `${item.name}-${idx}`}
|
||||
ref={idx === 0 ? firstRowRef : undefined}
|
||||
>
|
||||
<div className="fm-file-progress-window-file-type-icon">
|
||||
<GetIconElement size="14" icon={item.name} color="black" />
|
||||
<GetIconElement size="14" icon={mimeType} color="black" />
|
||||
</div>
|
||||
|
||||
<div className="fm-file-progress-window-file-datas">
|
||||
<div className="fm-file-progress-window-file-item-header">
|
||||
<div className="fm-file-progress-window-name" title={item.name}>
|
||||
<div className="fm-file-progress-window-name-text">{item.name}</div>
|
||||
<div className="fm-file-progress-window-name-text">{truncateNameMiddle(item.name, 25, 8, 8)}</div>
|
||||
{item.driveName && (
|
||||
<div className="fm-drive-line">
|
||||
<span className="fm-drive-chip" title={`Drive: ${item.driveName}`}>
|
||||
{item.driveName}
|
||||
{truncateNameMiddle(item.driveName, 25, 8, 8)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -182,7 +171,7 @@ export function FileProgressWindow({
|
||||
<button
|
||||
className="fm-file-progress-window-row-close"
|
||||
aria-label={rowActionLabel}
|
||||
onClick={() => onRowClose?.(item.name)}
|
||||
onClick={() => onRowClose?.(item.uuid)}
|
||||
type="button"
|
||||
>
|
||||
<CloseIcon size="14" />
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 2px;
|
||||
padding-right: 14px;
|
||||
padding-left: 14px;
|
||||
}
|
||||
.fm-modal-window.fm-get-info-modal .fm-modal-window-body::-webkit-scrollbar,
|
||||
.fm-get-info-body::-webkit-scrollbar {
|
||||
@@ -66,23 +67,60 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 60%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
color: #111827;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.fm-copy-btn {
|
||||
margin-left: 6px;
|
||||
.fm-copyable-value {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
line-height: 0;
|
||||
padding: 4px 8px;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-copy-btn:hover {
|
||||
background: #f5f5f5;
|
||||
.fm-copyable-value:hover {
|
||||
background: rgba(237, 129, 49, 0.1);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.fm-copyable-value svg {
|
||||
flex-shrink: 0;
|
||||
color: rgb(237, 129, 49);
|
||||
}
|
||||
|
||||
.fm-copied-indicator {
|
||||
position: absolute;
|
||||
left: -70px;
|
||||
background: rgb(237, 129, 49);
|
||||
color: white;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
animation: fadeInOut 2s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes fadeInOut {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, useState } from 'react'
|
||||
import { ReactElement, useState, useEffect } from 'react'
|
||||
import './GetInfoModal.scss'
|
||||
import { Button } from '../Button/Button'
|
||||
import { createPortal } from 'react-dom'
|
||||
@@ -16,11 +16,28 @@ interface GetInfoModalProps {
|
||||
export function GetInfoModal({ name, onCancelClick, properties }: GetInfoModalProps): ReactElement {
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
const timeoutRef = useState<{ [key: string]: NodeJS.Timeout }>({})[0]
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(timeoutRef).forEach(timeout => clearTimeout(timeout))
|
||||
}
|
||||
}, [timeoutRef])
|
||||
|
||||
const handleCopy = async (prop: FileProperty) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(prop.raw ?? prop.value)
|
||||
|
||||
if (timeoutRef[prop.key]) {
|
||||
clearTimeout(timeoutRef[prop.key])
|
||||
}
|
||||
|
||||
setCopiedKey(prop.key)
|
||||
window.setTimeout(() => setCopiedKey(null), 1200)
|
||||
|
||||
timeoutRef[prop.key] = setTimeout(() => {
|
||||
setCopiedKey(prev => (prev === prop.key ? null : prev))
|
||||
delete timeoutRef[prop.key]
|
||||
}, 2000)
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
@@ -45,20 +62,20 @@ export function GetInfoModal({ name, onCancelClick, properties }: GetInfoModalPr
|
||||
{group.properties.map(prop => (
|
||||
<div key={prop.key} className="fm-get-info-modal-property-row">
|
||||
<span className="fm-get-info-modal-property-label">{prop.label}</span>
|
||||
<span className="fm-get-info-modal-property-value">
|
||||
{prop.value}
|
||||
{(prop.raw || prop.value.includes('...')) && (
|
||||
<button
|
||||
className="fm-copy-btn"
|
||||
onClick={() => handleCopy(prop)}
|
||||
aria-label={`Copy ${prop.label}`}
|
||||
type="button"
|
||||
title={copiedKey === prop.key ? 'Copied!' : 'Copy'}
|
||||
>
|
||||
<ClipboardIcon size="14px" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
{prop.raw || prop.value.includes('...') ? (
|
||||
<button
|
||||
className="fm-get-info-modal-property-value fm-copyable-value"
|
||||
onClick={() => handleCopy(prop)}
|
||||
aria-label={`Copy ${prop.label}`}
|
||||
type="button"
|
||||
>
|
||||
<ClipboardIcon size="12px" />
|
||||
<span className="fm-copyable-value-text">{prop.value}</span>
|
||||
{copiedKey === prop.key && <span className="fm-copied-indicator">Copied!</span>}
|
||||
</button>
|
||||
) : (
|
||||
<span className="fm-get-info-modal-property-value">{prop.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
.fm-initialization-modal-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba(237, 237, 237);
|
||||
backdrop-filter: blur(5px);
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fm-initialization-modal-container .fm-modal-window {
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fm-initilization-progress-content {
|
||||
@@ -16,3 +23,7 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-main:has(.fm-initialization-modal-container) {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { BZZ, DAI, Duration, PostageBatch, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js'
|
||||
import { BeeModes, BZZ, DAI, Duration, PostageBatch, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js'
|
||||
import './InitialModal.scss'
|
||||
import { CustomDropdown } from '../CustomDropdown/CustomDropdown'
|
||||
import { Button } from '../Button/Button'
|
||||
@@ -21,7 +21,7 @@ import { TOOLTIPS } from '../../constants/tooltips'
|
||||
interface InitialModalProps {
|
||||
resetState: boolean
|
||||
handleVisibility: (isVisible: boolean) => void
|
||||
handleShowError: (flag: boolean) => void
|
||||
handleShowError: (flag: boolean, errorMessage?: string) => void
|
||||
setIsCreationInProgress: (isCreating: boolean) => void
|
||||
}
|
||||
|
||||
@@ -43,6 +43,25 @@ const createBatchIdOptions = (stamps: PostageBatch[]) => [
|
||||
}),
|
||||
]
|
||||
|
||||
const setSecurityLevel = (setter: (value: RedundancyLevel) => void) => {
|
||||
return (
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="admin-security-level" className="fm-input-label">
|
||||
Security Level <Tooltip label={TOOLTIPS.ADMIN_SECURITY_LEVEL} />
|
||||
</label>
|
||||
<FMSlider
|
||||
id="admin-security-level"
|
||||
defaultValue={0}
|
||||
marks={erasureCodeMarks}
|
||||
onChange={v => setter(v)}
|
||||
minValue={minMarkValue}
|
||||
maxValue={maxMarkValue}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function InitialModal({
|
||||
resetState,
|
||||
setIsCreationInProgress,
|
||||
@@ -60,8 +79,9 @@ export function InitialModal({
|
||||
const [usableStamps, setUsableStamps] = useState<PostageBatch[]>([])
|
||||
const [selectedBatch, setSelectedBatch] = useState<PostageBatch | null>(null)
|
||||
const [selectedBatchIndex, setSelectedBatchIndex] = useState<number>(-1)
|
||||
const [isNodeSyncing, setIsNodeSyncing] = useState(true)
|
||||
|
||||
const { walletBalance } = useContext(BeeContext)
|
||||
const { walletBalance, nodeInfo } = useContext(BeeContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { fm } = useContext(FMContext)
|
||||
|
||||
@@ -74,30 +94,64 @@ export function InitialModal({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const checkBalances = useCallback(
|
||||
(cost: BZZ) => {
|
||||
setIsBalanceSufficient(true)
|
||||
setIsxDaiBalanceSufficient(true)
|
||||
|
||||
if ((walletBalance && cost.gte(walletBalance.bzzBalance)) || !walletBalance) {
|
||||
safeSetState(isMountedRef, setIsBalanceSufficient)(false)
|
||||
}
|
||||
|
||||
const zeroDAI = DAI.fromDecimalString('0')
|
||||
|
||||
if ((walletBalance && zeroDAI.eq(walletBalance.nativeTokenBalance)) || !walletBalance) {
|
||||
safeSetState(isMountedRef, setIsxDaiBalanceSufficient)(false)
|
||||
}
|
||||
},
|
||||
[walletBalance],
|
||||
)
|
||||
|
||||
const handleCostFetch = useCallback(
|
||||
(cost: BZZ) => {
|
||||
safeSetState(isMountedRef, setIsNodeSyncing)(false)
|
||||
checkBalances(cost)
|
||||
safeSetState(isMountedRef, setCost)(cost.toSignificantDigits(2))
|
||||
},
|
||||
[checkBalances],
|
||||
)
|
||||
|
||||
const handleCostFetchError = useCallback(() => {
|
||||
safeSetState(isMountedRef, setIsNodeSyncing)(true)
|
||||
safeSetState(isMountedRef, setCost)('0')
|
||||
}, [])
|
||||
|
||||
const createAdminDrive = useCallback(async () => {
|
||||
setIsCreationInProgress?.(true)
|
||||
handleVisibility(false)
|
||||
|
||||
await handleCreateDrive(
|
||||
await handleCreateDrive({
|
||||
beeApi,
|
||||
fm,
|
||||
Size.fromBytes(capacity),
|
||||
Duration.fromEndDate(validityEndDate),
|
||||
ADMIN_STAMP_LABEL,
|
||||
false,
|
||||
erasureCodeLevel,
|
||||
true,
|
||||
size: Size.fromBytes(capacity),
|
||||
duration: Duration.fromEndDate(validityEndDate),
|
||||
label: ADMIN_STAMP_LABEL,
|
||||
encryption: false,
|
||||
redundancyLevel: erasureCodeLevel,
|
||||
adminRedundancy: erasureCodeLevel,
|
||||
isAdmin: true,
|
||||
resetState,
|
||||
selectedBatch,
|
||||
() => {
|
||||
existingBatch: selectedBatch,
|
||||
onSuccess: () => {
|
||||
handleVisibility(false)
|
||||
setIsCreationInProgress(false)
|
||||
}, // onSuccess
|
||||
() => {
|
||||
handleShowError(true)
|
||||
},
|
||||
onError: err => {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err)
|
||||
handleShowError(true, errorMessage)
|
||||
setIsCreationInProgress(false)
|
||||
}, // onError
|
||||
)
|
||||
},
|
||||
})
|
||||
}, [
|
||||
beeApi,
|
||||
fm,
|
||||
@@ -113,11 +167,7 @@ export function InitialModal({
|
||||
|
||||
useEffect(() => {
|
||||
const getStamps = async () => {
|
||||
const stamps = (await getUsableStamps(beeApi)).filter(s => {
|
||||
const { capacityPct } = calculateStampCapacityMetrics(s)
|
||||
|
||||
return capacityPct < 100
|
||||
})
|
||||
const stamps = await getUsableStamps(beeApi)
|
||||
|
||||
safeSetState(isMountedRef, setUsableStamps)([...stamps])
|
||||
}
|
||||
@@ -141,70 +191,117 @@ export function InitialModal({
|
||||
false,
|
||||
erasureCodeLevel,
|
||||
beeApi,
|
||||
(cost: BZZ) => {
|
||||
setIsBalanceSufficient(true)
|
||||
setIsxDaiBalanceSufficient(true)
|
||||
|
||||
if ((walletBalance && cost.gte(walletBalance.bzzBalance)) || !walletBalance) {
|
||||
safeSetState(isMountedRef, setIsBalanceSufficient)(false)
|
||||
}
|
||||
|
||||
const zeroDAI = DAI.fromDecimalString('0')
|
||||
|
||||
if ((walletBalance && zeroDAI.eq(walletBalance.nativeTokenBalance)) || !walletBalance) {
|
||||
safeSetState(isMountedRef, setIsxDaiBalanceSufficient)(false)
|
||||
}
|
||||
|
||||
safeSetState(isMountedRef, setCost)(cost.toSignificantDigits(2))
|
||||
},
|
||||
handleCostFetch,
|
||||
currentFetch,
|
||||
handleCostFetchError,
|
||||
)
|
||||
|
||||
if (lifetimeIndex >= 0) {
|
||||
if (lifetimeIndex >= 0 && !isNodeSyncing) {
|
||||
setIsCreateEnabled(true)
|
||||
} else {
|
||||
setIsCreateEnabled(false)
|
||||
}
|
||||
} else {
|
||||
setCost('0')
|
||||
setIsCreateEnabled(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [validityEndDate, beeApi, capacity, lifetimeIndex, walletBalance])
|
||||
}, [
|
||||
validityEndDate,
|
||||
erasureCodeLevel,
|
||||
beeApi,
|
||||
capacity,
|
||||
lifetimeIndex,
|
||||
isNodeSyncing,
|
||||
handleCostFetch,
|
||||
handleCostFetchError,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
setValidityEndDate(getExpiryDateByLifetime(lifetimeIndex))
|
||||
}, [lifetimeIndex])
|
||||
|
||||
const nonFullStamps = useMemo(() => {
|
||||
return usableStamps.filter(s => {
|
||||
const { capacityPct } = calculateStampCapacityMetrics(s, [], erasureCodeLevel)
|
||||
|
||||
return capacityPct < 100
|
||||
})
|
||||
}, [usableStamps, erasureCodeLevel])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBatchIndex >= 0 && selectedBatchIndex < usableStamps.length) {
|
||||
setSelectedBatch(usableStamps[selectedBatchIndex])
|
||||
if (selectedBatchIndex >= 0 && selectedBatchIndex < nonFullStamps.length) {
|
||||
setSelectedBatch(nonFullStamps[selectedBatchIndex])
|
||||
} else {
|
||||
setSelectedBatch(null)
|
||||
}
|
||||
}, [usableStamps, selectedBatchIndex])
|
||||
}, [nonFullStamps, selectedBatchIndex])
|
||||
|
||||
const { capacityPct, usedSize, totalSize } = useMemo(
|
||||
() => calculateStampCapacityMetrics(selectedBatch),
|
||||
[selectedBatch],
|
||||
)
|
||||
const { capacityPct, usedSize, stampSize } = useMemo(() => {
|
||||
if (!selectedBatch) {
|
||||
return {
|
||||
capacityPct: 0,
|
||||
usedSize: '—',
|
||||
stampSize: '—',
|
||||
usedBytes: 0,
|
||||
stampSizeBytes: 0,
|
||||
remainingBytes: 0,
|
||||
}
|
||||
}
|
||||
|
||||
return calculateStampCapacityMetrics(selectedBatch, [], erasureCodeLevel)
|
||||
}, [selectedBatch, erasureCodeLevel])
|
||||
|
||||
const initText = resetState ? 'Resetting' : 'Initializing'
|
||||
const createText = resetState ? 'Reset' : 'Create'
|
||||
|
||||
const isUltraLightNode = nodeInfo?.beeMode === BeeModes.ULTRA_LIGHT
|
||||
|
||||
const isCreateDriveDisabled =
|
||||
isUltraLightNode ||
|
||||
isNodeSyncing ||
|
||||
(selectedBatch ? false : !isCreateEnabled || !isBalanceSufficient || !isxDaiBalanceSufficient)
|
||||
|
||||
const renderUltraLightNodeWarning = () => {
|
||||
if (!isUltraLightNode) return null
|
||||
|
||||
const upgradeLink = (
|
||||
<a
|
||||
href="https://docs.ethswarm.org/docs/desktop/configuration/#upgrading-from-an-ultra-light-to-a-light-node"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
upgrade
|
||||
</a>
|
||||
)
|
||||
|
||||
if (selectedBatch) {
|
||||
return (
|
||||
<div>
|
||||
{resetState ? 'Resetting' : 'Creating'} a drive requires running a light node. Please {upgradeLink} to
|
||||
continue.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
Purchasing a stamp and {resetState ? 'resetting' : 'creating'} a drive requires running a light node. Please{' '}
|
||||
{upgradeLink} to continue.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fm-initialization-modal-container">
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header">Welcome to your Swarm File Manager</div>
|
||||
<div>{initText} the File Manager</div>
|
||||
{usableStamps.length > 0 && (
|
||||
{nonFullStamps.length > 0 && (
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-window-input-container">
|
||||
{/* <label htmlFor="admin-desired-lifetime" className="fm-input-label">
|
||||
Link an existing Admin Drive (optional)
|
||||
</label>
|
||||
<br /> */}
|
||||
<CustomDropdown
|
||||
id="batch-id-selector"
|
||||
options={createBatchIdOptions(usableStamps)}
|
||||
options={createBatchIdOptions(nonFullStamps)}
|
||||
value={selectedBatchIndex}
|
||||
label="Link an existing Admin Drive (optional)"
|
||||
onChange={(index: number) => {
|
||||
@@ -219,13 +316,14 @@ export function InitialModal({
|
||||
{selectedBatch && (
|
||||
<div className="fm-drive-item-content">
|
||||
<div className="fm-drive-item-capacity">
|
||||
Capacity <ProgressBar value={capacityPct} width="64px" /> {usedSize} / {totalSize}
|
||||
Capacity <ProgressBar value={capacityPct} width="64px" /> {usedSize} / {stampSize}
|
||||
</div>
|
||||
<div className="fm-drive-item-capacity">
|
||||
Expiry date: {selectedBatch.duration.toEndDate().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{selectedBatch && setSecurityLevel(setErasureCodeLevel)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -243,20 +341,7 @@ export function InitialModal({
|
||||
placeholder="Select a value"
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="admin-security-level" className="fm-input-label">
|
||||
Security Level <Tooltip label={TOOLTIPS.ADMIN_SECURITY_LEVEL} />
|
||||
</label>
|
||||
<FMSlider
|
||||
id="admin-security-level"
|
||||
defaultValue={0}
|
||||
marks={erasureCodeMarks}
|
||||
onChange={value => setErasureCodeLevel(value)}
|
||||
minValue={minMarkValue}
|
||||
maxValue={maxMarkValue}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
{setSecurityLevel(setErasureCodeLevel)}
|
||||
<div className="fm-modal-window-input-container">
|
||||
<div className="fm-modal-estimated-cost-container">
|
||||
<div className="fm-emphasized-text">Estimated Cost:</div>
|
||||
@@ -267,6 +352,12 @@ export function InitialModal({
|
||||
<Tooltip label={TOOLTIPS.ADMIN_ESTIMATED_COST} />
|
||||
</div>
|
||||
<div>(Based on current network conditions)</div>
|
||||
{renderUltraLightNodeWarning()}
|
||||
{isNodeSyncing && !selectedBatch && (
|
||||
<div className="fm-modal-info-warning" style={{ marginBottom: '16px' }}>
|
||||
Node is syncing. Please wait until sync completes before purchasing a stamp.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -274,7 +365,7 @@ export function InitialModal({
|
||||
<Button
|
||||
label={selectedBatch ? `${createText} Drive` : `Purchase Stamp & ${createText} Drive`}
|
||||
variant="primary"
|
||||
disabled={selectedBatch ? false : !isCreateEnabled || !isBalanceSufficient || !isxDaiBalanceSufficient}
|
||||
disabled={isCreateDriveDisabled}
|
||||
onClick={createAdminDrive}
|
||||
/>
|
||||
<Tooltip
|
||||
|
||||
@@ -7,10 +7,18 @@ import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { FILE_MANAGER_EVENTS } from '../../constants/common'
|
||||
|
||||
const NUMBER_OF_DAYS_WARNING = 7
|
||||
const DAYS_TO_MILLISECONDS_MULTIPLIER = 24 * 60 * 60 * 1000
|
||||
|
||||
const isExpiring = (s: PostageBatch): boolean => {
|
||||
return (
|
||||
s.duration &&
|
||||
s.duration.toEndDate().getTime() <= Date.now() + NUMBER_OF_DAYS_WARNING * DAYS_TO_MILLISECONDS_MULTIPLIER
|
||||
)
|
||||
}
|
||||
|
||||
interface NotificationBarProps {
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
@@ -20,7 +28,7 @@ export function NotificationBar({ setErrorMessage }: NotificationBarProps): Reac
|
||||
const [stampsToExpire, setStampsToExpire] = useState<PostageBatch[]>([])
|
||||
const [drivesToExpire, setDrivesToExpire] = useState<DriveInfo[]>([])
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { drives, adminDrive } = useContext(FMContext)
|
||||
const { drives, files, adminDrive } = useContext(FMContext)
|
||||
|
||||
const showExpiration = stampsToExpire.length > 0
|
||||
|
||||
@@ -38,12 +46,7 @@ export function NotificationBar({ setErrorMessage }: NotificationBarProps): Reac
|
||||
(adminDrive?.batchId.toString() === stamp.batchID.toString() ? adminDrive : null)
|
||||
|
||||
if (matchingDrive) {
|
||||
const isExpiring =
|
||||
stamp.duration &&
|
||||
stamp.duration.toEndDate().getTime() <=
|
||||
Date.now() + NUMBER_OF_DAYS_WARNING * DAYS_TO_MILLISECONDS_MULTIPLIER
|
||||
|
||||
if (isExpiring) {
|
||||
if (isExpiring(stamp)) {
|
||||
expiringStamps.push(stamp)
|
||||
expiringDrives.push(matchingDrive)
|
||||
}
|
||||
@@ -61,7 +64,37 @@ export function NotificationBar({ setErrorMessage }: NotificationBarProps): Reac
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [beeApi, drives, adminDrive])
|
||||
}, [beeApi, drives, adminDrive, setErrorMessage])
|
||||
|
||||
useEffect(() => {
|
||||
const onDriveUpgradeEnd = (e: Event) => {
|
||||
const { driveId, success, updatedStamp } = (e as CustomEvent).detail || {}
|
||||
|
||||
if (success && updatedStamp && driveId) {
|
||||
if (!isExpiring(updatedStamp)) {
|
||||
setTimeout(() => {
|
||||
setStampsToExpire(prev => {
|
||||
const stampIx = prev.findIndex(s => s.batchID.toString() === updatedStamp.batchID.toString())
|
||||
|
||||
return stampIx !== -1 ? prev.filter((_, i) => i !== stampIx) : prev
|
||||
})
|
||||
|
||||
setDrivesToExpire(prev => {
|
||||
const driveIx = prev.findIndex(d => d.id.toString() === driveId)
|
||||
|
||||
return driveIx !== -1 ? prev.filter((_, i) => i !== driveIx) : prev
|
||||
})
|
||||
}, 150)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, onDriveUpgradeEnd as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, onDriveUpgradeEnd as EventListener)
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!showExpiration) return null
|
||||
|
||||
@@ -74,6 +107,7 @@ export function NotificationBar({ setErrorMessage }: NotificationBarProps): Reac
|
||||
<ExpiringNotificationModal
|
||||
stamps={stampsToExpire}
|
||||
drives={drivesToExpire}
|
||||
files={files}
|
||||
onCancelClick={() => {
|
||||
setShowExpiringModal(false)
|
||||
}}
|
||||
|
||||
@@ -80,11 +80,52 @@
|
||||
}
|
||||
|
||||
.fm-initialization-modal-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fm-initialization-modal-container .fm-modal-window {
|
||||
width: 600px;
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: visible;
|
||||
pointer-events: auto;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.fm-initialization-modal-container .fm-modal-window-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fm-initialization-modal-container .fm-modal-window-footer {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fm-initialization-modal-container .fm-modal-window-scrollable {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.fm-main:has(.fm-initialization-modal-container) {
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
@@ -70,99 +70,102 @@ export function PrivateKeyModal({ onSaved }: Props): ReactElement {
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header">
|
||||
<div>Create Private Key</div>
|
||||
<Tooltip label={TOOLTIPS.PRIVATE_KEY_MODAL_HEADER} />
|
||||
</div>
|
||||
<div>
|
||||
Using a private key ensures that only you can access this File Manager instance. Save it securely before
|
||||
continuing.
|
||||
</div>
|
||||
<div className="fm-modal-info-warning flex-column">
|
||||
<span className="fm-modal-info-warning-text-header">IMPORTANT: Lost keys cannot be recovered</span>
|
||||
<span>
|
||||
Swarm never stores private keys. If you lose this key, access to this File Manager instance will be
|
||||
permanently lost.
|
||||
</span>
|
||||
</div>
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="fm-private-key" className="fm-emphasized-text fm-private-key-label">
|
||||
<span>New Private key</span>
|
||||
<button
|
||||
onClick={handleGenerateNew}
|
||||
type="button"
|
||||
className="fm-generate-btn"
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#e5e7eb')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = '#f3f4f6')}
|
||||
>
|
||||
Generate New
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<div className="fm-private-key-input-row">
|
||||
<input
|
||||
id="fm-private-key"
|
||||
type="text"
|
||||
className={`fm-input${showError ? ' has-error' : ''} fm-private-key-input`}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
setValue(e.target.value)
|
||||
setCopied(false)
|
||||
setShowError(false)
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{
|
||||
<div className="fm-modal-window-scrollable">
|
||||
<div>This key grants access to this File Manager instance. Save it before continuing..</div>
|
||||
<div className="fm-modal-info-warning flex-column">
|
||||
<span className="fm-modal-info-warning-text-header">CRITICAL: Key Cannot Be Recovered</span>
|
||||
<span>
|
||||
Swarm does not store this key and <strong>cannot</strong> retrieve it. Loss of the key will result in
|
||||
permanent loss of access to this File Manager instance.
|
||||
</span>
|
||||
</div>
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="fm-private-key" className="fm-emphasized-text fm-private-key-label">
|
||||
<span>1. New Private key</span>
|
||||
<button
|
||||
className="fm-copy-btn"
|
||||
onClick={handleCopyPrivateKey}
|
||||
aria-label="Copy private key"
|
||||
onClick={handleGenerateNew}
|
||||
type="button"
|
||||
title={copied ? 'Copied!' : 'Copy'}
|
||||
className="fm-generate-btn"
|
||||
onMouseEnter={e => (e.currentTarget.style.background = '#e5e7eb')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = '#f3f4f6')}
|
||||
>
|
||||
{copied ? <CheckDoubleLineIcon size="16px" /> : <ClipboardIcon size="16px" />}
|
||||
Generate New
|
||||
</button>
|
||||
}
|
||||
<Tooltip label={TOOLTIPS.PRIVATE_KEY_MODAL_GENERATED_KEY} />
|
||||
</label>
|
||||
|
||||
<div className="fm-private-key-input-row">
|
||||
<input
|
||||
id="fm-private-key"
|
||||
type="text"
|
||||
className={`fm-input${showError ? ' has-error' : ''} fm-private-key-input`}
|
||||
autoComplete="off"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
setValue(e.target.value)
|
||||
setCopied(false)
|
||||
setShowError(false)
|
||||
}}
|
||||
onBlur={handleBlur}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{
|
||||
<button
|
||||
className="fm-copy-btn"
|
||||
onClick={handleCopyPrivateKey}
|
||||
aria-label="Copy private key"
|
||||
type="button"
|
||||
title={copied ? 'Copied!' : 'Copy'}
|
||||
>
|
||||
{copied ? <CheckDoubleLineIcon size="16px" /> : <ClipboardIcon size="16px" />}
|
||||
</button>
|
||||
}
|
||||
<Tooltip label={TOOLTIPS.PRIVATE_KEY_MODAL_GENERATED_KEY} />
|
||||
</div>
|
||||
<div className="fm-input-hint-error">{showError ? 'Invalid private key.' : ''}</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="fm-private-key-confirm" className="fm-emphasized-text fm-confirm-key-label">
|
||||
Confirm Private Key
|
||||
</label>
|
||||
<div className="fm-private-key-input-row">
|
||||
<input
|
||||
id="fm-private-key-confirm"
|
||||
type="text"
|
||||
className="fm-input fm-confirm-key-input"
|
||||
placeholder="Paste or type your private key again"
|
||||
autoComplete="off"
|
||||
value={confirmValue}
|
||||
onChange={e => setConfirmValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<Tooltip label={TOOLTIPS.PRIVATE_KEY_MODAL_CONFIRM_KEY} />
|
||||
</div>
|
||||
<div className="fm-input-hint fm-confirm-key-hint">
|
||||
{confirmValue && value === confirmValue
|
||||
? '✓ Private keys match!'
|
||||
: 'Save the private key securely, then paste or type it again to confirm.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-input-hint-error">{showError ? 'Invalid private key.' : ''}</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="fm-private-key-confirm" className="fm-emphasized-text fm-confirm-key-label">
|
||||
Confirm Private Key
|
||||
</label>
|
||||
<div className="fm-private-key-input-row">
|
||||
<input
|
||||
id="fm-private-key-confirm"
|
||||
type="text"
|
||||
className="fm-input fm-confirm-key-input"
|
||||
placeholder="Paste or type your private key again"
|
||||
autoComplete="off"
|
||||
value={confirmValue}
|
||||
onChange={e => setConfirmValue(e.target.value)}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-input-hint fm-confirm-key-hint">
|
||||
{confirmValue && value === confirmValue ? '✓ Private keys match!' : ''}
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="flex-row">
|
||||
<div>
|
||||
<b>Key Storage:</b>
|
||||
</div>
|
||||
<Tooltip label={TOOLTIPS.PRIVATE_KEY_MODAL_KEY_INFO} />
|
||||
</div>
|
||||
The key is saved only in this browser's local storage. If browser data is cleared, a different browser
|
||||
is used, or the OS is updated, this local copy might be deleted. The key will be required to access this
|
||||
File Manager instance after that.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="flex-row">
|
||||
<div>
|
||||
<b>Safety Reminder:</b>
|
||||
</div>
|
||||
</div>
|
||||
<span>
|
||||
A copy of your private key is stored in this browser for convenience, but it’s not a backup - clearing
|
||||
browser data or switching devices will remove it.{' '}
|
||||
<b>Make sure you’ve saved your private key before continuing.</b>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-footer">
|
||||
<Button
|
||||
label="Save"
|
||||
|
||||
@@ -7,6 +7,8 @@ import EditIcon from 'remixicon-react/EditLineIcon'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { safeSetState } from '../../utils/common'
|
||||
|
||||
const maxFileNameLength = 60
|
||||
|
||||
interface RenameFileModalProps {
|
||||
currentName: string
|
||||
takenNames?: Set<string> | string[]
|
||||
@@ -115,6 +117,7 @@ export function RenameFileModal({
|
||||
onBlur={() => setTouched(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Enter a new file name"
|
||||
maxLength={maxFileNameLength}
|
||||
/>
|
||||
{error && (
|
||||
<div className="fm-error-text" style={{ marginTop: 8 }}>
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
color: rgb(31, 41, 55);
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
.fm-drive-item-icon {
|
||||
display: flex;
|
||||
@@ -26,6 +28,7 @@
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.fm-drive-item-container {
|
||||
@@ -48,7 +51,41 @@
|
||||
.fm-drive-item-capacity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
|
||||
& > span {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-tooltip-wrapper {
|
||||
flex-shrink: 0;
|
||||
margin-left: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
filter: none !important;
|
||||
opacity: 1 !important;
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
|
||||
&.fm-drive-item-capacity-updating {
|
||||
& > span {
|
||||
filter: blur(2px);
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// Keep tooltip interactive even when capacity is updating
|
||||
.fm-tooltip-wrapper {
|
||||
filter: none;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-drive-item-actions {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, useState, useContext, useEffect, useRef, useMemo } from 'react'
|
||||
import { ReactElement, useState, useContext, useEffect, useMemo, useCallback, useRef, memo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Drive from 'remixicon-react/HardDrive2LineIcon'
|
||||
import DriveFill from 'remixicon-react/HardDrive2FillIcon'
|
||||
@@ -8,15 +8,150 @@ import { ProgressBar } from '../../ProgressBar/ProgressBar'
|
||||
import { ContextMenu } from '../../ContextMenu/ContextMenu'
|
||||
import { useContextMenu } from '../../../hooks/useContextMenu'
|
||||
import { Button } from '../../Button/Button'
|
||||
import { DestroyDriveModal } from '../../DestroyDriveModal/DestroyDriveModal'
|
||||
import { DestroyDriveModal, ProgressDestroyModal } from '../../DestroyDriveModal/DestroyDriveModal'
|
||||
import { UpgradeDriveModal } from '../../UpgradeDriveModal/UpgradeDriveModal'
|
||||
import { UpgradeTimeoutModal } from '../../UpgradeTimeoutModal/UpgradeTimeoutModal'
|
||||
import { ViewType } from '../../../constants/transfers'
|
||||
import { useView } from '../../../../../pages/filemanager/ViewContext'
|
||||
import { Context as FMContext } from '../../../../../providers/FileManager'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { calculateStampCapacityMetrics, handleDestroyDrive } from '../../../utils/bee'
|
||||
import { calculateStampCapacityMetrics, handleDestroyAndForgetDrive } from '../../../utils/bee'
|
||||
import { Context as SettingsContext } from '../../../../../providers/Settings'
|
||||
import { truncateNameMiddle } from '../../../utils/common'
|
||||
import { Tooltip } from '../../Tooltip/Tooltip'
|
||||
import { TOOLTIPS } from '../../../constants/tooltips'
|
||||
import { FILE_MANAGER_EVENTS, UPLOAD_POLLING_TIMEOUT_MS } from '../../../constants/common'
|
||||
import { useStampPolling } from '../../../hooks/useStampPolling'
|
||||
|
||||
function useDriveEventListeners(
|
||||
driveId: string,
|
||||
handleUpgradeStart: (eventDriveId: string, id: string) => void,
|
||||
handleUpgradeEnd: (
|
||||
eventDriveId: string,
|
||||
id: string,
|
||||
success: boolean,
|
||||
error: string | undefined,
|
||||
updatedStamp?: PostageBatch,
|
||||
) => void,
|
||||
handleUpgradeTimeout: (eventDriveId: string, id: string) => void,
|
||||
handleFileUploaded: (e: Event) => void,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const onStart = (e: Event) => {
|
||||
const { driveId: eventDriveId } = (e as CustomEvent).detail || {}
|
||||
handleUpgradeStart(eventDriveId, driveId)
|
||||
}
|
||||
|
||||
const onEnd = (e: Event) => {
|
||||
const { driveId: eventDriveId, success, error, updatedStamp } = (e as CustomEvent).detail || {}
|
||||
handleUpgradeEnd(eventDriveId, driveId, success, error, updatedStamp)
|
||||
}
|
||||
|
||||
const onTimeout = (e: Event) => {
|
||||
const { driveId: eventDriveId } = (e as CustomEvent).detail || {}
|
||||
handleUpgradeTimeout(eventDriveId, driveId)
|
||||
}
|
||||
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_START, onStart as EventListener)
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, onEnd as EventListener)
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_TIMEOUT, onTimeout as EventListener)
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.FILE_UPLOADED, handleFileUploaded as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_START, onStart as EventListener)
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, onEnd as EventListener)
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_TIMEOUT, onTimeout as EventListener)
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.FILE_UPLOADED, handleFileUploaded as EventListener)
|
||||
}
|
||||
}, [driveId, handleUpgradeStart, handleUpgradeEnd, handleUpgradeTimeout, handleFileUploaded])
|
||||
}
|
||||
|
||||
interface DriveModalsProps {
|
||||
isUpgradeDriveModalOpen: boolean
|
||||
setIsUpgradeDriveModalOpen: (open: boolean) => void
|
||||
isUpgradeTimeoutModalOpen: boolean
|
||||
actualStamp: PostageBatch
|
||||
drive: DriveInfo
|
||||
setErrorMessage?: (error: string) => void
|
||||
isUpgrading: boolean
|
||||
isCapacityUpdating: boolean
|
||||
isDestroying: boolean
|
||||
setIsProgressModalOpen: (open: boolean) => void
|
||||
isProgressModalOpen: boolean
|
||||
isDestroyDriveModalOpen: boolean
|
||||
setIsDestroyDriveModalOpen: (open: boolean) => void
|
||||
doDestroy: () => Promise<void>
|
||||
onCancelTimeout: () => void
|
||||
}
|
||||
|
||||
function DriveModals({
|
||||
isUpgradeDriveModalOpen,
|
||||
setIsUpgradeDriveModalOpen,
|
||||
isUpgradeTimeoutModalOpen,
|
||||
actualStamp,
|
||||
drive,
|
||||
setErrorMessage,
|
||||
isUpgrading,
|
||||
isCapacityUpdating,
|
||||
isDestroying,
|
||||
setIsProgressModalOpen,
|
||||
isProgressModalOpen,
|
||||
isDestroyDriveModalOpen,
|
||||
setIsDestroyDriveModalOpen,
|
||||
doDestroy,
|
||||
onCancelTimeout,
|
||||
}: DriveModalsProps): ReactElement | null {
|
||||
return (
|
||||
<>
|
||||
{isUpgradeDriveModalOpen && (
|
||||
<UpgradeDriveModal
|
||||
stamp={actualStamp}
|
||||
drive={drive}
|
||||
onCancelClick={() => setIsUpgradeDriveModalOpen(false)}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isUpgradeTimeoutModalOpen && <UpgradeTimeoutModal driveName={drive.name} onOk={onCancelTimeout} />}
|
||||
|
||||
{isUpgrading && (
|
||||
<div className="fm-drive-item-creating-overlay" aria-live="polite">
|
||||
<div className="fm-mini-spinner" />
|
||||
<span>Upgrading drive…</span>
|
||||
</div>
|
||||
)}
|
||||
{isCapacityUpdating && !isUpgrading && (
|
||||
<div className="fm-drive-item-creating-overlay" aria-live="polite">
|
||||
<div className="fm-mini-spinner" />
|
||||
<span>Updating capacity…</span>
|
||||
</div>
|
||||
)}
|
||||
{isDestroying && (
|
||||
<div
|
||||
className="fm-drive-item-creating-overlay"
|
||||
aria-live="polite"
|
||||
onClick={() => setIsProgressModalOpen(true)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
title="Click to show progress modal"
|
||||
>
|
||||
<div className="fm-mini-spinner" />
|
||||
<span>Destroying drive…</span>
|
||||
</div>
|
||||
)}
|
||||
{isProgressModalOpen && isDestroying && (
|
||||
<ProgressDestroyModal drive={drive} onMinimize={() => setIsProgressModalOpen(false)} />
|
||||
)}
|
||||
{isDestroyDriveModalOpen && (
|
||||
<DestroyDriveModal
|
||||
drive={drive}
|
||||
onCancelClick={() => setIsDestroyDriveModalOpen(false)}
|
||||
doDestroy={doDestroy}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface DriveItemProps {
|
||||
drive: DriveInfo
|
||||
@@ -25,122 +160,247 @@ interface DriveItemProps {
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function DriveItem({ drive, stamp, isSelected, setErrorMessage }: DriveItemProps): ReactElement {
|
||||
const { fm, setShowError, refreshStamp } = useContext(FMContext)
|
||||
function DriveItemComponent({ drive, stamp, isSelected, setErrorMessage }: DriveItemProps): ReactElement {
|
||||
const { fm, adminDrive, files, setShowError, refreshStamp } = useContext(FMContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
|
||||
const driveId = drive.id.toString()
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isDestroyDriveModalOpen, setIsDestroyDriveModalOpen] = useState(false)
|
||||
const [isProgressModalOpen, setIsProgressModalOpen] = useState(false)
|
||||
const [isUpgradeDriveModalOpen, setIsUpgradeDriveModalOpen] = useState(false)
|
||||
const isMountedRef = useRef(true)
|
||||
const [isUpgradeTimeoutModalOpen, setIsUpgradeTimeoutModalOpen] = useState(false)
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
const [isCapacityUpdating, setIsCapacityUpdating] = useState(false)
|
||||
const [isDestroying, setIsDestroying] = useState(false)
|
||||
const [actualStamp, setActualStamp] = useState<PostageBatch>(stamp)
|
||||
const batchIDRef = useRef(stamp.batchID)
|
||||
const isUpgradingRef = useRef(false)
|
||||
const actualStampRef = useRef(actualStamp)
|
||||
const startPollingRef = useRef<((stamp: PostageBatch) => void) | null>(null)
|
||||
const stopPollingRef = useRef<(() => void) | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
actualStampRef.current = actualStamp
|
||||
}, [actualStamp])
|
||||
|
||||
const handleStampUpdated = useCallback((updatedStamp: PostageBatch) => {
|
||||
setActualStamp(updatedStamp)
|
||||
batchIDRef.current = updatedStamp.batchID
|
||||
}, [])
|
||||
|
||||
const handlePollingStateChange = useCallback((isPolling: boolean) => {
|
||||
setIsCapacityUpdating(isPolling)
|
||||
}, [])
|
||||
|
||||
const { startPolling, stopPolling } = useStampPolling({
|
||||
onStampUpdated: handleStampUpdated,
|
||||
onPollingStateChange: handlePollingStateChange,
|
||||
refreshStamp,
|
||||
timeout: UPLOAD_POLLING_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
startPollingRef.current = startPolling
|
||||
}, [startPolling])
|
||||
|
||||
useEffect(() => {
|
||||
stopPollingRef.current = stopPolling
|
||||
}, [stopPolling])
|
||||
|
||||
const { showContext, pos, contextRef, setPos, setShowContext } = useContextMenu<HTMLDivElement>()
|
||||
|
||||
const { setView, setActualItemView } = useView()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
if (isUpgradingRef.current) {
|
||||
return
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (actualStamp.batchID.toString() !== stamp.batchID.toString()) {
|
||||
setActualStamp(stamp)
|
||||
batchIDRef.current = stamp.batchID
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const incomingSize = stamp.size.toBytes()
|
||||
const currentSize = actualStamp.size.toBytes()
|
||||
const incomingExpiry = stamp.duration.toEndDate().getTime()
|
||||
const currentExpiry = actualStamp.duration.toEndDate().getTime()
|
||||
|
||||
if (incomingSize > currentSize || incomingExpiry > currentExpiry) {
|
||||
setActualStamp(stamp)
|
||||
batchIDRef.current = stamp.batchID
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [stamp])
|
||||
|
||||
useEffect(() => {
|
||||
setActualStamp(stamp)
|
||||
}, [stamp])
|
||||
return () => {
|
||||
if (stopPollingRef.current) {
|
||||
stopPollingRef.current()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
function handleMenuClick(e: React.MouseEvent) {
|
||||
setShowContext(true)
|
||||
setPos({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
function handleDestroyDriveClick() {
|
||||
setShowContext(false)
|
||||
}
|
||||
const handleUpgradeStart = useCallback((driveId: string, id: string) => {
|
||||
if (driveId !== id) return
|
||||
|
||||
useEffect(() => {
|
||||
const id = drive.id.toString()
|
||||
const batchId = stamp.batchID.toString()
|
||||
isUpgradingRef.current = true
|
||||
setIsUpgrading(true)
|
||||
}, [])
|
||||
|
||||
const onStart = (e: Event) => {
|
||||
const { driveId } = (e as CustomEvent).detail || {}
|
||||
const handleUpgradeEnd = useCallback(
|
||||
(driveId: string, id: string, success: boolean, error: string | undefined, updatedStamp?: PostageBatch) => {
|
||||
if (driveId !== id) return
|
||||
|
||||
if (driveId === id) {
|
||||
setIsUpgrading(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onEnd = async (e: Event) => {
|
||||
const { driveId, success, error } = (e as CustomEvent).detail || {}
|
||||
|
||||
if (!success) {
|
||||
if (error) {
|
||||
setErrorMessage?.(error)
|
||||
}
|
||||
|
||||
setShowError(true)
|
||||
}
|
||||
|
||||
if (driveId === id) {
|
||||
const resetUpgrading = () => {
|
||||
setIsUpgrading(false)
|
||||
|
||||
const upgradedStamp = await refreshStamp(batchId)
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
if (upgradedStamp) {
|
||||
setActualStamp(upgradedStamp)
|
||||
}
|
||||
isUpgradingRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||
window.addEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||
if (!success && error) {
|
||||
resetUpgrading()
|
||||
setErrorMessage?.(error)
|
||||
setShowError(true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||
window.removeEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||
}
|
||||
}, [drive.id, setShowError, setErrorMessage, stamp.batchID, refreshStamp])
|
||||
return
|
||||
}
|
||||
|
||||
const { capacityPct, usedSize, totalSize } = useMemo(
|
||||
() => calculateStampCapacityMetrics(actualStamp, drive),
|
||||
[actualStamp, drive],
|
||||
if (updatedStamp) {
|
||||
setActualStamp(updatedStamp)
|
||||
batchIDRef.current = updatedStamp.batchID
|
||||
setTimeout(resetUpgrading, 300)
|
||||
} else {
|
||||
resetUpgrading()
|
||||
}
|
||||
},
|
||||
[setErrorMessage, setShowError],
|
||||
)
|
||||
|
||||
const doDestroy = useCallback(async () => {
|
||||
const closeModals = () => {
|
||||
setIsDestroyDriveModalOpen(false)
|
||||
setIsDestroying(false)
|
||||
setIsProgressModalOpen(false)
|
||||
}
|
||||
|
||||
setIsDestroyDriveModalOpen(false)
|
||||
setIsProgressModalOpen(true)
|
||||
setIsDestroying(true)
|
||||
|
||||
await handleDestroyAndForgetDrive({
|
||||
beeApi,
|
||||
fm,
|
||||
drive,
|
||||
isDestroy: true,
|
||||
adminDrive,
|
||||
onSuccess: closeModals,
|
||||
onError: e => {
|
||||
closeModals()
|
||||
setErrorMessage?.(`Error destroying drive: ${drive.name}: ${e}`)
|
||||
setShowError(true)
|
||||
},
|
||||
})
|
||||
}, [beeApi, fm, drive, adminDrive, setErrorMessage, setShowError])
|
||||
|
||||
const handleUpgradeTimeout = useCallback(
|
||||
(eventDriveId: string, id: string) => {
|
||||
if (eventDriveId !== id) return
|
||||
setIsUpgradeTimeoutModalOpen(true)
|
||||
},
|
||||
[setIsUpgradeTimeoutModalOpen],
|
||||
)
|
||||
|
||||
const handleCancelTimeout = useCallback(() => {
|
||||
setIsUpgrading(false)
|
||||
isUpgradingRef.current = false
|
||||
setIsUpgradeTimeoutModalOpen(false)
|
||||
|
||||
if (startPollingRef.current && actualStampRef.current) {
|
||||
startPollingRef.current(actualStampRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleFileUploaded = useCallback(
|
||||
(e: Event) => {
|
||||
const { fileInfo } = (e as CustomEvent).detail || {}
|
||||
|
||||
if (!fileInfo || fileInfo.driveId !== driveId || !startPollingRef.current) return
|
||||
|
||||
startPollingRef.current(actualStampRef.current)
|
||||
},
|
||||
[driveId],
|
||||
)
|
||||
|
||||
useDriveEventListeners(driveId, handleUpgradeStart, handleUpgradeEnd, handleUpgradeTimeout, handleFileUploaded)
|
||||
|
||||
const { capacityPct, usedSize, stampSize } = useMemo(() => {
|
||||
const filesPerDrive = files.filter(fi => fi.driveId === drive.id.toString())
|
||||
|
||||
return calculateStampCapacityMetrics(actualStamp, filesPerDrive, drive.redundancyLevel, isCapacityUpdating)
|
||||
}, [actualStamp, drive, files, isCapacityUpdating])
|
||||
|
||||
const handleDriveClick = useCallback(() => {
|
||||
setView(ViewType.File)
|
||||
setActualItemView?.(drive.name)
|
||||
}, [setView, setActualItemView, drive.name])
|
||||
|
||||
const handleDestroyClick = useCallback(() => {
|
||||
setShowContext(false)
|
||||
setIsDestroyDriveModalOpen(true)
|
||||
}, [setShowContext, setIsDestroyDriveModalOpen])
|
||||
|
||||
const selectedClass = isSelected ? ' fm-drive-item-container-selected' : ''
|
||||
const containerClassName = `fm-drive-item-container${selectedClass}`
|
||||
|
||||
const updatingClass = isUpgrading || isCapacityUpdating ? ' fm-drive-item-capacity-updating' : ''
|
||||
const capacityClassName = `fm-drive-item-capacity${updatingClass}`
|
||||
|
||||
const driveIcon = isHovered ? <DriveFill size="16px" /> : <Drive size="16px" />
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fm-drive-item-container${isSelected ? ' fm-drive-item-container-selected' : ''}`}
|
||||
onClick={() => {
|
||||
setView(ViewType.File)
|
||||
setActualItemView?.(drive.name)
|
||||
}}
|
||||
>
|
||||
<div className={containerClassName} onClick={handleDriveClick}>
|
||||
<div
|
||||
className="fm-drive-item-info"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="fm-drive-item-header">
|
||||
<div className="fm-drive-item-icon">{isHovered ? <DriveFill size="16px" /> : <Drive size="16px" />}</div>
|
||||
<div>{drive.name}</div>
|
||||
<div className="fm-drive-item-icon">{driveIcon}</div>
|
||||
<div>{truncateNameMiddle(drive.name, 35, 8, 8)}</div>
|
||||
</div>
|
||||
<div className="fm-drive-item-content">
|
||||
<div className="fm-drive-item-capacity">
|
||||
Capacity <ProgressBar value={capacityPct} width="64px" /> {usedSize} / {totalSize}
|
||||
<div className={capacityClassName}>
|
||||
<span>
|
||||
Capacity <ProgressBar value={capacityPct} width="64px" /> {usedSize} / {stampSize}
|
||||
</span>
|
||||
<Tooltip
|
||||
label={
|
||||
isUpgrading || isCapacityUpdating ? TOOLTIPS.DRIVE_CAPACITY_UPDATING : TOOLTIPS.DRIVE_CAPACITY_INFO
|
||||
}
|
||||
iconSize="12px"
|
||||
disableMargin={true}
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-drive-item-capacity">
|
||||
Expiry date: {actualStamp.duration.toEndDate().toLocaleDateString()}
|
||||
<div className={capacityClassName}>
|
||||
<span>Expiry date: {actualStamp.duration.toEndDate().toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-drive-item-actions">
|
||||
<MoreFill
|
||||
size="13"
|
||||
className={`fm-pointer${isUpgrading ? ' fm-disabled' : ''}`}
|
||||
onClick={!isUpgrading ? handleMenuClick : undefined}
|
||||
aria-disabled={isUpgrading ? 'true' : 'false'}
|
||||
className={`fm-pointer${isUpgrading || isDestroying ? ' fm-disabled' : ''}`}
|
||||
onClick={!isUpgrading && !isDestroying ? handleMenuClick : undefined}
|
||||
aria-disabled={isUpgrading || isDestroying ? 'true' : 'false'}
|
||||
/>
|
||||
{showContext &&
|
||||
createPortal(
|
||||
@@ -153,13 +413,7 @@ export function DriveItem({ drive, stamp, isSelected, setErrorMessage }: DriveIt
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<div
|
||||
className="fm-context-item red"
|
||||
onClick={() => {
|
||||
handleDestroyDriveClick()
|
||||
setIsDestroyDriveModalOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="fm-context-item red" onClick={handleDestroyClick}>
|
||||
Destroy entire drive
|
||||
</div>
|
||||
</ContextMenu>
|
||||
@@ -171,48 +425,38 @@ export function DriveItem({ drive, stamp, isSelected, setErrorMessage }: DriveIt
|
||||
label="Upgrade"
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={isUpgrading}
|
||||
disabled={isUpgrading || isDestroying}
|
||||
onClick={() => setIsUpgradeDriveModalOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{isUpgradeDriveModalOpen && (
|
||||
<UpgradeDriveModal
|
||||
stamp={actualStamp}
|
||||
drive={drive}
|
||||
onCancelClick={() => setIsUpgradeDriveModalOpen(false)}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isUpgrading && (
|
||||
<div className="fm-drive-item-creating-overlay" aria-live="polite">
|
||||
<div className="fm-mini-spinner" />
|
||||
<span>Upgrading drive…</span>
|
||||
</div>
|
||||
)}
|
||||
{isDestroyDriveModalOpen && (
|
||||
<DestroyDriveModal
|
||||
drive={drive}
|
||||
onCancelClick={() => setIsDestroyDriveModalOpen(false)}
|
||||
doDestroy={async () => {
|
||||
setIsDestroyDriveModalOpen(false)
|
||||
|
||||
await handleDestroyDrive(
|
||||
beeApi,
|
||||
fm,
|
||||
drive,
|
||||
() => {
|
||||
setIsDestroyDriveModalOpen(false)
|
||||
},
|
||||
e => {
|
||||
setIsDestroyDriveModalOpen(false)
|
||||
setErrorMessage?.(`Error destroying drive: ${drive.name}: ${e}`)
|
||||
setShowError(true)
|
||||
},
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<DriveModals
|
||||
isUpgradeDriveModalOpen={isUpgradeDriveModalOpen}
|
||||
setIsUpgradeDriveModalOpen={setIsUpgradeDriveModalOpen}
|
||||
isUpgradeTimeoutModalOpen={isUpgradeTimeoutModalOpen}
|
||||
actualStamp={actualStamp}
|
||||
drive={drive}
|
||||
setErrorMessage={setErrorMessage}
|
||||
isUpgrading={isUpgrading}
|
||||
isCapacityUpdating={isCapacityUpdating}
|
||||
isDestroying={isDestroying}
|
||||
setIsProgressModalOpen={setIsProgressModalOpen}
|
||||
isProgressModalOpen={isProgressModalOpen}
|
||||
isDestroyDriveModalOpen={isDestroyDriveModalOpen}
|
||||
setIsDestroyDriveModalOpen={setIsDestroyDriveModalOpen}
|
||||
doDestroy={doDestroy}
|
||||
onCancelTimeout={handleCancelTimeout}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function arePropsEqual(prevProps: DriveItemProps, nextProps: DriveItemProps) {
|
||||
const driveIdEqual = prevProps.drive.id.toString() === nextProps.drive.id.toString()
|
||||
const stampIdEqual = prevProps.stamp.batchID.toString() === nextProps.stamp.batchID.toString()
|
||||
const isSelectedEqual = prevProps.isSelected === nextProps.isSelected
|
||||
|
||||
return driveIdEqual && stampIdEqual && isSelectedEqual
|
||||
}
|
||||
|
||||
export const MemoizedDriveItem = memo(DriveItemComponent, arePropsEqual)
|
||||
export const DriveItem = MemoizedDriveItem
|
||||
|
||||
@@ -7,9 +7,10 @@ import { ContextMenu } from '../../ContextMenu/ContextMenu'
|
||||
import { useContextMenu } from '../../../hooks/useContextMenu'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { Context as FMContext } from '../../../../../providers/FileManager'
|
||||
import { handleForgetDrive } from '../../../utils/bee'
|
||||
import { handleDestroyAndForgetDrive } from '../../../utils/bee'
|
||||
import { ConfirmModal } from '../../ConfirmModal/ConfirmModal'
|
||||
import './DriveItem.scss'
|
||||
import { truncateNameMiddle } from '../../../utils/common'
|
||||
|
||||
interface Props {
|
||||
drive: DriveInfo
|
||||
@@ -18,7 +19,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ExpiredDriveItem({ drive, onForgot, setErrorMessage }: Props): ReactElement {
|
||||
const { fm, setShowError } = useContext(FMContext)
|
||||
const { fm, adminDrive, setShowError } = useContext(FMContext)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [showForgetConfirm, setShowForgetConfirm] = useState(false)
|
||||
const { showContext, pos, contextRef, setPos, setShowContext } = useContextMenu<HTMLDivElement>()
|
||||
@@ -37,7 +38,7 @@ export function ExpiredDriveItem({ drive, onForgot, setErrorMessage }: Props): R
|
||||
<div className="fm-drive-item-info">
|
||||
<div className="fm-drive-item-header">
|
||||
<div className="fm-drive-item-icon">{isHovered ? <DriveFill size="16px" /> : <Drive size="16px" />}</div>
|
||||
<div>{drive.name}</div>
|
||||
<div>{truncateNameMiddle(drive.name, 35, 8, 8)}</div>
|
||||
</div>
|
||||
<div className="fm-drive-item-content">
|
||||
<div className="fm-drive-item-capacity">Stamp expired — files unavailable</div>
|
||||
@@ -89,21 +90,21 @@ export function ExpiredDriveItem({ drive, onForgot, setErrorMessage }: Props): R
|
||||
cancelLabel="Keep"
|
||||
onCancel={() => setShowForgetConfirm(false)}
|
||||
onConfirm={async () => {
|
||||
if (!fm) return
|
||||
|
||||
await handleForgetDrive(
|
||||
await handleDestroyAndForgetDrive({
|
||||
fm,
|
||||
drive,
|
||||
async () => {
|
||||
isDestroy: false,
|
||||
adminDrive,
|
||||
onSuccess: async () => {
|
||||
setShowForgetConfirm(false)
|
||||
await onForgot?.()
|
||||
},
|
||||
() => {
|
||||
onError: () => {
|
||||
setShowForgetConfirm(false)
|
||||
setErrorMessage?.(`Failed to forget drive ${drive.name}`)
|
||||
setShowError(true)
|
||||
},
|
||||
)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -40,6 +40,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
.fm-sidebar-item-description {
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
color: rgb(75, 85, 99);
|
||||
font-size: 14px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fm-sidebar-drive-creation {
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgb(146, 146, 146);
|
||||
|
||||
@@ -20,6 +20,8 @@ import { useView } from '../../../../pages/filemanager/ViewContext'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import { getUsableStamps } from '../../utils/bee'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { truncateNameMiddle } from '../../utils/common'
|
||||
import { FILE_MANAGER_EVENTS } from '../../constants/common'
|
||||
|
||||
interface SidebarProps {
|
||||
loading: boolean
|
||||
@@ -34,6 +36,7 @@ export function Sidebar({ setErrorMessage, loading }: SidebarProps): ReactElemen
|
||||
const [isCreateDriveOpen, setIsCreateDriveOpen] = useState(false)
|
||||
const [usableStamps, setUsableStamps] = useState<PostageBatch[]>([])
|
||||
const [isDriveCreationInProgress, setIsDriveCreationInProgress] = useState(false)
|
||||
const [creatingDriveName, setCreatingDriveName] = useState<string | null>(null)
|
||||
const [isExpiredOpen, setIsExpiredOpen] = useState(false)
|
||||
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
@@ -65,8 +68,17 @@ export function Sidebar({ setErrorMessage, loading }: SidebarProps): ReactElemen
|
||||
getStamps()
|
||||
}
|
||||
|
||||
const handleUpgradeEnd = async () => {
|
||||
if (isMounted && beeApi) {
|
||||
await getStamps()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, handleUpgradeEnd as EventListener)
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
window.removeEventListener(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, handleUpgradeEnd as EventListener)
|
||||
}
|
||||
}, [beeApi, drives])
|
||||
|
||||
@@ -81,14 +93,22 @@ export function Sidebar({ setErrorMessage, loading }: SidebarProps): ReactElemen
|
||||
setView(ViewType.File)
|
||||
}
|
||||
|
||||
if (currentDrive && !currentStamp && usableStamps.length > 0) {
|
||||
if (currentDrive && usableStamps.length > 0) {
|
||||
const correspondingStamp = usableStamps.find(s => s.batchID.toString() === currentDrive.batchId.toString())
|
||||
|
||||
if (correspondingStamp) {
|
||||
setCurrentStamp(correspondingStamp)
|
||||
}
|
||||
}
|
||||
}, [fm, drives, currentDrive, currentStamp, usableStamps, setCurrentDrive, setCurrentStamp, setView])
|
||||
}, [fm, drives, currentDrive, usableStamps, setCurrentDrive, setCurrentStamp, setView, beeApi])
|
||||
|
||||
const handleCreateNewDrive = () => {
|
||||
if (isDriveCreationInProgress) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsCreateDriveOpen(true)
|
||||
}
|
||||
|
||||
const isCurrent = (di: DriveInfo) => currentDrive?.id.toString() === di.id.toString()
|
||||
|
||||
@@ -96,12 +116,22 @@ export function Sidebar({ setErrorMessage, loading }: SidebarProps): ReactElemen
|
||||
<div className="fm-sidebar">
|
||||
<div className="fm-sidebar-content">
|
||||
{!loading && (
|
||||
<div className="fm-sidebar-item" onClick={() => setIsCreateDriveOpen(true)}>
|
||||
<div className="fm-sidebar-item-icon">
|
||||
<Add size="16px" />
|
||||
<>
|
||||
<div
|
||||
className={`fm-sidebar-item ${isDriveCreationInProgress ? 'disabled' : ''}`}
|
||||
onClick={() => handleCreateNewDrive()}
|
||||
>
|
||||
<div className="fm-sidebar-item-icon">
|
||||
<Add size="16px" />
|
||||
</div>
|
||||
<div>Create new drive</div>
|
||||
</div>
|
||||
<div>Create new drive</div>
|
||||
</div>
|
||||
{isDriveCreationInProgress && (
|
||||
<div className="fm-sidebar-item-description">
|
||||
{truncateNameMiddle(creatingDriveName || 'Your Drive', 35, 8, 8)} is currently being created.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{isCreateDriveOpen && (
|
||||
@@ -110,12 +140,19 @@ export function Sidebar({ setErrorMessage, loading }: SidebarProps): ReactElemen
|
||||
onDriveCreated={() => {
|
||||
setIsCreateDriveOpen(false)
|
||||
setIsDriveCreationInProgress(false)
|
||||
setCreatingDriveName(null)
|
||||
}}
|
||||
onCreationStarted={(driveName: string) => {
|
||||
setIsDriveCreationInProgress(true)
|
||||
setCreatingDriveName(driveName)
|
||||
}}
|
||||
onCreationStarted={() => setIsDriveCreationInProgress(true)}
|
||||
onCreationError={(name: string) => {
|
||||
setIsDriveCreationInProgress(false)
|
||||
setErrorMessage?.(`Error creating drive: ${name}`)
|
||||
setErrorMessage?.(
|
||||
`Error creating drive ${name}. Please try again. Possible causes include insufficient xDAI balance or a lost connection to the RPC.`,
|
||||
)
|
||||
setShowError(true)
|
||||
setCreatingDriveName(null)
|
||||
|
||||
return
|
||||
}}
|
||||
@@ -253,7 +290,7 @@ export function Sidebar({ setErrorMessage, loading }: SidebarProps): ReactElemen
|
||||
}}
|
||||
title={`${d.name} Trash`}
|
||||
>
|
||||
{d.name} Trash
|
||||
{truncateNameMiddle(d.name, 35, 8, 8)} Trash
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.fm-tooltip-wrapper.no-margin {
|
||||
@@ -39,18 +40,15 @@
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 50%;
|
||||
left: calc(100% + 6px);
|
||||
position: fixed;
|
||||
z-index: 9999;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
font-weight: 400; /* ensure no bold styling */
|
||||
font-weight: 400;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.16s ease-in-out, visibility 0.16s ease-in-out, transform 0.16s ease-in-out;
|
||||
transition: opacity 0.16s ease-in-out, visibility 0.16s ease-in-out;
|
||||
border: 1px solid rgb(209, 213, 219);
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
}
|
||||
|
||||
.fm-tooltip-container.bottom {
|
||||
@@ -61,18 +59,6 @@
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
/* Left alignment (flip) when wrapper has .left class */
|
||||
.fm-tooltip-wrapper.left .fm-tooltip-container {
|
||||
left: auto;
|
||||
right: calc(100% + 6px);
|
||||
transform: translateY(-50%) translateX(-4px);
|
||||
}
|
||||
|
||||
.fm-tooltip-wrapper.left:hover .fm-tooltip-container {
|
||||
transform: translateY(-50%) translateX(0);
|
||||
}
|
||||
|
||||
.fm-inline-label-with-tooltip {
|
||||
|
||||
@@ -22,6 +22,7 @@ export function Tooltip({
|
||||
bottomTooltip = false,
|
||||
}: TooltipProps): ReactElement {
|
||||
const [alignLeft, setAlignLeft] = useState(false)
|
||||
const [position, setPosition] = useState<{ top: number; left?: number; right?: number } | null>(null)
|
||||
const wrapperRef = useRef<HTMLSpanElement>(null)
|
||||
|
||||
const evaluateAlignment = useCallback(() => {
|
||||
@@ -33,14 +34,27 @@ export function Tooltip({
|
||||
if (!container) return
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
|
||||
const modalContainer = wrapper.closest('.fm-modal-container') as HTMLElement | null
|
||||
let containerOffset = 0
|
||||
|
||||
if (modalContainer) {
|
||||
const containerRect = modalContainer.getBoundingClientRect()
|
||||
containerOffset = containerRect.left
|
||||
}
|
||||
|
||||
const tooltipWidth = container.offsetWidth || 0
|
||||
const projectedRight = wrapperRect.right + gapPx + tooltipWidth + edgeOffsetPx
|
||||
const viewportWidth = window.innerWidth
|
||||
|
||||
const top = wrapperRect.top + wrapperRect.height / 2
|
||||
|
||||
if (projectedRight > viewportWidth) {
|
||||
setAlignLeft(true)
|
||||
setPosition({ top, right: viewportWidth - wrapperRect.left + gapPx - containerOffset })
|
||||
} else {
|
||||
setAlignLeft(false)
|
||||
setPosition({ top, left: wrapperRect.right + gapPx - containerOffset })
|
||||
}
|
||||
}, [edgeOffsetPx, gapPx])
|
||||
|
||||
@@ -58,6 +72,16 @@ export function Tooltip({
|
||||
</span>
|
||||
<div
|
||||
className={`fm-tooltip-container${bottomTooltip ? ' bottom' : ''}`}
|
||||
style={
|
||||
position
|
||||
? {
|
||||
top: `${position.top}px`,
|
||||
left: position.left !== undefined ? `${position.left}px` : undefined,
|
||||
right: position.right !== undefined ? `${position.right}px` : undefined,
|
||||
transform: 'translateY(-50%)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: label }}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
.fm-upgrade-drive-modal-container {
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal {
|
||||
width: 600px;
|
||||
max-height: calc(100vh - 48px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.fm-modal-window-scrollable {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-wallet {
|
||||
@@ -57,4 +74,4 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
import { ReactElement, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import './UpgradeDriveModal.scss'
|
||||
import '../../styles/global.scss'
|
||||
import { CustomDropdown } from '../CustomDropdown/CustomDropdown'
|
||||
import { Button } from '../Button/Button'
|
||||
import { Warning } from '@material-ui/icons'
|
||||
import { createPortal } from 'react-dom'
|
||||
import DriveIcon from 'remixicon-react/HardDrive2LineIcon'
|
||||
import DatabaseIcon from 'remixicon-react/Database2LineIcon'
|
||||
import WalletIcon from 'remixicon-react/Wallet3LineIcon'
|
||||
import ExternalLinkIcon from 'remixicon-react/ExternalLinkLineIcon'
|
||||
import CalendarIcon from 'remixicon-react/CalendarLineIcon'
|
||||
import { desiredLifetimeOptions } from '../../constants/stamps'
|
||||
import { Context as BeeContext } from '../../../../providers/Bee'
|
||||
import { fromBytesConversion, getExpiryDateByLifetime } from '../../utils/common'
|
||||
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
|
||||
import {
|
||||
BatchId,
|
||||
BeeRequestOptions,
|
||||
@@ -27,8 +20,17 @@ import {
|
||||
Utils,
|
||||
} from '@ethersphere/bee-js'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
|
||||
import { CustomDropdown } from '../CustomDropdown/CustomDropdown'
|
||||
import { Button } from '../Button/Button'
|
||||
import { desiredLifetimeOptions } from '../../constants/stamps'
|
||||
import { Context as BeeContext } from '../../../../providers/Bee'
|
||||
import { fromBytesConversion, getExpiryDateByLifetime, truncateNameMiddle } from '../../utils/common'
|
||||
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import { getHumanReadableFileSize } from '../../../../utils/file'
|
||||
import { Warning } from '@material-ui/icons'
|
||||
import { useStampPolling } from '../../hooks/useStampPolling'
|
||||
import { FILE_MANAGER_EVENTS, POLLING_TIMEOUT_MS } from '../../constants/common'
|
||||
|
||||
interface UpgradeDriveModalProps {
|
||||
stamp: PostageBatch
|
||||
@@ -50,10 +52,10 @@ export function UpgradeDriveModal({
|
||||
}: UpgradeDriveModalProps): ReactElement {
|
||||
const { nodeAddresses, walletBalance } = useContext(BeeContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { setShowError } = useContext(FMContext)
|
||||
const { refreshStamp, setShowError } = useContext(FMContext)
|
||||
|
||||
const [isBalanceSufficient, setIsBalanceSufficient] = useState(true)
|
||||
const [capacity, setCapacity] = useState(Size.fromBytes(0))
|
||||
const [capacity, setCapacity] = useState(stamp.size)
|
||||
const [capacityExtensionCost, setCapacityExtensionCost] = useState('')
|
||||
const [capacityIndex, setCapacityIndex] = useState(0)
|
||||
const [durationExtensionCost, setDurationExtensionCost] = useState('')
|
||||
@@ -66,14 +68,37 @@ export function UpgradeDriveModal({
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
const { startPolling } = useStampPolling({
|
||||
refreshStamp,
|
||||
onStampUpdated: (updatedStamp: PostageBatch) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, {
|
||||
detail: {
|
||||
driveId: drive.id.toString(),
|
||||
success: true,
|
||||
updatedStamp,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
onTimeout: (finalStamp: PostageBatch | null) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_TIMEOUT, {
|
||||
detail: {
|
||||
driveId: drive.id.toString(),
|
||||
finalStamp: finalStamp || null,
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
onPollingStateChange: () => {
|
||||
// no-op
|
||||
},
|
||||
timeout: POLLING_TIMEOUT_MS,
|
||||
})
|
||||
|
||||
const handleCapacityChange = (value: number, index: number) => {
|
||||
setCapacity(Size.fromBytes(value === -1 ? 0 : value))
|
||||
setCapacity(value === -1 ? stamp.size : Size.fromBytes(value))
|
||||
setCapacityIndex(index)
|
||||
}
|
||||
|
||||
@@ -88,8 +113,6 @@ export function UpgradeDriveModal({
|
||||
isCapacityExtensionSet: boolean,
|
||||
isDurationExtensionSet: boolean,
|
||||
) => {
|
||||
setIsBalanceSufficient(true)
|
||||
|
||||
let cost: BZZ | undefined
|
||||
|
||||
try {
|
||||
@@ -107,6 +130,8 @@ export function UpgradeDriveModal({
|
||||
|
||||
if ((walletBalance && cost && cost.gte(walletBalance.bzzBalance)) || !walletBalance) {
|
||||
setIsBalanceSufficient(false)
|
||||
} else {
|
||||
setIsBalanceSufficient(true)
|
||||
}
|
||||
|
||||
const bothExtensions = isCapacityExtensionSet && isDurationExtensionSet
|
||||
@@ -130,7 +155,7 @@ export function UpgradeDriveModal({
|
||||
|
||||
setExtensionCost(noExtensions ? '0' : costText)
|
||||
},
|
||||
[beeApi, walletBalance, setErrorMessage, setShowError],
|
||||
[beeApi, walletBalance, isMountedRef, setErrorMessage, setShowError],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,13 +183,14 @@ export function UpgradeDriveModal({
|
||||
useEffect(() => {
|
||||
const fetchExtensionCost = () => {
|
||||
const isCapacitySet = capacityIndex > 0
|
||||
const isDurationSet = true
|
||||
const duration = Duration.fromEndDate(validityEndDate)
|
||||
const isDurationSet = lifetimeIndex >= 0
|
||||
const extendDuration =
|
||||
lifetimeIndex >= 0 ? Duration.fromEndDate(validityEndDate, stamp.duration.toEndDate()) : Duration.ZERO
|
||||
|
||||
handleCostCalculation(
|
||||
stamp.batchID,
|
||||
capacity,
|
||||
duration,
|
||||
extendDuration,
|
||||
undefined,
|
||||
false,
|
||||
defaultErasureCodeLevel,
|
||||
@@ -174,114 +200,126 @@ export function UpgradeDriveModal({
|
||||
}
|
||||
|
||||
fetchExtensionCost()
|
||||
}, [capacity, validityEndDate, capacityIndex, handleCostCalculation, lifetimeIndex, stamp.batchID])
|
||||
}, [capacity, validityEndDate, capacityIndex, handleCostCalculation, lifetimeIndex, stamp.batchID, stamp.duration])
|
||||
|
||||
useEffect(() => {
|
||||
setValidityEndDate(getExpiryDateByLifetime(lifetimeIndex, stamp.duration.toEndDate()))
|
||||
}, [lifetimeIndex, stamp.duration])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const batchIdStr = stamp.batchID.toString()
|
||||
const shortBatchId = batchIdStr.length > 12 ? `${batchIdStr.slice(0, 4)}...${batchIdStr.slice(-4)}` : batchIdStr
|
||||
|
||||
return createPortal(
|
||||
<div className={`fm-modal-container${containerColor === 'none' ? ' fm-modal-container-no-bg' : ''}`}>
|
||||
<div
|
||||
className={`fm-modal-container fm-upgrade-drive-modal-container${
|
||||
containerColor === 'none' ? ' fm-modal-container-no-bg' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="fm-modal-window fm-upgrade-drive-modal">
|
||||
<div className="fm-modal-window-header">
|
||||
<DriveIcon size="18px" /> Upgrade {drive.name || stamp.label || shortBatchId}
|
||||
<DriveIcon size="18px" /> Upgrade {truncateNameMiddle(drive.name || stamp.label || shortBatchId, 35)}
|
||||
</div>
|
||||
<div>Choose extension period and additional storage for your drive.</div>
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-upgrade-drive-modal-wallet">
|
||||
<div className="fm-upgrade-drive-modal-wallet-header fm-emphasized-text">
|
||||
<WalletIcon size="14px" color="rgb(237, 129, 49)" /> Wallet information
|
||||
</div>
|
||||
{walletBalance && nodeAddresses ? (
|
||||
<div className="fm-upgrade-drive-modal-wallet-info-container">
|
||||
<div className="fm-upgrade-drive-modal-wallet-info">
|
||||
<div>Balance</div>
|
||||
<div>{`${walletBalance.bzzBalance.toSignificantDigits(4)} xBZZ`}</div>
|
||||
</div>
|
||||
<div className="fm-upgrade-drive-modal-wallet-info">
|
||||
<div>Wallet address:</div>
|
||||
<div className="fm-value-snippet">{`${walletBalance.walletAddress.slice(
|
||||
0,
|
||||
4,
|
||||
)}...${walletBalance.walletAddress.slice(-4)}`}</div>
|
||||
</div>
|
||||
<div className="fm-modal-window-scrollable">
|
||||
<div>Choose extension period and additional storage for your drive.</div>
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-upgrade-drive-modal-wallet">
|
||||
<div className="fm-upgrade-drive-modal-wallet-header fm-emphasized-text">
|
||||
<WalletIcon size="14px" color="rgb(237, 129, 49)" /> Wallet information
|
||||
</div>
|
||||
{walletBalance && nodeAddresses ? (
|
||||
<div className="fm-upgrade-drive-modal-wallet-info-container">
|
||||
<div className="fm-upgrade-drive-modal-wallet-info">
|
||||
<div>Balance</div>
|
||||
<div>{`${walletBalance.bzzBalance.toSignificantDigits(4)} xBZZ`}</div>
|
||||
</div>
|
||||
<div className="fm-upgrade-drive-modal-wallet-info">
|
||||
<div>Wallet address:</div>
|
||||
<div className="fm-value-snippet">{`${walletBalance.walletAddress.slice(
|
||||
0,
|
||||
4,
|
||||
)}...${walletBalance.walletAddress.slice(-4)}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>Wallet information is not available</div>
|
||||
)}
|
||||
<div className="fm-upgrade-drive-modal-info fm-swarm-orange-font">
|
||||
<a
|
||||
className="fm-upgrade-drive-modal-info-link fm-pointer"
|
||||
href="https://www.ethswarm.org/get-bzz#how-to-get-bzz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon size="14px" />
|
||||
Need help topping up?
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div>Wallet information is not available</div>
|
||||
)}
|
||||
<div className="fm-upgrade-drive-modal-info fm-swarm-orange-font">
|
||||
<a
|
||||
className="fm-upgrade-drive-modal-info-link fm-pointer"
|
||||
href="https://www.ethswarm.org/get-bzz#how-to-get-bzz"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLinkIcon size="14px" />
|
||||
Need help topping up?
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-upgrade-drive-modal-input-row">
|
||||
<div className="fm-modal-window-input-container">
|
||||
<CustomDropdown
|
||||
id="drive-type"
|
||||
label="Additional storage"
|
||||
icon={<DatabaseIcon size="14px" color="rgb(237, 129, 49)" />}
|
||||
options={sizeMarks}
|
||||
value={capacityIndex === 0 ? -1 : capacity.toBytes()}
|
||||
onChange={handleCapacityChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<CustomDropdown
|
||||
id="drive-type"
|
||||
label="Duration"
|
||||
icon={<CalendarIcon size="14px" color="rgb(237, 129, 49)" />}
|
||||
options={desiredLifetimeOptions}
|
||||
value={lifetimeIndex}
|
||||
onChange={(value, index) => {
|
||||
setLifetimeIndex(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-white-section">
|
||||
<div className="fm-emphasized-text">Summary</div>
|
||||
<div>
|
||||
Drive: {drive.name} {drive.isAdmin && <Warning style={{ fontSize: '16px' }} />}
|
||||
</div>
|
||||
<div>
|
||||
BatchId: {stamp.label} ({shortBatchId})
|
||||
</div>
|
||||
<div>Expiry: {stamp.duration.toEndDate().toLocaleDateString()}</div>
|
||||
<div>
|
||||
Additional storage:{' '}
|
||||
{(() => {
|
||||
if (capacityIndex === 0) return '0 GB'
|
||||
|
||||
return `${
|
||||
fromBytesConversion(Math.max(capacity.toBytes() - stamp.size.toBytes(), 0), 'GB').toFixed(3) + ' GB'
|
||||
} ${durationExtensionCost === '' ? '' : '(' + extensionCost + ' xBZZ)'}`
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
Extension period:{' '}
|
||||
{`${desiredLifetimeOptions[lifetimeIndex]?.label} ${
|
||||
capacityExtensionCost === '' ? '' : '(' + extensionCost + ' xBZZ)'
|
||||
}`}
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-upgrade-drive-modal-input-row">
|
||||
<div className="fm-modal-window-input-container">
|
||||
<CustomDropdown
|
||||
id="drive-type"
|
||||
label="Additional storage"
|
||||
icon={<DatabaseIcon size="14px" color="rgb(237, 129, 49)" />}
|
||||
options={sizeMarks}
|
||||
value={capacityIndex === 0 ? -1 : capacity.toBytes()}
|
||||
onChange={handleCapacityChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<CustomDropdown
|
||||
id="drive-type"
|
||||
label="Duration"
|
||||
icon={<CalendarIcon size="14px" color="rgb(237, 129, 49)" />}
|
||||
options={desiredLifetimeOptions}
|
||||
value={lifetimeIndex}
|
||||
onChange={(value, index) => {
|
||||
setLifetimeIndex(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-upgrade-drive-modal-info fm-emphasized-text">
|
||||
Total:{' '}
|
||||
<span className="fm-swarm-orange-font">
|
||||
{extensionCost} xBZZ {isBalanceSufficient ? '' : '(Insufficient balance)'}
|
||||
</span>
|
||||
<div className="fm-modal-white-section">
|
||||
<div className="fm-emphasized-text">Summary</div>
|
||||
<div>
|
||||
Drive: {truncateNameMiddle(drive.name)} {drive.isAdmin && <Warning style={{ fontSize: '16px' }} />}
|
||||
</div>
|
||||
<div>
|
||||
BatchId: {truncateNameMiddle(stamp.label, 25)} ({shortBatchId})
|
||||
</div>
|
||||
<div>Expiry: {stamp.duration.toEndDate().toLocaleDateString()}</div>
|
||||
<div>
|
||||
Additional storage:{' '}
|
||||
{(() => {
|
||||
if (capacityIndex === 0) return '0 GB'
|
||||
|
||||
return `${
|
||||
fromBytesConversion(Math.max(capacity.toBytes() - stamp.size.toBytes(), 0), 'GB').toFixed(3) + ' GB'
|
||||
} ${durationExtensionCost === '' ? '' : '(' + extensionCost + ' xBZZ)'}`
|
||||
})()}
|
||||
</div>
|
||||
<div>
|
||||
Extension period:{' '}
|
||||
{`${desiredLifetimeOptions[lifetimeIndex]?.label} ${
|
||||
capacityExtensionCost === '' ? '' : '(' + extensionCost + ' xBZZ)'
|
||||
}`}
|
||||
</div>
|
||||
|
||||
<div className="fm-upgrade-drive-modal-info fm-emphasized-text">
|
||||
Total:{' '}
|
||||
<span className="fm-swarm-orange-font">
|
||||
{extensionCost} xBZZ {isBalanceSufficient ? '' : '(Insufficient balance)'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,7 +334,7 @@ export function UpgradeDriveModal({
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('fm:drive-upgrade-start', {
|
||||
new CustomEvent(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_START, {
|
||||
detail: { driveId: drive.id.toString() },
|
||||
}),
|
||||
)
|
||||
@@ -306,24 +344,19 @@ export function UpgradeDriveModal({
|
||||
await beeApi.extendStorage(
|
||||
stamp.batchID,
|
||||
capacity,
|
||||
durationExtensionCost === '0'
|
||||
? Duration.ZERO
|
||||
: Duration.fromEndDate(validityEndDate, stamp.duration.toEndDate()),
|
||||
lifetimeIndex >= 0
|
||||
? Duration.fromEndDate(validityEndDate, stamp.duration.toEndDate())
|
||||
: Duration.ZERO,
|
||||
undefined,
|
||||
false,
|
||||
defaultErasureCodeLevel,
|
||||
)
|
||||
|
||||
// TODO: replace eventlisteners with a better maintainable solution
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('fm:drive-upgrade-end', {
|
||||
detail: { driveId: drive.id.toString(), success: true },
|
||||
}),
|
||||
)
|
||||
startPolling(stamp, capacityIndex > 0)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Upgrade failed'
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('fm:drive-upgrade-end', {
|
||||
new CustomEvent(FILE_MANAGER_EVENTS.DRIVE_UPGRADE_END, {
|
||||
detail: {
|
||||
driveId: drive.id.toString(),
|
||||
success: false,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
.fm-upgrade-timeout-modal {
|
||||
.fm-modal-white-section {
|
||||
p {
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin-left: 20px;
|
||||
margin-top: 8px;
|
||||
|
||||
li {
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ReactElement } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Button } from '../Button/Button'
|
||||
import '../../styles/global.scss'
|
||||
import './UpgradeTimeoutModal.scss'
|
||||
|
||||
interface UpgradeTimeoutModalProps {
|
||||
driveName: string
|
||||
onOk: () => void
|
||||
}
|
||||
|
||||
export function UpgradeTimeoutModal({ driveName, onOk }: UpgradeTimeoutModalProps): ReactElement {
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container fm-upgrade-timeout-modal">
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header">Drive upgrade taking longer than expected</div>
|
||||
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-white-section">
|
||||
<p>
|
||||
The upgrade for <strong>{driveName}</strong> is taking longer than expected.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-footer">
|
||||
<Button label="OK" variant="primary" onClick={onOk} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -7,13 +7,16 @@ import { createPortal } from 'react-dom'
|
||||
import HistoryIcon from 'remixicon-react/HistoryLineIcon'
|
||||
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import type { FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { FeedIndex } from '@ethersphere/bee-js'
|
||||
import { ConflictAction, useUploadConflictDialog } from '../../hooks/useUploadConflictDialog'
|
||||
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
|
||||
import { Tooltip } from '../Tooltip/Tooltip'
|
||||
import { TOOLTIPS } from '../../constants/tooltips'
|
||||
|
||||
import { indexStrToBigint } from '../../utils/common'
|
||||
import { VersionsList, truncateNameMiddle } from './VersionList/VersionList'
|
||||
import { verifyDriveSpace } from '../../utils/bee'
|
||||
import { indexStrToBigint, truncateNameMiddle } from '../../utils/common'
|
||||
import { VersionsList } from './VersionList/VersionList'
|
||||
import { ActionTag, DownloadProgress, TrackDownloadProps } from '../../constants/transfers'
|
||||
import { useTransfers } from '../../hooks/useTransfers'
|
||||
|
||||
@@ -32,7 +35,7 @@ interface VersionHistoryModalProps {
|
||||
}
|
||||
|
||||
export function VersionHistoryModal({ fileInfo, onCancelClick, onDownload }: VersionHistoryModalProps): ReactElement {
|
||||
const { fm, files, currentDrive } = useContext(FMContext)
|
||||
const { fm, files, currentDrive, currentStamp, refreshStamp } = useContext(FMContext)
|
||||
|
||||
const localTransfers = useTransfers({})
|
||||
const trackDownload = onDownload ?? localTransfers.trackDownload
|
||||
@@ -180,7 +183,7 @@ export function VersionHistoryModal({ fileInfo, onCancelClick, onDownload }: Ver
|
||||
|
||||
const doRestore = useCallback(
|
||||
async (versionFi: FileInfo): Promise<void> => {
|
||||
if (!fm || !currentDrive) return
|
||||
if (!fm || !currentDrive || !currentStamp) return
|
||||
|
||||
try {
|
||||
const restoredFrom = indexStrToBigint(versionFi.version)
|
||||
@@ -207,14 +210,27 @@ export function VersionHistoryModal({ fileInfo, onCancelClick, onDownload }: Ver
|
||||
},
|
||||
}
|
||||
|
||||
verifyDriveSpace({
|
||||
fm,
|
||||
redundancyLevel: currentDrive.redundancyLevel,
|
||||
stamp: currentStamp,
|
||||
useInfoSize: true,
|
||||
driveId: versionFi.driveId,
|
||||
cb: err => {
|
||||
throw new Error(err)
|
||||
},
|
||||
})
|
||||
|
||||
await fm.restoreVersion(withMeta)
|
||||
|
||||
refreshStamp(versionFi.batchId.toString())
|
||||
onCancelClick()
|
||||
} catch (e) {
|
||||
const msg = (e as Error)?.message || JSON.stringify(e)
|
||||
setError(msg)
|
||||
}
|
||||
},
|
||||
[fm, onCancelClick, currentDrive],
|
||||
[fm, currentStamp, currentDrive, refreshStamp, onCancelClick],
|
||||
)
|
||||
|
||||
const restoreVersion = useCallback(
|
||||
@@ -263,7 +279,7 @@ export function VersionHistoryModal({ fileInfo, onCancelClick, onDownload }: Ver
|
||||
<>
|
||||
Version history –{' '}
|
||||
<span className="vh-title" title={fileInfo.name}>
|
||||
{truncateNameMiddle(fileInfo.name, 56)}
|
||||
{truncateNameMiddle(fileInfo.name)}
|
||||
</span>
|
||||
{fileInfo && (
|
||||
<span
|
||||
@@ -296,16 +312,21 @@ export function VersionHistoryModal({ fileInfo, onCancelClick, onDownload }: Ver
|
||||
|
||||
{renameConfirm && (
|
||||
<ConfirmModal
|
||||
title="Restore this version?"
|
||||
title={
|
||||
<>
|
||||
Restore this version?
|
||||
<Tooltip label={TOOLTIPS.FILE_OPERATION_RESTORE_VERSION} />
|
||||
</>
|
||||
}
|
||||
message={
|
||||
<>
|
||||
Restoring will rename:
|
||||
<b className="vh-name" title={renameConfirm.headName}>
|
||||
{truncateNameMiddle(renameConfirm.headName, 44)}
|
||||
{truncateNameMiddle(renameConfirm.headName)}
|
||||
</b>{' '}
|
||||
→{' '}
|
||||
<b className="vh-name" title={renameConfirm.targetName}>
|
||||
{truncateNameMiddle(renameConfirm.targetName, 44)}
|
||||
{truncateNameMiddle(renameConfirm.targetName)}
|
||||
</b>
|
||||
.
|
||||
</>
|
||||
|
||||
+43
-16
@@ -10,20 +10,15 @@ import DownloadIcon from 'remixicon-react/Download2LineIcon'
|
||||
import { FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
|
||||
import { Context as FMContext } from '../../../../../providers/FileManager'
|
||||
import { capitalizeFirstLetter, formatBytes, indexStrToBigint } from '../../../utils/common'
|
||||
import { capitalizeFirstLetter, formatBytes, indexStrToBigint, truncateNameMiddle } from '../../../utils/common'
|
||||
import { startDownloadingQueue } from '../../../utils/download'
|
||||
import { ActionTag, DownloadProgress, TrackDownloadProps } from '../../../constants/transfers'
|
||||
import { Context as SettingsContext } from '../../../../../providers/Settings'
|
||||
import { useContextMenu } from '../../../hooks/useContextMenu'
|
||||
|
||||
export const truncateNameMiddle = (s: string, max = 42): string => {
|
||||
const str = String(s)
|
||||
|
||||
if (str.length <= max) return str
|
||||
const half = Math.floor((max - 1) / 2)
|
||||
|
||||
return `${str.slice(0, half)}…${str.slice(-half)}`
|
||||
}
|
||||
import { uuidV4 } from '../../../../../utils'
|
||||
import { ConfirmModal } from '../../ConfirmModal/ConfirmModal'
|
||||
import { Tooltip } from '../../Tooltip/Tooltip'
|
||||
import { TOOLTIPS } from '../../../constants/tooltips'
|
||||
|
||||
interface VersionListProps {
|
||||
versions: FileInfo[]
|
||||
@@ -322,11 +317,11 @@ const RowFull = memo(
|
||||
<div className="vh-rename" title={`Restoring will rename: “${headFi.name}” → “${item.name}”`}>
|
||||
Restoring will rename{' '}
|
||||
<b className="vh-name" title={headFi.name}>
|
||||
{truncateNameMiddle(headFi.name, 44)}
|
||||
{truncateNameMiddle(headFi.name)}
|
||||
</b>{' '}
|
||||
→{' '}
|
||||
<b className="vh-name" title={item.name}>
|
||||
{truncateNameMiddle(item.name, 44)}
|
||||
{truncateNameMiddle(item.name)}
|
||||
</b>
|
||||
</div>
|
||||
)}
|
||||
@@ -401,9 +396,10 @@ VersionRow.displayName = 'VersionRow'
|
||||
export function VersionsList({ versions, headFi, restoreVersion, onDownload }: VersionListProps) {
|
||||
const { handleCloseContext } = useContextMenu<HTMLDivElement>()
|
||||
|
||||
const { fm } = useContext(FMContext)
|
||||
const { fm, drives, currentDrive } = useContext(FMContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
const [confirmRestore, setConfirmRestore] = useState<FileInfo | null>(null)
|
||||
|
||||
const toggle = useCallback(
|
||||
(key: string, fi: FileInfo) => {
|
||||
@@ -433,15 +429,20 @@ export function VersionsList({ versions, headFi, restoreVersion, onDownload }: V
|
||||
if (!fm || !beeApi) return
|
||||
const rawSize = fileInfo.customMetadata?.size
|
||||
const expectedSize = rawSize ? Number(rawSize) : undefined
|
||||
const driveName = drives.find(d => d.id.toString() === fileInfo.driveId.toString())?.name ?? currentDrive?.name
|
||||
await startDownloadingQueue(
|
||||
fm,
|
||||
[fileInfo],
|
||||
[onDownload({ name: fileInfo.name, size: formatBytes(rawSize), expectedSize })],
|
||||
[onDownload({ uuid: uuidV4(), name: fileInfo.name, size: formatBytes(rawSize), expectedSize, driveName })],
|
||||
)
|
||||
},
|
||||
[handleCloseContext, fm, beeApi, onDownload],
|
||||
[handleCloseContext, fm, beeApi, onDownload, drives, currentDrive],
|
||||
)
|
||||
|
||||
const handleRestoreClick = useCallback((fileInfo: FileInfo) => {
|
||||
setConfirmRestore(fileInfo)
|
||||
}, [])
|
||||
|
||||
if (!versions.length || !fm) return null
|
||||
|
||||
return (
|
||||
@@ -466,12 +467,38 @@ export function VersionsList({ versions, headFi, restoreVersion, onDownload }: V
|
||||
headFi={headFi}
|
||||
isCurrent={Boolean(isCurrent)}
|
||||
fmDownload={() => handleDownload(item)}
|
||||
onRestore={restoreVersion}
|
||||
onRestore={handleRestoreClick}
|
||||
collapsed={collapsed}
|
||||
onToggle={() => toggle(key, item)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
{confirmRestore && (
|
||||
<ConfirmModal
|
||||
title={
|
||||
<>
|
||||
Restore this version?
|
||||
<Tooltip label={TOOLTIPS.FILE_OPERATION_RESTORE_VERSION} />
|
||||
</>
|
||||
}
|
||||
message={
|
||||
<>
|
||||
This will restore <b title={confirmRestore.name}>{truncateNameMiddle(confirmRestore.name)}</b> to version{' '}
|
||||
{indexStrToBigint(confirmRestore.version)?.toString()}.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Restore"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={async () => {
|
||||
await restoreVersion(confirmRestore)
|
||||
setConfirmRestore(null)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setConfirmRestore(null)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user