* feat: add file manager module - Complete file manager implementation with UI/UX - Add drive management functionality - Add file upload/download with progress tracking - Add stamp integration and handling - Add bulk operations and context menus Co-authored-by: Roland Seres <roland.seres90@gmail.com> Co-authored-by: nidishk <nidishkrishnan45@gmail.com>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
.fm-admin-status-bar-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgb(33, 33, 33);
|
||||
height: 60px;
|
||||
padding: 10px 16px;
|
||||
|
||||
&.is-loading {
|
||||
filter: blur(1px);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-admin-status-bar-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
color: rgb(229, 231, 235);
|
||||
}
|
||||
|
||||
.fm-admin-status-bar-upgrade-button {
|
||||
padding: 6px;
|
||||
background-color: rgb(237, 237, 237);
|
||||
border-radius: 0;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&[aria-disabled='true'] {
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-admin-status-bar-loader {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background: linear-gradient(90deg, transparent, rgba(0, 0, 0, 0.04), transparent);
|
||||
animation: fmShimmer 1.2s infinite;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@keyframes fmShimmer {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-admin-status-bar-progress-pill-container {
|
||||
position: absolute;
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.fm-admin-status-progress-pill {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
|
||||
padding: 6px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
|
||||
background: rgb(255, 255, 255);
|
||||
color: #000000;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 0 8px rgba(0, 0, 0, 0.25);
|
||||
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
z-index: 999;
|
||||
|
||||
&:hover {
|
||||
background: rgb(221, 221, 221);
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '›';
|
||||
font-size: 14px;
|
||||
opacity: 0.7;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
transform: translateX(2px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-admin-status-bar-container.is-loading .fm-admin-status-progress-pill {
|
||||
filter: none;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { ReactElement, useState, useMemo, useEffect, useRef, useContext } 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 { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
|
||||
|
||||
interface AdminStatusBarProps {
|
||||
adminStamp: PostageBatch | null
|
||||
adminDrive: DriveInfo | null
|
||||
loading: boolean
|
||||
isCreationInProgress: boolean
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function AdminStatusBar({
|
||||
adminStamp,
|
||||
adminDrive,
|
||||
loading,
|
||||
isCreationInProgress,
|
||||
setErrorMessage,
|
||||
}: AdminStatusBarProps): ReactElement {
|
||||
const { setShowError, refreshStamp } = useContext(FMContext)
|
||||
|
||||
const [isUpgradeDriveModalOpen, setIsUpgradeDriveModalOpen] = 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
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setShowProgressModal(isCreationInProgress || loading)
|
||||
}, [isCreationInProgress, loading, setShowProgressModal])
|
||||
|
||||
useEffect(() => {
|
||||
setActualStamp(adminStamp)
|
||||
}, [adminStamp, setActualStamp])
|
||||
|
||||
useEffect(() => {
|
||||
if (!adminDrive) return
|
||||
|
||||
const id = adminDrive.id.toString()
|
||||
const batchId = adminStamp?.batchID.toString() || ''
|
||||
|
||||
const onStart = (e: Event) => {
|
||||
const { driveId } = (e as CustomEvent).detail || {}
|
||||
|
||||
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 && batchId) {
|
||||
setIsUpgrading(false)
|
||||
|
||||
const upgradedStamp = await refreshStamp(batchId)
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
if (upgradedStamp) {
|
||||
setActualStamp(upgradedStamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||
window.addEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||
window.removeEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||
}
|
||||
}, [adminDrive, adminStamp?.batchID, setErrorMessage, setShowError, refreshStamp, setIsUpgrading])
|
||||
|
||||
const { capacityPct, usedSize, totalSize } = useMemo(
|
||||
() => calculateStampCapacityMetrics(actualStamp, adminDrive),
|
||||
[actualStamp, adminDrive],
|
||||
)
|
||||
|
||||
const expiresAt = useMemo(
|
||||
() => (actualStamp ? actualStamp.duration.toEndDate().toLocaleDateString() : '—'),
|
||||
[actualStamp],
|
||||
)
|
||||
|
||||
const isBusy = loading || isUpgrading || isCreationInProgress
|
||||
const blurCls = isBusy ? ' is-loading' : ''
|
||||
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>
|
||||
|
||||
{isUpgradeDriveModalOpen && actualStamp && adminDrive && (
|
||||
<UpgradeDriveModal
|
||||
stamp={actualStamp}
|
||||
drive={adminDrive}
|
||||
onCancelClick={() => setIsUpgradeDriveModalOpen(false)}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="fm-admin-status-bar-upgrade-button"
|
||||
onClick={() => !isBusy && actualStamp && adminDrive && setIsUpgradeDriveModalOpen(true)}
|
||||
aria-disabled={isBusy ? 'true' : 'false'}
|
||||
>
|
||||
{isBusy ? 'Working…' : 'Manage'}
|
||||
</div>
|
||||
|
||||
{isUpgrading && (
|
||||
<div className="fm-drive-item-creating-overlay" aria-live="polite">
|
||||
<div className="fm-mini-spinner" />
|
||||
<span>Upgrading admin drive…</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showProgressModal && (
|
||||
<ConfirmModal
|
||||
title="Admin Drive Creation"
|
||||
isProgress
|
||||
spinnerMessage={statusText}
|
||||
showFooter={false}
|
||||
showMinimize={true}
|
||||
onMinimize={() => setShowProgressModal(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!showProgressModal && (loading || isCreationInProgress) && (
|
||||
<div className="fm-admin-status-bar-progress-pill-container">
|
||||
<div className="fm-admin-status-progress-pill" onClick={() => setShowProgressModal(true)}>
|
||||
{statusText}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
.fm-button {
|
||||
border-radius: 0px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.fm-button-primary {
|
||||
background-color: rgb(237, 129, 49);
|
||||
color: white;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-button-secondary {
|
||||
background-color: rgb(255, 255, 255);
|
||||
color: rgb(55, 65, 81);
|
||||
border: 1px solid rgb(209, 213, 219);
|
||||
|
||||
&:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-button-danger {
|
||||
background-color: #dc2626;
|
||||
color: white;
|
||||
border: 1px solid #dc2626;
|
||||
|
||||
&:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-button-small {
|
||||
font-size: 10px;
|
||||
padding: 2px 3px;
|
||||
}
|
||||
|
||||
.fm-button-medium {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fm-button-disabled {
|
||||
background-color: rgb(156, 163, 175);
|
||||
border: 1px solid rgb(156, 163, 175);
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-button-icon {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { ReactElement } from 'react'
|
||||
import './Button.scss'
|
||||
|
||||
interface ButtonProps {
|
||||
label: string
|
||||
onClick?: () => void
|
||||
icon?: ReactElement
|
||||
size?: 'small' | 'medium'
|
||||
variant?: 'primary' | 'secondary' | 'danger'
|
||||
disabled?: boolean
|
||||
width?: number
|
||||
}
|
||||
|
||||
export function Button({
|
||||
label,
|
||||
onClick,
|
||||
icon,
|
||||
size = 'medium',
|
||||
variant = 'primary',
|
||||
disabled,
|
||||
width,
|
||||
}: ButtonProps): ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`fm-button fm-button-${variant} fm-button-${size}${icon ? ' fm-button-icon' : ''}${
|
||||
disabled ? ' fm-button-disabled' : ''
|
||||
}`}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
style={{ width: width ? `${width}px` : undefined }}
|
||||
>
|
||||
{icon} {label}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
.fm-modal-container .fm-modal-window {
|
||||
width: min(560px, 92vw);
|
||||
}
|
||||
|
||||
.fm-modal-container .fm-modal-window-header {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fm-modal-container .fm-modal-window-body .fm-modal-white-section {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
word-break: break-word;
|
||||
padding: 12px 14px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.fm-modal-container .fm-modal-window-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.fm-modal-container .fm-modal-window {
|
||||
width: 94vw;
|
||||
}
|
||||
.fm-modal-container .fm-modal-window-body .fm-modal-white-section {
|
||||
max-height: 56vh;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-modal-no-background {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.fm-spinner-message {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ReactElement } from 'react'
|
||||
import '../../styles/global.scss'
|
||||
import './ConfirmModal.scss'
|
||||
import { Button } from '../Button/Button'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
interface ConfirmModalProps {
|
||||
title?: string
|
||||
message?: React.ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
onConfirm?: () => void | Promise<void>
|
||||
onCancel?: () => void
|
||||
showFooter?: boolean
|
||||
isProgress?: boolean
|
||||
spinnerMessage?: string
|
||||
showMinimize?: boolean
|
||||
onMinimize?: () => void
|
||||
background?: boolean
|
||||
}
|
||||
|
||||
export function ConfirmModal({
|
||||
title = 'Are you sure?',
|
||||
message,
|
||||
confirmLabel = 'Confirm',
|
||||
cancelLabel = 'Cancel',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
showFooter = true,
|
||||
isProgress = false,
|
||||
spinnerMessage,
|
||||
showMinimize = true,
|
||||
onMinimize,
|
||||
background = true,
|
||||
}: ConfirmModalProps): ReactElement {
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
|
||||
return createPortal(
|
||||
<div className={`fm-modal-container fm-confirm-modal ${background ? '' : 'fm-modal-no-background'}`}>
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header">{title}</div>
|
||||
|
||||
<div className="fm-modal-window-body">
|
||||
{isProgress ? (
|
||||
<div className="fm-spinner-center">
|
||||
<div className="fm-spinner-message">
|
||||
<div>{spinnerMessage || 'Working…'}</div>
|
||||
<div className="fm-mini-spinner" />
|
||||
</div>
|
||||
{showMinimize && <Button label="Minimize" variant="secondary" onClick={onMinimize} />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="fm-modal-white-section">{message}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showFooter && (onCancel || onConfirm) && (
|
||||
<div className="fm-modal-window-footer">
|
||||
{onCancel && <Button label={cancelLabel} variant="secondary" onClick={onCancel} />}
|
||||
{onConfirm && <Button label={confirmLabel} variant="primary" onClick={() => onConfirm()} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.fm-context-menu {
|
||||
background: white;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 0px;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactElement } from 'react'
|
||||
|
||||
import './ContextMenu.scss'
|
||||
|
||||
interface ContextMenuProps {
|
||||
children?: ReactElement | ReactElement[]
|
||||
}
|
||||
|
||||
export function ContextMenu({ children }: ContextMenuProps): ReactElement {
|
||||
return <div className="fm-context-menu">{children}</div>
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
.fm-create-drive-modal-container {
|
||||
position: absolute;
|
||||
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: #000c;
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-create-drive-modal {
|
||||
width: 450px;
|
||||
padding: 24px;
|
||||
background-color: rgb(255, 255, 255);
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-create-drive-modal-header {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: start;
|
||||
font-size: 18px;
|
||||
color: rgb(237, 129, 49);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fm-create-drive-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
border: 1px solid rgb(229, 231, 235);
|
||||
padding: 24px;
|
||||
background-color: rgb(249, 250, 251);
|
||||
}
|
||||
|
||||
.fm-create-drive-modal-input-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: rgb(55, 65, 81);
|
||||
|
||||
& input {
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
font-family: 'iAWriterQuattroV';
|
||||
border: 1px solid rgb(209, 213, 219);
|
||||
|
||||
line-height: 21px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
& input::placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
& input:focus {
|
||||
border: 1px solid rgb(237, 129, 49) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-create-drive-modal-footer {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fm-modal-info-note {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import { ReactElement, useContext, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { BZZ, Duration, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js'
|
||||
import './CreateDriveModal.scss'
|
||||
import { CustomDropdown } from '../CustomDropdown/CustomDropdown'
|
||||
import { Button } from '../Button/Button'
|
||||
import { fmFetchCost, handleCreateDrive } from '../../utils/bee'
|
||||
import { getExpiryDateByLifetime } from '../../utils/common'
|
||||
import { erasureCodeMarks } from '../../constants/common'
|
||||
import { desiredLifetimeOptions } from '../../constants/stamps'
|
||||
import { Context as BeeContext } from '../../../../providers/Bee'
|
||||
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||
import { FMSlider } from '../Slider/Slider'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import { getHumanReadableFileSize } from '../../../../utils/file'
|
||||
import { Tooltip } from '../Tooltip/Tooltip'
|
||||
import { TOOLTIPS } from '../../constants/tooltips'
|
||||
|
||||
const minMarkValue = Math.min(...erasureCodeMarks.map(mark => mark.value))
|
||||
const maxMarkValue = Math.max(...erasureCodeMarks.map(mark => mark.value))
|
||||
|
||||
interface CreateDriveModalProps {
|
||||
onCancelClick: () => void
|
||||
onDriveCreated: () => void
|
||||
onCreationStarted: () => void
|
||||
onCreationError: (name: string) => void
|
||||
}
|
||||
// TODO: select existing batch id or create a new one - just like in InitialModal
|
||||
export function CreateDriveModal({
|
||||
onCancelClick,
|
||||
onDriveCreated,
|
||||
onCreationStarted,
|
||||
onCreationError,
|
||||
}: CreateDriveModalProps): ReactElement {
|
||||
const [isCreateEnabled, setIsCreateEnabled] = useState(false)
|
||||
const [isBalanceSufficient, setIsBalanceSufficient] = useState(true)
|
||||
const [capacity, setCapacity] = useState(0)
|
||||
const [lifetimeIndex, setLifetimeIndex] = useState(-1)
|
||||
const [validityEndDate, setValidityEndDate] = useState(new Date())
|
||||
const [driveName, setDriveName] = useState('')
|
||||
const [capacityIndex, setCapacityIndex] = useState(-1)
|
||||
const [encryptionEnabled] = useState(false)
|
||||
const [erasureCodeLevel, setErasureCodeLevel] = useState(RedundancyLevel.OFF)
|
||||
const [cost, setCost] = useState('0')
|
||||
|
||||
const [sizeMarks, setSizeMarks] = useState<{ value: number; label: string }[]>([])
|
||||
const { walletBalance } = useContext(BeeContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { fm } = useContext(FMContext)
|
||||
const currentFetch = useRef<Promise<void> | null>(null)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCapacityChange = (value: number, index: number) => {
|
||||
setCapacityIndex(index)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const newSizes = Array.from(Utils.getStampEffectiveBytesBreakpoints(encryptionEnabled, erasureCodeLevel).values())
|
||||
|
||||
setSizeMarks(
|
||||
newSizes.map(size => ({
|
||||
value: size,
|
||||
label: getHumanReadableFileSize(size),
|
||||
})),
|
||||
)
|
||||
|
||||
setCapacity(newSizes[capacityIndex])
|
||||
}, [encryptionEnabled, erasureCodeLevel, capacityIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (capacity > 0 && validityEndDate.getTime() > new Date().getTime()) {
|
||||
fmFetchCost(
|
||||
capacity,
|
||||
validityEndDate,
|
||||
false,
|
||||
erasureCodeLevel,
|
||||
beeApi,
|
||||
(cost: BZZ) => {
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
setIsBalanceSufficient(true)
|
||||
|
||||
if ((walletBalance && cost.gte(walletBalance.bzzBalance)) || !walletBalance) {
|
||||
setIsBalanceSufficient(false)
|
||||
}
|
||||
setCost(cost.toSignificantDigits(2))
|
||||
},
|
||||
currentFetch,
|
||||
)
|
||||
|
||||
if (driveName && driveName.trim().length > 0) {
|
||||
setIsCreateEnabled(true)
|
||||
} else {
|
||||
setIsCreateEnabled(false)
|
||||
}
|
||||
} else {
|
||||
setCost('0')
|
||||
setIsCreateEnabled(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [capacity, validityEndDate, beeApi, driveName, walletBalance])
|
||||
|
||||
useEffect(() => {
|
||||
setValidityEndDate(getExpiryDateByLifetime(lifetimeIndex))
|
||||
}, [lifetimeIndex])
|
||||
|
||||
return (
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header">Create new drive</div>
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="drive-name" className="fm-input-label">
|
||||
Drive name: <Tooltip label={TOOLTIPS.DRIVE_NAME} />
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="drive-name"
|
||||
placeholder="My important files"
|
||||
value={driveName}
|
||||
onChange={e => setDriveName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="drive-initial-capacity" className="fm-input-label">
|
||||
Initial capacity: <Tooltip label={TOOLTIPS.DRIVE_INITIAL_CAPACITY} />
|
||||
</label>
|
||||
<CustomDropdown
|
||||
id="drive-initial-capacity"
|
||||
options={sizeMarks}
|
||||
value={capacity}
|
||||
onChange={handleCapacityChange}
|
||||
placeholder="Select a value"
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-modal-info-warning">
|
||||
Drive sizes shown above are system-calculated based on your current stamp configuration
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="drive-desired-lifetime" className="fm-input-label">
|
||||
Desired lifetime: <Tooltip label={TOOLTIPS.DRIVE_DESIRED_LIFETIME} />
|
||||
</label>
|
||||
<CustomDropdown
|
||||
id="drive-desired-lifetime"
|
||||
options={desiredLifetimeOptions}
|
||||
value={lifetimeIndex}
|
||||
onChange={setLifetimeIndex}
|
||||
placeholder="Select a value"
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="drive-security-level" className="fm-input-label">
|
||||
Security Level <Tooltip label={TOOLTIPS.DRIVE_SECURITY_LEVEL} />
|
||||
</label>
|
||||
<FMSlider
|
||||
id="drive-security-level"
|
||||
defaultValue={0}
|
||||
marks={erasureCodeMarks}
|
||||
onChange={value => setErasureCodeLevel(value)}
|
||||
minValue={minMarkValue}
|
||||
maxValue={maxMarkValue}
|
||||
step={1}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="fm-modal-estimated-cost-container">
|
||||
<div className="fm-emphasized-text">Estimated Cost:</div>
|
||||
<div>
|
||||
{cost} BZZ {isBalanceSufficient ? '' : '(Insufficient balance)'}
|
||||
</div>
|
||||
|
||||
<Tooltip label={TOOLTIPS.DRIVE_ESTIMATED_COST} bottomTooltip={true} />
|
||||
</div>
|
||||
<div>(Based on current network conditions)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-modal-window-footer">
|
||||
<Button
|
||||
label="Create drive"
|
||||
variant="primary"
|
||||
disabled={!isCreateEnabled || !isBalanceSufficient}
|
||||
onClick={async () => {
|
||||
if (isCreateEnabled && fm && beeApi && walletBalance) {
|
||||
onCreationStarted()
|
||||
onCancelClick()
|
||||
|
||||
await handleCreateDrive(
|
||||
beeApi,
|
||||
fm,
|
||||
Size.fromBytes(capacity),
|
||||
Duration.fromEndDate(validityEndDate),
|
||||
driveName,
|
||||
encryptionEnabled,
|
||||
erasureCodeLevel,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
() => onDriveCreated(), // onSuccess
|
||||
() => onCreationError(driveName), // onError
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
.fm-custom-dropdown {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
|
||||
.fm-custom-dropdown-selected {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid rgb(209, 213, 219);
|
||||
border-radius: 0px;
|
||||
background: #fff;
|
||||
color: rgb(55, 65, 81);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: border-color 0.2s;
|
||||
outline: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 40px;
|
||||
|
||||
.placeholder {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
& .arrow {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) rotate(0deg);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
pointer-events: none;
|
||||
display: inline-block;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
&.open .arrow {
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&.open {
|
||||
border-color: rgb(237, 129, 49);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-custom-dropdown-list {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: calc(100% + 4px);
|
||||
background: #fff;
|
||||
border: 1px solid rgb(209, 213, 219);
|
||||
border-radius: 0px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
z-index: 20;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
list-style: none;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
animation: fadeIn 0.15s;
|
||||
|
||||
li {
|
||||
padding: 10px 16px;
|
||||
cursor: pointer;
|
||||
color: rgb(55, 65, 81);
|
||||
transition: background 0.15s;
|
||||
&:hover,
|
||||
&.selected {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import './CustomDropdown.scss'
|
||||
import ArrowDropdown from 'remixicon-react/ArrowDropDownLineIcon'
|
||||
import { useClickOutside } from '../../hooks/useClickOutside'
|
||||
import { Tooltip } from '../Tooltip/Tooltip'
|
||||
|
||||
interface Option {
|
||||
value: number
|
||||
label: string
|
||||
}
|
||||
|
||||
interface CustomDropdownProps {
|
||||
options: Option[]
|
||||
value: number
|
||||
onChange: (value: number, index: number) => void
|
||||
placeholder?: string
|
||||
id?: string
|
||||
label?: string
|
||||
icon?: React.ReactNode
|
||||
infoText?: string
|
||||
}
|
||||
|
||||
export function CustomDropdown({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
id,
|
||||
label,
|
||||
icon,
|
||||
infoText,
|
||||
}: CustomDropdownProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useClickOutside(ref, () => setOpen(false), open)
|
||||
|
||||
const selectedLabel = options.find(opt => opt.value === value)?.label || ''
|
||||
|
||||
return (
|
||||
<div className="fm-custom-dropdown" ref={ref}>
|
||||
{label && (
|
||||
<label htmlFor={id} className="fm-input-label">
|
||||
{icon} {label} {infoText && <Tooltip label={infoText ? infoText : ''} iconSize="14px" />}
|
||||
</label>
|
||||
)}
|
||||
<div
|
||||
className={`fm-custom-dropdown-selected${open ? ' open' : ''}`}
|
||||
id={id}
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
>
|
||||
{selectedLabel || <span className="placeholder">{placeholder} </span>}
|
||||
|
||||
<ArrowDropdown className="arrow" />
|
||||
</div>
|
||||
{open && (
|
||||
<ul className="fm-custom-dropdown-list">
|
||||
{options.map((opt, index) => (
|
||||
<li
|
||||
key={opt.value}
|
||||
className={opt.value === value ? 'selected' : ''}
|
||||
onClick={() => {
|
||||
onChange(opt.value, index)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.fm-delete-file-modal {
|
||||
width: 510px;
|
||||
}
|
||||
|
||||
.fm-delete-file-modal-list {
|
||||
margin: 0 0 12px 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fm-delete-file-modal-list-item {
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { ReactElement, useState } from 'react'
|
||||
import './DeleteFileModal.scss'
|
||||
import { Button } from '../Button/Button'
|
||||
import { createPortal } from 'react-dom'
|
||||
import TrashIcon from 'remixicon-react/DeleteBin6LineIcon'
|
||||
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 { FileAction } from '../../constants/transfers'
|
||||
|
||||
interface DeleteFileModalProps {
|
||||
name?: string
|
||||
names?: string[]
|
||||
currentDriveName?: string
|
||||
onCancelClick: () => void
|
||||
onProceed: (action: FileAction) => void
|
||||
}
|
||||
|
||||
export function DeleteFileModal({
|
||||
name,
|
||||
names,
|
||||
currentDriveName,
|
||||
onCancelClick,
|
||||
onProceed,
|
||||
}: DeleteFileModalProps): ReactElement {
|
||||
const [value, setValue] = useState<FileAction>(FileAction.Trash)
|
||||
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
const isBulk = Array.isArray(names) && names.length > 0
|
||||
const count = isBulk ? names.length : 1
|
||||
const headerText = isBulk ? `Delete ${count} file${count > 1 ? 's' : ''}?` : `Delete ${name}?`
|
||||
const subjectNoun = isBulk ? 'selected file(s)' : 'this file'
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window fm-delete-file-modal">
|
||||
<div className="fm-modal-window-header">
|
||||
<TrashIcon /> <span className="fm-main-font-color">{headerText}</span>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-body">
|
||||
{isBulk && (
|
||||
<ul className="fm-delete-file-modal-list">
|
||||
{names.map(n => (
|
||||
<li key={n} className="fm-delete-file-modal-list-item" title={n}>
|
||||
{n}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<FormControl component="fieldset">
|
||||
<div className="fm-radio-group">
|
||||
<div className="fm-form-control-label">
|
||||
<FormControlLabel
|
||||
value={FileAction.Trash}
|
||||
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 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.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fm-form-control-label">
|
||||
<FormControlLabel
|
||||
value={FileAction.Forget}
|
||||
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 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.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fm-form-control-label">
|
||||
<FormControlLabel
|
||||
value={FileAction.Destroy}
|
||||
control={
|
||||
<Radio checked={value === FileAction.Destroy} onChange={() => setValue(FileAction.Destroy)} />
|
||||
}
|
||||
label={
|
||||
<div className="fm-radio-label">
|
||||
<div className="fm-radio-label-header fm-main-font-color fm-line-height-fit">
|
||||
Destroy entire drive {currentDriveName ? `‘${currentDriveName}’` : ''} to delete this{' '}
|
||||
{subjectNoun}
|
||||
</div>
|
||||
<div className="fm-red-font" onClick={e => e.preventDefault()}>
|
||||
<AlertIcon size="14px" className="fm-alert-icon-inline" />
|
||||
Warning: This will make all files on this drive inaccessible. This action is irreversible.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-footer">
|
||||
<Button label="Proceed" variant="primary" onClick={() => onProceed(value)} />
|
||||
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.fm-modal-body-destroy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ReactElement, useState } from 'react'
|
||||
import '../../styles/global.scss'
|
||||
import './DestroyDriveModal.scss'
|
||||
import { Button } from '../Button/Button'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
|
||||
interface DestroyDriveModalProps {
|
||||
drive: DriveInfo
|
||||
onCancelClick: () => void
|
||||
doDestroy: () => void | Promise<void>
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header fm-red-font">Destroy entire drive</div>
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-body-destroy">
|
||||
<div className="fm-emphasized-text">Destroy Drive? This Action Is Permanent</div>
|
||||
<div>All files stored only on this drive will become inaccessible.</div>
|
||||
<div>
|
||||
While the data may still temporarily persist on Swarm, it will be permanently removed once the storage
|
||||
expires and the data is garbage collected by the network. The File Manager will no longer recognise or
|
||||
recover these files.
|
||||
</div>
|
||||
<div>Confirmation:</div>
|
||||
<div>Requires typing a fixed expression to prevent accidental deletion. This action cannot be undone.</div>
|
||||
<div>
|
||||
Type: <span className="fm-emphasized-text">{destroyDriveText}</span>
|
||||
</div>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<input
|
||||
type="text"
|
||||
id="drive-name"
|
||||
placeholder={destroyDriveText}
|
||||
value={driveNameInput}
|
||||
onChange={e => setDriveNameInput(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-modal-window-footer">
|
||||
<Button
|
||||
label="Destroy entire drive"
|
||||
variant="danger"
|
||||
disabled={destroyDriveText !== driveNameInput}
|
||||
onClick={() => doDestroy()}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
.fm-error-modal-container {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1500;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fm-error-modal-message {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fm-error-modal-button-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: right;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { ReactElement } from 'react'
|
||||
import './ErrorModal.scss'
|
||||
import { Button } from '../Button/Button'
|
||||
|
||||
interface ErrorModalProps {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export function ErrorModal({ label, onClick }: ErrorModalProps): ReactElement {
|
||||
return (
|
||||
<div className="fm-error-modal-container">
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-error-modal-message">{label}</div>
|
||||
<div className="fm-error-modal-button-container">
|
||||
<Button variant="primary" label="OK" width={100} onClick={onClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
.fm-expiring-notification-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fm-expiring-notification-modal-section-left {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fm-expiring-notification-modal-section-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.fm-expiring-notification-modal-section-left-header {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.fm-expiring-notification-modal-section-right-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-expiring-notification-modal-section-right-button {
|
||||
margin-top: 20px;
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.fm-expiring-notification-modal-footer-one-button {
|
||||
width: 33%;
|
||||
}
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
import { ReactElement, useState, useMemo, useEffect } from 'react'
|
||||
import { Warning } from '@material-ui/icons'
|
||||
import './ExpiringNotificationModal.scss'
|
||||
import '../../styles/global.scss'
|
||||
|
||||
import { Button } from '../Button/Button'
|
||||
import { createPortal } from 'react-dom'
|
||||
import DriveIcon from 'remixicon-react/HardDrive2LineIcon'
|
||||
import CalendarIcon from 'remixicon-react/CalendarLineIcon'
|
||||
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'
|
||||
|
||||
const EXPIRING_ITEMS_PAGE_SIZE = 3
|
||||
|
||||
interface ExpiringNotificationModalProps {
|
||||
stamps: PostageBatch[]
|
||||
drives: DriveInfo[]
|
||||
onCancelClick: () => void
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function ExpiringNotificationModal({
|
||||
stamps,
|
||||
drives,
|
||||
onCancelClick,
|
||||
setErrorMessage,
|
||||
}: ExpiringNotificationModalProps): ReactElement {
|
||||
const [showUpgradeDriveModal, setShowUpgradeDriveModal] = useState(false)
|
||||
const [actualStamp, setActualStamp] = useState<PostageBatch | undefined>(undefined)
|
||||
const [actualDrive, setActualDrive] = useState<DriveInfo | undefined>(undefined)
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
|
||||
const sortedStamps = useMemo(() => {
|
||||
return [...stamps].sort((a, b) => {
|
||||
const daysLeftA = getDaysLeft(a.duration.toEndDate())
|
||||
const daysLeftB = getDaysLeft(b.duration.toEndDate())
|
||||
|
||||
return daysLeftA - daysLeftB
|
||||
})
|
||||
}, [stamps])
|
||||
|
||||
const totalPages = Math.ceil(sortedStamps.length / EXPIRING_ITEMS_PAGE_SIZE)
|
||||
const startIndex = currentPage * EXPIRING_ITEMS_PAGE_SIZE
|
||||
const paginatedStamps = sortedStamps.slice(startIndex, startIndex + EXPIRING_ITEMS_PAGE_SIZE)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(0)
|
||||
}, [stamps])
|
||||
|
||||
if (stamps.length === 0) return <></>
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window fm-upgrade-drive-modal">
|
||||
<div className="fm-modal-window-header fm-red-font">
|
||||
<AlertIcon size="21px" /> Drives Expiring soon
|
||||
</div>
|
||||
<div>The following drives will expire soon. Extend them to keep your data accessible.</div>
|
||||
|
||||
<div className="fm-modal-window-body fm-expiring-notification-modal-body">
|
||||
{paginatedStamps.map((stamp, index) => {
|
||||
const daysLeft = getDaysLeft(stamp.duration.toEndDate())
|
||||
let daysClass = ''
|
||||
|
||||
const drive = drives.find(d => d.batchId.toString() === stamp.batchID.toString())
|
||||
|
||||
if (!drive) return null
|
||||
|
||||
if (daysLeft < 10) {
|
||||
daysClass = 'fm-red-font'
|
||||
} else if (daysLeft < 30) {
|
||||
daysClass = 'fm-swarm-orange-font'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${stamp.batchID.toString()}-${currentPage}-${index}`}
|
||||
className="fm-modal-white-section fm-space-between"
|
||||
>
|
||||
<div className="fm-expiring-notification-modal-section-left fm-space-between">
|
||||
<DriveIcon size="20" color="rgb(237, 129, 49)" />
|
||||
<div>
|
||||
<div className="fm-expiring-notification-modal-section-left-header fm-emphasized-text">
|
||||
{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()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-expiring-notification-modal-section-right">
|
||||
<div className="fm-expiring-notification-modal-section-right-header">
|
||||
<CalendarIcon size="14" /> Expiry date: {stamp.duration.toEndDate().toLocaleDateString()}
|
||||
</div>
|
||||
<div className={daysClass}>{daysLeft} days left</div>
|
||||
<div className="fm-expiring-notification-modal-section-right-button">
|
||||
<Button
|
||||
label="Upgrade"
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setActualStamp(stamp)
|
||||
setActualDrive(drive)
|
||||
setShowUpgradeDriveModal(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="fm-modal-window-footer">
|
||||
<div className="fm-expiring-notification-modal-footer-one-button">
|
||||
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
|
||||
<span>
|
||||
Page {currentPage + 1} / {totalPages} · total {sortedStamps.length}
|
||||
</span>
|
||||
{currentPage > 0 && (
|
||||
<Button label="Previous" variant="secondary" onClick={() => setCurrentPage(prev => prev - 1)} />
|
||||
)}
|
||||
{currentPage + 1 < totalPages && (
|
||||
<Button label="Next" variant="primary" onClick={() => setCurrentPage(prev => prev + 1)} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showUpgradeDriveModal && actualStamp && actualDrive && (
|
||||
<UpgradeDriveModal
|
||||
stamp={actualStamp}
|
||||
onCancelClick={onCancelClick}
|
||||
containerColor="none"
|
||||
drive={actualDrive}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
.fm-file-browser-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: calc(100vh - 120px);
|
||||
background-color: white;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-file-browser-content {
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.fm-file-browser-content-header {
|
||||
display: grid;
|
||||
padding: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid rgb(226, 232, 240);
|
||||
}
|
||||
|
||||
.fm-file-browser-content[data-search-mode='false'] .fm-file-browser-content-header {
|
||||
grid-template-columns: 32px 2fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.fm-file-browser-content[data-search-mode='true'] .fm-file-browser-content-header {
|
||||
grid-template-columns: 32px 2fr 1.1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.fm-file-browser-content-header-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
gap: 8px;
|
||||
font-weight: 700;
|
||||
|
||||
& input {
|
||||
margin: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: rgb(237, 129, 49);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-file-browser-content-header-item.fm-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fm-file-browser-content-header-item-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: rgb(237, 129, 49);
|
||||
}
|
||||
|
||||
.fm-file-browser-content-body {
|
||||
flex: 1 1 auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-file-browser-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
max-height: 45px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid #929292;
|
||||
background-color: #ededed;
|
||||
}
|
||||
|
||||
.fm-file-browser-footer > * {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.fm-file-browser-footer > :nth-child(1) {
|
||||
margin-right: 8px;
|
||||
}
|
||||
.fm-file-browser-footer > :nth-child(3) {
|
||||
margin-left: auto;
|
||||
}
|
||||
.fm-file-browser-footer {
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
.fm-file-browser-context-menu {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
}
|
||||
.fm-file-browser-context-menu[data-drop='up'] {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
.fm-file-browser-context-menu[data-drop='up'] .caret {
|
||||
transform: rotate(180deg);
|
||||
bottom: -6px;
|
||||
top: auto;
|
||||
}
|
||||
|
||||
.fm-context-item {
|
||||
margin: 4px;
|
||||
padding: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: #d1d1d1;
|
||||
cursor: pointer;
|
||||
}
|
||||
&.red {
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-context-item[aria-disabled="true"] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fm-context-item-border {
|
||||
border-bottom: 1px solid #d1d1d1;
|
||||
}
|
||||
|
||||
.fm-upload-download-indicator {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.fm-drag-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1500;
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.fm-drag-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.fm-drop-hint {
|
||||
padding: 24px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.fm-context-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-info {
|
||||
font-weight: 600;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
border: 1px solid currentColor;
|
||||
opacity: .6;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.fm-info--inline {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-info--inline::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
left: calc(100% + 8px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
max-width: 280px;
|
||||
white-space: normal;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(17, 24, 39, 0.98);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
letter-spacing: 0.1px;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity .08s ease, visibility .08s ease;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.fm-info--inline::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: calc(100% + 2px);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
border: 6px solid transparent;
|
||||
border-right-color: rgba(17, 24, 39, 0.98);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity .08s ease, visibility .08s ease;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
.fm-info--inline:hover::after,
|
||||
.fm-info--inline:focus-visible::after,
|
||||
.fm-info--inline:hover::before,
|
||||
.fm-info--inline:focus-visible::before {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.fm-file-browser-context-menu {
|
||||
overflow: visible;
|
||||
}
|
||||
.fm-refresh-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 2000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fm-refresh-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fm-refresh-text {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.fm-file-browser-content-header-item {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fm-header-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px; /* space between sort button and the × bubble */
|
||||
}
|
||||
|
||||
.fm-header-button {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fm-header-button[data-dir='asc'] .fm-file-browser-content-header-item-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.fm-file-browser-content-header-item-icon.is-inactive {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.fm-sort-clear {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid currentColor;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.fm-sort-clear:hover,
|
||||
.fm-sort-clear:focus-visible {
|
||||
opacity: 1;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px rgba(237, 129, 49, 0.2);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(2px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import { ReactElement, useEffect, useLayoutEffect, useRef, useState, useContext } from 'react'
|
||||
import './FileBrowser.scss'
|
||||
import { FileBrowserHeader } from './FileBrowserHeader/FileBrowserHeader'
|
||||
import { FileBrowserContent } from './FileBrowserContent/FileBrowserContent'
|
||||
import { useContextMenu } from '../../hooks/useContextMenu'
|
||||
import { NotificationBar } from '../NotificationBar/NotificationBar'
|
||||
import { FileAction, FileTransferType, TransferStatus, ViewType } from '../../constants/transfers'
|
||||
import { FileProgressNotification } from '../FileProgressNotification/FileProgressNotification'
|
||||
import { useView } from '../../../../pages/filemanager/ViewContext'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import { useTransfers } from '../../hooks/useTransfers'
|
||||
import { useSearch } from '../../../../pages/filemanager/SearchContext'
|
||||
import { useFileFiltering } from '../../hooks/useFileFiltering'
|
||||
import { useDragAndDrop } from '../../hooks/useDragAndDrop'
|
||||
import { useBulkActions } from '../../hooks/useBulkActions'
|
||||
import { SortKey, SortDir, useSorting } from '../../hooks/useSorting'
|
||||
|
||||
import { Point, Dir, safeSetState } from '../../utils/common'
|
||||
import { computeContextMenuPosition } from '../../utils/ui'
|
||||
import { FileBrowserTopBar } from './FileBrowserTopBar/FileBrowserTopBar'
|
||||
import { handleDestroyDrive } from '../../utils/bee'
|
||||
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||
import { ErrorModal } from '../ErrorModal/ErrorModal'
|
||||
import { FileBrowserModals } from './FileBrowserModals'
|
||||
import { FileBrowserContextMenu } from './FileBrowserMenu/FileBrowserContextMenu'
|
||||
import { FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
|
||||
const extractFilesFromClipboardEvent = (e: React.ClipboardEvent): File[] => {
|
||||
const out: File[] = []
|
||||
const items = e.clipboardData?.items ?? []
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const it = items[i]
|
||||
|
||||
if (it.kind === 'file') {
|
||||
const f = it.getAsFile()
|
||||
|
||||
if (f) out.push(f)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
interface FileBrowserProps {
|
||||
errorMessage?: string
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function FileBrowser({ errorMessage, setErrorMessage }: FileBrowserProps): ReactElement {
|
||||
const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu<HTMLDivElement>()
|
||||
const { view, setActualItemView } = useView()
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { files, currentDrive, resync, drives, fm, showError, setShowError } = useContext(FMContext)
|
||||
const {
|
||||
uploadFiles,
|
||||
isUploading,
|
||||
uploadItems,
|
||||
isDownloading,
|
||||
downloadItems,
|
||||
trackDownload,
|
||||
conflictPortal,
|
||||
cancelOrDismissUpload,
|
||||
cancelOrDismissDownload,
|
||||
dismissAllUploads,
|
||||
dismissAllDownloads,
|
||||
} = useTransfers({ setErrorMessage })
|
||||
|
||||
const { query, scope, includeActive, includeTrashed } = useSearch()
|
||||
|
||||
const [safePos, setSafePos] = useState<Point>(pos)
|
||||
const [dropDir, setDropDir] = useState<Dir>(Dir.Down)
|
||||
|
||||
const legacyUploadRef = useRef<HTMLInputElement | null>(null)
|
||||
const contentRef = useRef<HTMLDivElement | null>(null)
|
||||
const bodyRef = useRef<HTMLDivElement | null>(null)
|
||||
const isMountedRef = useRef(true)
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
|
||||
const [showBulkDeleteModal, setShowBulkDeleteModal] = useState(false)
|
||||
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
|
||||
const [confirmBulkForget, setConfirmBulkForget] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [pendingCancelUpload, setPendingCancelUpload] = useState<string | null>(null)
|
||||
|
||||
const q = query.trim().toLowerCase()
|
||||
const isSearchMode = q.length > 0
|
||||
|
||||
const getDriveName = (fi: FileInfo): string => {
|
||||
const match = drives.find(d => d.id.toString() === fi.driveId.toString())
|
||||
|
||||
return match?.name ?? ''
|
||||
}
|
||||
|
||||
const openTopbarMenu = (anchorEl: HTMLElement) => {
|
||||
const r = anchorEl.getBoundingClientRect()
|
||||
const bodyRect = bodyRef.current?.getBoundingClientRect()
|
||||
const clickX = Math.round(r.right - 6)
|
||||
const minY = (bodyRect?.top ?? 0) + 8
|
||||
const clickY = Math.max(Math.round(r.bottom + 6), minY)
|
||||
const fakeEvt = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
preventDefault: () => {},
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
stopPropagation: () => {},
|
||||
clientX: clickX,
|
||||
clientY: clickY,
|
||||
} as React.MouseEvent<HTMLDivElement>
|
||||
handleContextMenu(fakeEvt)
|
||||
}
|
||||
|
||||
const { listToRender } = useFileFiltering({
|
||||
files,
|
||||
currentDrive: currentDrive || null,
|
||||
view,
|
||||
isSearchMode,
|
||||
query: q,
|
||||
scope,
|
||||
includeActive,
|
||||
includeTrashed,
|
||||
})
|
||||
|
||||
const { sorted, sort, toggle, reset } = useSorting(listToRender, {
|
||||
persist: false,
|
||||
defaultState: { key: SortKey.Timestamp, dir: SortDir.Desc },
|
||||
getDriveName,
|
||||
})
|
||||
|
||||
const bulk = useBulkActions({
|
||||
listToRender,
|
||||
trackDownload,
|
||||
})
|
||||
|
||||
const { isDragging, handleDragEnter, handleDragOver, handleDragLeave, handleDrop, handleOverlayDrop } =
|
||||
useDragAndDrop({
|
||||
onFilesDropped: uploadFiles,
|
||||
currentDrive: currentDrive || null,
|
||||
})
|
||||
|
||||
const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files
|
||||
|
||||
if (files && files.length > 0) {
|
||||
uploadFiles(files)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const onContextUploadFile = () => {
|
||||
const el = legacyUploadRef.current
|
||||
|
||||
if (!el) return
|
||||
|
||||
try {
|
||||
if (typeof (el as HTMLInputElement).showPicker === 'function') {
|
||||
;(el as HTMLInputElement).showPicker()
|
||||
} else {
|
||||
el.click()
|
||||
}
|
||||
} catch {
|
||||
el.click()
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => handleCloseContext())
|
||||
}
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
|
||||
const files = extractFilesFromClipboardEvent(e)
|
||||
|
||||
if (files.length === 0) return
|
||||
|
||||
e.preventDefault()
|
||||
uploadFiles(files)
|
||||
}
|
||||
|
||||
const handleFileBrowserContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const t = e.target as HTMLElement
|
||||
|
||||
if (t.closest('.fm-file-item-context-menu, .fm-file-browser-context-menu')) return
|
||||
|
||||
if (!e.shiftKey && t.closest('.fm-file-item-content')) return
|
||||
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleContextMenu(e)
|
||||
}
|
||||
|
||||
const handleDeleteModalProceed = async (action: FileAction) => {
|
||||
setShowBulkDeleteModal(false)
|
||||
|
||||
if (action === FileAction.Trash) {
|
||||
return await bulk.bulkTrash(bulk.selectedFiles)
|
||||
}
|
||||
|
||||
if (action === FileAction.Forget) {
|
||||
return setConfirmBulkForget(true)
|
||||
}
|
||||
|
||||
if (action === FileAction.Destroy) {
|
||||
return setShowDestroyDriveModal(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDestroyDriveConfirm = async () => {
|
||||
if (!currentDrive) return
|
||||
|
||||
setShowDestroyDriveModal(false)
|
||||
|
||||
await handleDestroyDrive(
|
||||
beeApi,
|
||||
fm,
|
||||
currentDrive,
|
||||
() => {
|
||||
setShowDestroyDriveModal(false)
|
||||
},
|
||||
e => {
|
||||
setErrorMessage?.(`Error destroying drive: ${currentDrive.name}: ${e}`)
|
||||
setShowError(true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const handleUploadClose = (name: string) => {
|
||||
const row = uploadItems.find(i => i.name === name)
|
||||
|
||||
if (row?.status === TransferStatus.Uploading) {
|
||||
setPendingCancelUpload(name)
|
||||
} else {
|
||||
cancelOrDismissUpload(name)
|
||||
}
|
||||
}
|
||||
|
||||
const updateContextMenuPosition = () => {
|
||||
const menu = contextRef.current
|
||||
const body = bodyRef.current
|
||||
|
||||
if (!menu) return
|
||||
|
||||
const rect = menu.getBoundingClientRect()
|
||||
const containerRect = body?.getBoundingClientRect() ?? null
|
||||
|
||||
const { safePos: sp, dropDir: dd } = computeContextMenuPosition({
|
||||
clickPos: pos,
|
||||
menuRect: rect,
|
||||
viewport: { w: window.innerWidth, h: window.innerHeight },
|
||||
margin: 8,
|
||||
containerRect,
|
||||
})
|
||||
|
||||
const topLeft = containerRect
|
||||
? { x: Math.round(sp.x - containerRect.left), y: Math.round(sp.y - containerRect.top + 2) }
|
||||
: sp
|
||||
|
||||
setSafePos(topLeft)
|
||||
setDropDir(dd)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showContext) return
|
||||
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(updateContextMenuPosition)
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [showContext, pos, contextRef])
|
||||
|
||||
useEffect(() => {
|
||||
let title = currentDrive?.name || ''
|
||||
|
||||
if (isSearchMode) {
|
||||
title = 'Search results'
|
||||
|
||||
if (scope === 'selected' && currentDrive?.name) {
|
||||
title += ` — ${currentDrive.name}`
|
||||
}
|
||||
}
|
||||
|
||||
setActualItemView?.(title)
|
||||
}, [isSearchMode, scope, currentDrive, setActualItemView])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSearchMode) {
|
||||
bulk.clearAll()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isSearchMode])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
|
||||
if (rafIdRef.current) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doRefresh = async () => {
|
||||
handleCloseContext()
|
||||
|
||||
if (isRefreshing) return
|
||||
|
||||
setIsRefreshing(true)
|
||||
|
||||
try {
|
||||
await resync()
|
||||
} finally {
|
||||
safeSetState(isMountedRef, setIsRefreshing)(false)
|
||||
}
|
||||
}
|
||||
|
||||
const showDeleteModal = showBulkDeleteModal && bulk.selectedFiles.length > 0 && view === ViewType.File
|
||||
const showDragOverlay = isDragging && Boolean(currentDrive)
|
||||
const fileCountText = bulk.selectedFiles.length === 1 ? 'file' : 'files'
|
||||
|
||||
return (
|
||||
<>
|
||||
{conflictPortal}
|
||||
|
||||
<input type="file" ref={legacyUploadRef} style={{ display: 'none' }} onChange={onFileSelected} multiple />
|
||||
<input type="file" ref={bulk.fileInputRef} style={{ display: 'none' }} onChange={onFileSelected} multiple />
|
||||
|
||||
<div className="fm-file-browser-container" data-search-mode={isSearchMode ? 'true' : 'false'}>
|
||||
<FileBrowserTopBar onOpenMenu={openTopbarMenu} canOpen={!isSearchMode && Boolean(currentDrive)} />
|
||||
<div
|
||||
className="fm-file-browser-content"
|
||||
data-search-mode={isSearchMode ? 'true' : 'false'}
|
||||
ref={contentRef}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onPaste={handlePaste}
|
||||
onContextMenu={handleFileBrowserContextMenu}
|
||||
>
|
||||
<FileBrowserHeader
|
||||
key={isSearchMode ? 'hdr-search' : 'hdr-normal'}
|
||||
isSearchMode={isSearchMode}
|
||||
bulk={bulk}
|
||||
sortKey={sort.key}
|
||||
sortDir={sort.dir}
|
||||
onSortName={() => toggle(SortKey.Name)}
|
||||
onSortSize={() => toggle(SortKey.Size)}
|
||||
onSortDate={() => toggle(SortKey.Timestamp)}
|
||||
onSortDrive={() => toggle(SortKey.Drive)}
|
||||
onClearSort={reset}
|
||||
/>
|
||||
<div
|
||||
className="fm-file-browser-content-body"
|
||||
ref={bodyRef}
|
||||
onMouseDown={e => {
|
||||
if (e.button !== 0) return
|
||||
handleCloseContext()
|
||||
}}
|
||||
>
|
||||
<FileBrowserContent
|
||||
key={isSearchMode ? `content-search` : `content-${currentDrive?.id.toString() ?? 'none'}`}
|
||||
listToRender={sorted}
|
||||
drives={drives}
|
||||
currentDrive={currentDrive || null}
|
||||
view={view}
|
||||
isSearchMode={isSearchMode}
|
||||
trackDownload={trackDownload}
|
||||
selectedIds={bulk.selectedIds}
|
||||
onToggleSelected={bulk.toggleOne}
|
||||
bulkSelectedCount={bulk.selectedCount}
|
||||
onBulk={{
|
||||
download: () => bulk.bulkDownload(bulk.selectedFiles),
|
||||
restore: () => bulk.bulkRestore(bulk.selectedFiles),
|
||||
forget: () => bulk.bulkForget(bulk.selectedFiles),
|
||||
destroy: () => setShowDestroyDriveModal(true),
|
||||
delete: () => setShowBulkDeleteModal(true),
|
||||
}}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
{showError && (
|
||||
<ErrorModal
|
||||
label={errorMessage || 'An error occurred'}
|
||||
onClick={() => {
|
||||
setShowError(false)
|
||||
setErrorMessage?.('')
|
||||
|
||||
return
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showContext && (
|
||||
<div
|
||||
ref={contextRef}
|
||||
className="fm-file-browser-context-menu fm-context-menu"
|
||||
style={{ top: safePos.y, left: safePos.x }}
|
||||
data-drop={dropDir}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<FileBrowserContextMenu
|
||||
drives={drives}
|
||||
view={view}
|
||||
selectedFilesCount={bulk.selectedFiles.length}
|
||||
onRefresh={doRefresh}
|
||||
enableRefresh={Boolean(fm?.adminStamp)}
|
||||
onUploadFile={onContextUploadFile}
|
||||
onBulkDownload={() => bulk.bulkDownload(bulk.selectedFiles)}
|
||||
onBulkRestore={() => bulk.bulkRestore(bulk.selectedFiles)}
|
||||
onBulkDelete={() => setShowBulkDeleteModal(true)}
|
||||
onBulkDestroy={() => setShowDestroyDriveModal(true)}
|
||||
onBulkForget={() => bulk.bulkForget(bulk.selectedFiles)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDragOverlay && (
|
||||
<div
|
||||
className="fm-drag-overlay"
|
||||
onDragOver={e => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'copy'
|
||||
}}
|
||||
onDrop={handleOverlayDrop}
|
||||
>
|
||||
<div className="fm-drag-text">Drop file(s) to upload</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FileBrowserModals
|
||||
showDeleteModal={showDeleteModal}
|
||||
selectedFiles={bulk.selectedFiles}
|
||||
fileCountText={fileCountText}
|
||||
currentDrive={currentDrive || null}
|
||||
confirmBulkForget={confirmBulkForget}
|
||||
showDestroyDriveModal={showDestroyDriveModal}
|
||||
pendingCancelUpload={pendingCancelUpload}
|
||||
onDeleteCancel={() => setShowBulkDeleteModal(false)}
|
||||
onDeleteProceed={handleDeleteModalProceed}
|
||||
onForgetConfirm={async () => {
|
||||
await bulk.bulkForget(bulk.selectedFiles)
|
||||
setConfirmBulkForget(false)
|
||||
}}
|
||||
onForgetCancel={() => setConfirmBulkForget(false)}
|
||||
onDestroyCancel={() => setShowDestroyDriveModal(false)}
|
||||
onDestroyConfirm={handleDestroyDriveConfirm}
|
||||
onCancelUploadConfirm={() => {
|
||||
if (pendingCancelUpload) {
|
||||
cancelOrDismissUpload(pendingCancelUpload)
|
||||
setPendingCancelUpload(null)
|
||||
}
|
||||
}}
|
||||
onCancelUploadCancel={() => setPendingCancelUpload(null)}
|
||||
/>
|
||||
|
||||
{isRefreshing && (
|
||||
<div className="fm-refresh-overlay" aria-busy="true" aria-live="polite">
|
||||
<div className="fm-refresh-content">
|
||||
<div className="fm-mini-spinner" role="status" aria-label="Syncing…" />
|
||||
<span className="fm-refresh-text">Syncing latest files…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fm-file-browser-footer">
|
||||
<FileProgressNotification
|
||||
label="Uploading files"
|
||||
type={FileTransferType.Upload}
|
||||
open={isUploading}
|
||||
count={uploadItems.length}
|
||||
items={uploadItems}
|
||||
onRowClose={handleUploadClose}
|
||||
onCloseAll={() => dismissAllUploads()}
|
||||
/>
|
||||
<FileProgressNotification
|
||||
label="Downloading files"
|
||||
type={FileTransferType.Download}
|
||||
open={isDownloading}
|
||||
count={downloadItems.length}
|
||||
items={downloadItems}
|
||||
onRowClose={name => cancelOrDismissDownload(name)}
|
||||
onCloseAll={() => dismissAllDownloads()}
|
||||
/>
|
||||
<NotificationBar setErrorMessage={setErrorMessage} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
import { ReactElement, useCallback } from 'react'
|
||||
import { FileItem } from '../FileItem/FileItem'
|
||||
import { FileInfo, DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
|
||||
import { getFileId } from '../../../utils/common'
|
||||
|
||||
interface FileBrowserContentProps {
|
||||
listToRender: FileInfo[]
|
||||
drives: DriveInfo[]
|
||||
currentDrive: DriveInfo | null
|
||||
view: ViewType
|
||||
isSearchMode: boolean
|
||||
trackDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
|
||||
selectedIds?: Set<string>
|
||||
onToggleSelected?: (fi: FileInfo, checked: boolean) => void
|
||||
bulkSelectedCount?: number
|
||||
onBulk: {
|
||||
download?: () => void
|
||||
restore?: () => void
|
||||
forget?: () => void
|
||||
destroy?: () => void
|
||||
delete?: () => void
|
||||
}
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function FileBrowserContent({
|
||||
listToRender,
|
||||
drives,
|
||||
currentDrive,
|
||||
view,
|
||||
isSearchMode,
|
||||
trackDownload,
|
||||
selectedIds,
|
||||
onToggleSelected,
|
||||
bulkSelectedCount,
|
||||
onBulk,
|
||||
setErrorMessage,
|
||||
}: FileBrowserContentProps): ReactElement {
|
||||
const renderEmptyState = useCallback((): ReactElement => {
|
||||
if (drives.length === 0) {
|
||||
return <div className="fm-drop-hint">Create a drive to start using the file manager</div>
|
||||
}
|
||||
|
||||
if (!currentDrive) {
|
||||
return <div className="fm-drop-hint">Select a drive to upload or view its files</div>
|
||||
}
|
||||
|
||||
if (view === ViewType.Trash) {
|
||||
return (
|
||||
<div className="fm-drop-hint">
|
||||
Files from "{currentDrive?.name}" that are trashed can be viewed here
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className="fm-drop-hint">Drag & drop files here into "{currentDrive?.name}"</div>
|
||||
}, [drives, currentDrive, view])
|
||||
|
||||
const renderFileList = useCallback(
|
||||
(filesToRender: FileInfo[], showDriveColumn = false): ReactElement[] => {
|
||||
return filesToRender
|
||||
.map(fi => {
|
||||
const drive = drives.find(d => d.id.toString() === fi.driveId.toString())
|
||||
|
||||
return drive ? { fi, driveName: drive.name } : null
|
||||
})
|
||||
.filter((item): item is { fi: FileInfo; driveName: string } => item !== null)
|
||||
.map(({ fi, driveName }) => {
|
||||
const key = `${getFileId(fi)}::${fi.version ?? ''}::${showDriveColumn ? 'search' : 'normal'}`
|
||||
|
||||
return (
|
||||
<FileItem
|
||||
key={key}
|
||||
fileInfo={fi}
|
||||
onDownload={trackDownload}
|
||||
showDriveColumn={showDriveColumn}
|
||||
driveName={driveName}
|
||||
selected={Boolean(selectedIds?.has(getFileId(fi)))}
|
||||
onToggleSelected={onToggleSelected}
|
||||
bulkSelectedCount={bulkSelectedCount}
|
||||
onBulk={onBulk}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)
|
||||
})
|
||||
},
|
||||
[trackDownload, drives, selectedIds, onToggleSelected, bulkSelectedCount, onBulk, setErrorMessage],
|
||||
)
|
||||
|
||||
if (drives.length === 0) {
|
||||
return renderEmptyState()
|
||||
}
|
||||
|
||||
if (!isSearchMode) {
|
||||
if (!currentDrive) {
|
||||
return <div className="fm-drop-hint">Select a drive to upload or view its files</div>
|
||||
}
|
||||
|
||||
if (view === ViewType.Expired) {
|
||||
return (
|
||||
<div className="fm-drop-hint">
|
||||
The stamp for drive "{currentDrive?.name}" is expired, no files can be found
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (listToRender.length === 0) {
|
||||
if (view === ViewType.Trash) {
|
||||
return (
|
||||
<div className="fm-drop-hint">
|
||||
Files from "{currentDrive?.name}" that are trashed can be viewed here
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <div className="fm-drop-hint">Drag & drop files here into "{currentDrive?.name}"</div>
|
||||
}
|
||||
|
||||
return <>{renderFileList(listToRender)}</>
|
||||
}
|
||||
|
||||
if (listToRender.length === 0) {
|
||||
return <div className="fm-drop-hint">No results found.</div>
|
||||
}
|
||||
|
||||
return <>{renderFileList(listToRender, true)}</>
|
||||
}
|
||||
|
||||
export default FileBrowserContent
|
||||
+188
@@ -0,0 +1,188 @@
|
||||
import { ReactElement } from 'react'
|
||||
import DownIcon from 'remixicon-react/ArrowDownSLineIcon'
|
||||
import { useBulkActions } from '../../../hooks/useBulkActions'
|
||||
import { SortDir, SortKey } from '../../../hooks/useSorting'
|
||||
import { capitalizeFirstLetter } from '../../../../../../src/modules/filemanager/utils/common'
|
||||
|
||||
interface FileBrowserHeaderProps {
|
||||
isSearchMode: boolean
|
||||
bulk: ReturnType<typeof useBulkActions>
|
||||
sortKey: SortKey
|
||||
sortDir: SortDir
|
||||
onSortName: () => void
|
||||
onSortSize: () => void
|
||||
onSortDate: () => void
|
||||
onSortDrive: () => void
|
||||
onClearSort: () => void
|
||||
}
|
||||
|
||||
enum AriaSortValue {
|
||||
Ascending = 'ascending',
|
||||
Descending = 'descending',
|
||||
None = 'none',
|
||||
}
|
||||
|
||||
const Arrow = ({ active, dir }: { active: boolean; dir: SortDir }) => {
|
||||
let title: string | undefined
|
||||
|
||||
if (active) {
|
||||
const sortValue = dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
|
||||
title = capitalizeFirstLetter(sortValue)
|
||||
} else {
|
||||
title = undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={'fm-file-browser-content-header-item-icon' + (active ? '' : ' is-inactive')}
|
||||
aria-hidden={title ? 'false' : 'true'}
|
||||
aria-label={title}
|
||||
title={title}
|
||||
>
|
||||
<DownIcon size="16px" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HeaderCell({
|
||||
label,
|
||||
isActive,
|
||||
dir,
|
||||
onToggle,
|
||||
onClear,
|
||||
ariaSort,
|
||||
'data-testid': testId,
|
||||
}: {
|
||||
label: string
|
||||
isActive: boolean
|
||||
dir: SortDir
|
||||
onToggle: () => void
|
||||
onClear: () => void
|
||||
ariaSort: AriaSortValue
|
||||
'data-testid'?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="fm-header-cell" role="columnheader" aria-sort={ariaSort} data-testid={testId}>
|
||||
<button
|
||||
type="button"
|
||||
className="fm-header-button"
|
||||
onClick={onToggle}
|
||||
data-dir={isActive ? dir : undefined}
|
||||
aria-label={
|
||||
isActive
|
||||
? `Sort by ${label.toLowerCase()}, currently ${
|
||||
dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
|
||||
}`
|
||||
: `Sort by ${label.toLowerCase()}`
|
||||
}
|
||||
title={
|
||||
isActive
|
||||
? `Currently ${capitalizeFirstLetter(
|
||||
dir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending,
|
||||
)}`
|
||||
: 'Click to sort'
|
||||
}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<Arrow active={isActive} dir={dir} />
|
||||
</button>
|
||||
|
||||
{isActive && (
|
||||
<button
|
||||
type="button"
|
||||
className="fm-sort-clear"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClear()
|
||||
}}
|
||||
aria-label="Reset sorting to default"
|
||||
title="Clear sorting"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function FileBrowserHeader({
|
||||
isSearchMode,
|
||||
bulk,
|
||||
sortKey,
|
||||
sortDir,
|
||||
onSortName,
|
||||
onSortSize,
|
||||
onSortDate,
|
||||
onSortDrive,
|
||||
onClearSort,
|
||||
}: FileBrowserHeaderProps): ReactElement {
|
||||
const ariaSort = (thisKey: SortKey): AriaSortValue => {
|
||||
if (sortKey !== thisKey) return AriaSortValue.None
|
||||
|
||||
return sortDir === SortDir.Asc ? AriaSortValue.Ascending : AriaSortValue.Descending
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fm-file-browser-content-header" role="row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulk.allChecked}
|
||||
ref={el => {
|
||||
if (el) el.indeterminate = bulk.someChecked
|
||||
}}
|
||||
onChange={e => (e.target.checked ? bulk.selectAll() : bulk.clearAll())}
|
||||
/>
|
||||
|
||||
<div className="fm-file-browser-content-header-item fm-name">
|
||||
<HeaderCell
|
||||
label="Name"
|
||||
isActive={sortKey === SortKey.Name}
|
||||
dir={sortDir}
|
||||
onToggle={onSortName}
|
||||
onClear={onClearSort}
|
||||
ariaSort={ariaSort(SortKey.Name)}
|
||||
data-testid="hdr-name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isSearchMode && (
|
||||
<div className="fm-file-browser-content-header-item fm-drive">
|
||||
<HeaderCell
|
||||
label="Drive"
|
||||
isActive={sortKey === SortKey.Drive}
|
||||
dir={sortDir}
|
||||
onToggle={onSortDrive}
|
||||
onClear={onClearSort}
|
||||
ariaSort={ariaSort(SortKey.Drive)}
|
||||
data-testid="hdr-drive"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fm-file-browser-content-header-item fm-size">
|
||||
<HeaderCell
|
||||
label="Size"
|
||||
isActive={sortKey === SortKey.Size}
|
||||
dir={sortDir}
|
||||
onToggle={onSortSize}
|
||||
onClear={onClearSort}
|
||||
ariaSort={ariaSort(SortKey.Size)}
|
||||
data-testid="hdr-size"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fm-file-browser-content-header-item fm-date-mod">
|
||||
<HeaderCell
|
||||
label="Date mod."
|
||||
isActive={sortKey === SortKey.Timestamp}
|
||||
dir={sortDir}
|
||||
onToggle={onSortDate}
|
||||
onClear={onClearSort}
|
||||
ariaSort={ariaSort(SortKey.Timestamp)}
|
||||
data-testid="hdr-date"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
import { ContextMenu } from '../../ContextMenu/ContextMenu'
|
||||
import { ReactElement } from 'react'
|
||||
import '../FileBrowser.scss'
|
||||
import { ViewType } from '../../../constants/transfers'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { Tooltip } from '../../Tooltip/Tooltip'
|
||||
|
||||
interface FileBrowserContextMenuProps {
|
||||
drives: DriveInfo[]
|
||||
view: ViewType
|
||||
selectedFilesCount: number
|
||||
onRefresh: () => void
|
||||
onUploadFile: () => void
|
||||
onBulkDownload: () => void
|
||||
onBulkRestore: () => void
|
||||
onBulkDelete: () => void
|
||||
onBulkDestroy: () => void
|
||||
onBulkForget: () => void
|
||||
enableRefresh?: boolean
|
||||
}
|
||||
|
||||
export function FileBrowserContextMenu({
|
||||
drives,
|
||||
view,
|
||||
selectedFilesCount,
|
||||
onRefresh,
|
||||
onUploadFile,
|
||||
onBulkDownload,
|
||||
onBulkRestore,
|
||||
onBulkDelete,
|
||||
onBulkDestroy,
|
||||
onBulkForget,
|
||||
enableRefresh,
|
||||
}: FileBrowserContextMenuProps): ReactElement {
|
||||
if (drives.length === 0) {
|
||||
if (!enableRefresh) {
|
||||
return <></>
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<div className="fm-context-item" onClick={onRefresh}>
|
||||
Refresh
|
||||
</div>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedFilesCount > 1) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<div className="fm-context-item" onClick={onBulkDownload}>
|
||||
Download
|
||||
</div>
|
||||
{view === ViewType.File ? (
|
||||
<div className="fm-context-item red" onClick={onBulkDelete}>
|
||||
Delete…
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="fm-context-item" onClick={onBulkRestore}>
|
||||
Restore
|
||||
</div>
|
||||
<div className="fm-context-item red" onClick={onBulkDestroy}>
|
||||
Destroy
|
||||
</div>
|
||||
<div className="fm-context-item red" onClick={onBulkForget}>
|
||||
Forget permanently
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
if (view === ViewType.Trash) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<div className="fm-context-item" onClick={onRefresh}>
|
||||
Refresh
|
||||
</div>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<div className="fm-context-item" style={{ display: 'none' }}>
|
||||
New folder
|
||||
</div>
|
||||
<div className="fm-context-item" onClick={onUploadFile}>
|
||||
Upload file(s)
|
||||
</div>
|
||||
<div className="fm-context-item" style={{ display: 'none' }}>
|
||||
Upload folder
|
||||
</div>
|
||||
<div className="fm-context-item-border" />
|
||||
<div
|
||||
className="fm-context-item"
|
||||
role="menuitem"
|
||||
aria-disabled="true"
|
||||
tabIndex={-1}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<Tooltip label="Tip: Use ⌘V / Ctrl+V or Browser → Edit → Paste." iconSize="14px" gapPx={6} disableMargin>
|
||||
Paste
|
||||
</Tooltip>
|
||||
</span>
|
||||
</div>
|
||||
<div className="fm-context-item-border" />
|
||||
<div className="fm-context-item" onClick={onRefresh}>
|
||||
Refresh
|
||||
</div>
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ReactElement } from 'react'
|
||||
import type { FileInfo, DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
|
||||
import { DeleteFileModal } from '../DeleteFileModal/DeleteFileModal'
|
||||
import { DestroyDriveModal } from '../DestroyDriveModal/DestroyDriveModal'
|
||||
import { FileAction } from '../../constants/transfers'
|
||||
|
||||
interface FileBrowserModalsProps {
|
||||
showDeleteModal: boolean
|
||||
selectedFiles: FileInfo[]
|
||||
fileCountText: string
|
||||
currentDrive: DriveInfo | null
|
||||
confirmBulkForget: boolean
|
||||
showDestroyDriveModal: boolean
|
||||
pendingCancelUpload: string | null
|
||||
onDeleteCancel: () => void
|
||||
onDeleteProceed: (action: FileAction) => void
|
||||
onForgetConfirm: () => Promise<void>
|
||||
onForgetCancel: () => void
|
||||
onDestroyCancel: () => void
|
||||
onDestroyConfirm: () => Promise<void>
|
||||
onCancelUploadConfirm: () => void
|
||||
onCancelUploadCancel: () => void
|
||||
}
|
||||
|
||||
export function FileBrowserModals({
|
||||
showDeleteModal,
|
||||
selectedFiles,
|
||||
fileCountText,
|
||||
currentDrive,
|
||||
confirmBulkForget,
|
||||
showDestroyDriveModal,
|
||||
pendingCancelUpload,
|
||||
onDeleteCancel,
|
||||
onDeleteProceed,
|
||||
onForgetConfirm,
|
||||
onForgetCancel,
|
||||
onDestroyCancel,
|
||||
onDestroyConfirm,
|
||||
onCancelUploadConfirm,
|
||||
onCancelUploadCancel,
|
||||
}: FileBrowserModalsProps): ReactElement {
|
||||
return (
|
||||
<>
|
||||
{showDeleteModal && (
|
||||
<DeleteFileModal
|
||||
names={selectedFiles.map(f => f.name)}
|
||||
currentDriveName={currentDrive?.name}
|
||||
onCancelClick={onDeleteCancel}
|
||||
onProceed={onDeleteProceed}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmBulkForget && (
|
||||
<ConfirmModal
|
||||
title="Forget permanently?"
|
||||
message={
|
||||
<>
|
||||
This removes <b>{selectedFiles.length}</b> {fileCountText} from your view.
|
||||
<br />
|
||||
The data remains on Swarm until the drive expires.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Forget"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onForgetConfirm}
|
||||
onCancel={onForgetCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDestroyDriveModal && currentDrive && (
|
||||
<DestroyDriveModal drive={currentDrive} onCancelClick={onDestroyCancel} doDestroy={onDestroyConfirm} />
|
||||
)}
|
||||
|
||||
{pendingCancelUpload && (
|
||||
<ConfirmModal
|
||||
title="Cancel upload?"
|
||||
message={
|
||||
<>
|
||||
Stopping now will cancel the network request. Data already transmitted cannot be reverted.{' '}
|
||||
<b>We will try our best to clean up the transmitted data.</b>
|
||||
<br />
|
||||
To remove any (remaining) cancelled items from your browser view later, use{' '}
|
||||
<i>Right-click → Delete → Forget</i>.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Cancel upload"
|
||||
cancelLabel="Keep uploading"
|
||||
onConfirm={onCancelUploadConfirm}
|
||||
onCancel={onCancelUploadCancel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
.fm-file-browser-top-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
background-color: rgb(237, 129, 49);
|
||||
padding: 12px;
|
||||
gap: 8px;
|
||||
color: rgb(255, 255, 255);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fm-file-browser-container[data-search-mode="true"] .fm-file-browser-top-bar {
|
||||
background-color: rgb(37, 99, 235);
|
||||
}
|
||||
|
||||
.fm-file-browser-top-bar__title {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fm-topbar-kebab {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgb(255, 255, 255);
|
||||
line-height: 1;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
border-radius: 4px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
cursor: pointer;
|
||||
transition: background-color .12s ease, opacity .12s ease;
|
||||
}
|
||||
|
||||
.fm-topbar-kebab:hover,
|
||||
.fm-topbar-kebab:focus-visible {
|
||||
background: rgba(255,255,255,.12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fm-topbar-kebab:active {
|
||||
background: rgba(255,255,255,.18);
|
||||
}
|
||||
|
||||
.fm-topbar-kebab:disabled {
|
||||
opacity: .5;
|
||||
cursor: not-allowed;
|
||||
background: transparent;
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { ReactElement } from 'react'
|
||||
import './FileBrowserTopBar.scss'
|
||||
import { useView } from '../../../../../pages/filemanager/ViewContext'
|
||||
import { ViewType } from '../../../constants/transfers'
|
||||
|
||||
type Props = {
|
||||
onOpenMenu?: (anchorEl: HTMLElement) => void
|
||||
canOpen?: boolean
|
||||
}
|
||||
|
||||
export function FileBrowserTopBar({ onOpenMenu, canOpen = true }: Props): ReactElement {
|
||||
const { view, actualItemView } = useView()
|
||||
|
||||
const viewText = view === ViewType.Trash ? ' Trash' : ''
|
||||
|
||||
return (
|
||||
<div className="fm-file-browser-top-bar">
|
||||
<div className="fm-file-browser-top-bar__title">
|
||||
{actualItemView}
|
||||
{viewText}
|
||||
</div>
|
||||
{canOpen && (
|
||||
<button
|
||||
type="button"
|
||||
className="fm-topbar-kebab"
|
||||
aria-haspopup="menu"
|
||||
aria-label="More actions"
|
||||
onClick={e => onOpenMenu?.(e.currentTarget)}
|
||||
>
|
||||
⋯
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
.fm-file-item-content {
|
||||
position: relative;
|
||||
display: grid;
|
||||
padding: 12px;
|
||||
|
||||
& input {
|
||||
margin: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover { background-color: #d1d1d1; }
|
||||
}
|
||||
|
||||
.fm-file-browser-content[data-search-mode='false'] .fm-file-item-content {
|
||||
grid-template-columns: 32px 2fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.fm-file-browser-content[data-search-mode='true'] .fm-file-item-content {
|
||||
grid-template-columns: 32px 2fr 1.1fr 1fr 1fr;
|
||||
}
|
||||
|
||||
.fm-file-item-content-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: start;
|
||||
gap: 6px;
|
||||
|
||||
& input {
|
||||
accent-color: var(--fm-accent, rgb(237, 129, 49));
|
||||
}
|
||||
}
|
||||
|
||||
.fm-file-item-content-item.fm-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fm-file-item-name,
|
||||
.fm-file-item-content-item.fm-name {
|
||||
font-weight: 400;
|
||||
gap: 8px;
|
||||
|
||||
& svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--fm-accent, #ed8131);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-file-item-content-item.fm-drive {
|
||||
gap: 8px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
flex: 0 0 180px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.fm-drive-name { opacity: 0.9; }
|
||||
|
||||
.fm-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.fm-pill--active {
|
||||
background: #e0f2fe;
|
||||
color: #075985;
|
||||
border-color: #bae6fd;
|
||||
}
|
||||
.fm-pill--trash {
|
||||
background: #fee2e2;
|
||||
color: #b91c1c;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
.fm-file-browser-content[data-search-mode='true'] .fm-file-item-content::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--fm-accent, #2563eb);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.fm-file-item-context-menu {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.fm-file-item-context-menu[data-drop='up'] {
|
||||
transform-origin: bottom left;
|
||||
}
|
||||
|
||||
.fm-file-item-context-menu[data-drop='up'] .caret {
|
||||
transform: rotate(180deg);
|
||||
bottom: -6px;
|
||||
top: auto;
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
import { ReactElement, useContext, useLayoutEffect, useMemo, useState, useRef, useEffect, useCallback } from 'react'
|
||||
import './FileItem.scss'
|
||||
import { GetIconElement } from '../../../utils/GetIconElement'
|
||||
import { ContextMenu } from '../../ContextMenu/ContextMenu'
|
||||
import { useContextMenu } from '../../../hooks/useContextMenu'
|
||||
import { Context as SettingsContext } from '../../../../../providers/Settings'
|
||||
import { ActionTag, DownloadProgress, TrackDownloadProps, ViewType } from '../../../constants/transfers'
|
||||
import { GetInfoModal } from '../../GetInfoModal/GetInfoModal'
|
||||
import { VersionHistoryModal } from '../../VersionHistoryModal/VersionHistoryModal'
|
||||
import { DeleteFileModal } from '../../DeleteFileModal/DeleteFileModal'
|
||||
import { RenameFileModal } from '../../RenameFileModal/RenameFileModal'
|
||||
import { buildGetInfoGroups } from '../../../utils/infoGroups'
|
||||
import type { FilePropertyGroup } from '../../../utils/infoGroups'
|
||||
import { useView } from '../../../../../pages/filemanager/ViewContext'
|
||||
import type { DriveInfo, FileInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { Context as FMContext } from '../../../../../providers/FileManager'
|
||||
import { DestroyDriveModal } from '../../DestroyDriveModal/DestroyDriveModal'
|
||||
import { ConfirmModal } from '../../ConfirmModal/ConfirmModal'
|
||||
|
||||
import { capitalizeFirstLetter, Dir, formatBytes, isTrashed, safeSetState } from '../../../utils/common'
|
||||
import { FileAction } from '../../../constants/transfers'
|
||||
import { startDownloadingQueue, createDownloadAbort } from '../../../utils/download'
|
||||
import { computeContextMenuPosition } from '../../../utils/ui'
|
||||
import { getUsableStamps, handleDestroyDrive } from '../../../utils/bee'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
|
||||
interface FileItemProps {
|
||||
fileInfo: FileInfo
|
||||
onDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
|
||||
showDriveColumn?: boolean
|
||||
driveName: string
|
||||
selected?: boolean
|
||||
onToggleSelected?: (fi: FileInfo, checked: boolean) => void
|
||||
bulkSelectedCount?: number
|
||||
onBulk: {
|
||||
download?: () => void
|
||||
restore?: () => void
|
||||
forget?: () => void
|
||||
destroy?: () => void
|
||||
delete?: () => void
|
||||
}
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function FileItem({
|
||||
fileInfo,
|
||||
onDownload,
|
||||
showDriveColumn,
|
||||
driveName,
|
||||
selected = false,
|
||||
onToggleSelected,
|
||||
bulkSelectedCount,
|
||||
onBulk,
|
||||
setErrorMessage,
|
||||
}: FileItemProps): ReactElement {
|
||||
const { showContext, pos, contextRef, handleContextMenu, handleCloseContext } = useContextMenu<HTMLDivElement>()
|
||||
const { fm, currentDrive, files, drives, setShowError } = useContext(FMContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { view } = useView()
|
||||
|
||||
const [driveStamp, setDriveStamp] = useState<PostageBatch | undefined>(undefined)
|
||||
const [safePos, setSafePos] = useState(pos)
|
||||
const [dropDir, setDropDir] = useState<Dir>(Dir.Down)
|
||||
const [showGetInfoModal, setShowGetInfoModal] = useState(false)
|
||||
const [infoGroups, setInfoGroups] = useState<FilePropertyGroup[] | null>(null)
|
||||
const [showVersionHistory, setShowVersionHistory] = useState(false)
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [showDestroyDriveModal, setShowDestroyDriveModal] = useState(false)
|
||||
const [destroyDrive, setDestroyDrive] = useState<DriveInfo | null>(null)
|
||||
const [confirmForget, setConfirmForget] = useState(false)
|
||||
|
||||
const isMountedRef = useRef(true)
|
||||
const rafIdRef = useRef<number | null>(null)
|
||||
|
||||
const size = formatBytes(fileInfo.customMetadata?.size)
|
||||
const dateMod = new Date(fileInfo.timestamp || 0).toLocaleDateString()
|
||||
const isTrashedFile = isTrashed(fileInfo)
|
||||
const statusLabel = isTrashedFile ? 'Trash' : 'Active'
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
const getStamps = async () => {
|
||||
const stamps = await getUsableStamps(beeApi)
|
||||
const driveStamp = stamps.find(s =>
|
||||
drives.some(d => d.batchId.toString() === s.batchID.toString() && d.id === fileInfo.driveId),
|
||||
)
|
||||
|
||||
safeSetState(isMountedRef, setDriveStamp)(driveStamp)
|
||||
}
|
||||
|
||||
getStamps()
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
}
|
||||
}
|
||||
}, [beeApi, drives, fileInfo.driveId])
|
||||
|
||||
const openGetInfo = useCallback(async () => {
|
||||
if (!fm || !isMountedRef.current) return
|
||||
|
||||
const groups = await buildGetInfoGroups(fm, fileInfo, driveName, driveStamp)
|
||||
setInfoGroups(groups)
|
||||
setShowGetInfoModal(true)
|
||||
}, [fm, fileInfo, driveName, driveStamp])
|
||||
|
||||
const takenNames = useMemo(() => {
|
||||
if (!currentDrive || !files) return new Set<string>()
|
||||
const wanted = currentDrive.batchId.toString()
|
||||
const sameDrive = files.filter(fi => fi.batchId.toString() === wanted)
|
||||
const out = new Set<string>()
|
||||
sameDrive.forEach(fi => {
|
||||
if (fi.topic.toString() !== fileInfo.topic.toString()) out.add(fi.name)
|
||||
})
|
||||
|
||||
return out
|
||||
}, [files, currentDrive, fileInfo.topic])
|
||||
|
||||
const handleItemContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.shiftKey) return
|
||||
handleContextMenu(e)
|
||||
}
|
||||
|
||||
// TODO: handleOpen shall only be available for images, videos etc... -> do not download 10GB into memory
|
||||
const handleDownload = useCallback(
|
||||
async (isNewWindow?: boolean) => {
|
||||
if (!fm || !beeApi) return
|
||||
|
||||
handleCloseContext()
|
||||
|
||||
const rawSize = fileInfo.customMetadata?.size
|
||||
const expectedSize = rawSize ? Number(rawSize) : undefined
|
||||
|
||||
createDownloadAbort(fileInfo.name)
|
||||
|
||||
await startDownloadingQueue(
|
||||
fm,
|
||||
[fileInfo],
|
||||
[onDownload({ name: fileInfo.name, size: formatBytes(rawSize), expectedSize })],
|
||||
isNewWindow,
|
||||
)
|
||||
},
|
||||
[handleCloseContext, fm, beeApi, fileInfo, onDownload],
|
||||
)
|
||||
// TODO: refactor doTrash, doRecover, doForget to a single function with action param and remove switch case mybe
|
||||
const doTrash = useCallback(async () => {
|
||||
if (!fm) return
|
||||
|
||||
const withMeta: FileInfo = {
|
||||
...fileInfo,
|
||||
customMetadata: {
|
||||
...(fileInfo.customMetadata ?? {}),
|
||||
lifecycle: capitalizeFirstLetter(ActionTag.Trashed),
|
||||
lifecycleAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
await fm.trashFile(withMeta)
|
||||
}, [fm, fileInfo])
|
||||
|
||||
const doRecover = useCallback(async () => {
|
||||
if (!fm) return
|
||||
|
||||
const withMeta: FileInfo = {
|
||||
...fileInfo,
|
||||
customMetadata: {
|
||||
...(fileInfo.customMetadata ?? {}),
|
||||
lifecycle: capitalizeFirstLetter(ActionTag.Recovered),
|
||||
lifecycleAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
await fm.recoverFile(withMeta)
|
||||
}, [fm, fileInfo])
|
||||
|
||||
const doForget = useCallback(async () => {
|
||||
if (!fm) return
|
||||
|
||||
await fm.forgetFile(fileInfo)
|
||||
}, [fm, fileInfo])
|
||||
|
||||
const showDestroyDrive = useCallback(() => {
|
||||
setDestroyDrive(currentDrive || null)
|
||||
setShowDestroyDriveModal(true)
|
||||
}, [currentDrive])
|
||||
|
||||
const doRename = useCallback(
|
||||
async (newName: string) => {
|
||||
if (!fm || !currentDrive) return
|
||||
|
||||
if (takenNames.has(newName)) throw new Error('name-taken')
|
||||
|
||||
try {
|
||||
await fm.upload(
|
||||
currentDrive,
|
||||
{
|
||||
name: newName,
|
||||
topic: fileInfo.topic,
|
||||
file: {
|
||||
reference: fileInfo.file.reference,
|
||||
historyRef: fileInfo.file.historyRef,
|
||||
},
|
||||
customMetadata: fileInfo.customMetadata,
|
||||
files: [],
|
||||
},
|
||||
{
|
||||
actHistoryAddress: fileInfo.file.historyRef,
|
||||
},
|
||||
)
|
||||
} catch (e: unknown) {
|
||||
setErrorMessage?.(`Error renaming file ${fileInfo.name}`)
|
||||
setShowError(true)
|
||||
}
|
||||
},
|
||||
|
||||
[fm, currentDrive, fileInfo, takenNames, setErrorMessage, setShowError],
|
||||
)
|
||||
|
||||
const MenuItem = ({
|
||||
disabled,
|
||||
danger,
|
||||
onClick,
|
||||
children,
|
||||
}: {
|
||||
disabled?: boolean
|
||||
danger?: boolean
|
||||
onClick?: () => void
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<div
|
||||
className={`fm-context-item${danger ? ' red' : ''}`}
|
||||
aria-disabled={disabled ? 'true' : 'false'}
|
||||
style={disabled ? { opacity: 0.5, pointerEvents: 'none' } : undefined}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
const isBulk = (bulkSelectedCount ?? 0) > 1
|
||||
|
||||
const renderContextMenuItems = useCallback(() => {
|
||||
const viewItem = (
|
||||
<MenuItem disabled={isBulk} onClick={() => handleDownload(true)}>
|
||||
View / Open
|
||||
</MenuItem>
|
||||
)
|
||||
|
||||
const downloadItem = isBulk ? (
|
||||
<MenuItem onClick={onBulk.download}>Download</MenuItem>
|
||||
) : (
|
||||
<MenuItem onClick={() => handleDownload(false)}>Download</MenuItem>
|
||||
)
|
||||
|
||||
const getInfoItem = (
|
||||
<MenuItem
|
||||
disabled={isBulk}
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
openGetInfo()
|
||||
}}
|
||||
>
|
||||
Get info
|
||||
</MenuItem>
|
||||
)
|
||||
|
||||
if (view === ViewType.File) {
|
||||
return (
|
||||
<>
|
||||
{viewItem}
|
||||
{downloadItem}
|
||||
<MenuItem
|
||||
disabled={isBulk}
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
setShowRenameModal(true)
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</MenuItem>
|
||||
<div className="fm-context-item-border" />
|
||||
<MenuItem
|
||||
disabled={isBulk}
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
setShowVersionHistory(true)
|
||||
}}
|
||||
>
|
||||
Version history
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
|
||||
if (isBulk) onBulk.delete?.()
|
||||
else setShowDeleteModal(true)
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<div className="fm-context-item-border" />
|
||||
{getInfoItem}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{viewItem}
|
||||
{downloadItem}
|
||||
<div className="fm-context-item-border" />
|
||||
{isBulk ? (
|
||||
<>
|
||||
<MenuItem danger onClick={onBulk.restore}>
|
||||
Restore
|
||||
</MenuItem>
|
||||
<MenuItem danger onClick={onBulk.destroy}>
|
||||
Destroy
|
||||
</MenuItem>
|
||||
<MenuItem danger onClick={onBulk.forget}>
|
||||
Forget permanently
|
||||
</MenuItem>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MenuItem
|
||||
danger
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
doRecover()
|
||||
}}
|
||||
>
|
||||
Restore
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
danger
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
|
||||
const parentDrive = drives.find(d => d.id.toString() === fileInfo.driveId.toString())
|
||||
|
||||
if (parentDrive) {
|
||||
setDestroyDrive(parentDrive)
|
||||
setShowDestroyDriveModal(true)
|
||||
} else if (currentDrive) {
|
||||
setDestroyDrive(currentDrive)
|
||||
setShowDestroyDriveModal(true)
|
||||
} else {
|
||||
setErrorMessage?.('Unable to resolve drive for this file.')
|
||||
setShowError(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Destroy
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem
|
||||
danger
|
||||
onClick={() => {
|
||||
handleCloseContext()
|
||||
setConfirmForget(true)
|
||||
}}
|
||||
>
|
||||
Forget permanently
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
<div className="fm-context-item-border" />
|
||||
{getInfoItem}
|
||||
</>
|
||||
)
|
||||
}, [
|
||||
isBulk,
|
||||
view,
|
||||
handleDownload,
|
||||
handleCloseContext,
|
||||
openGetInfo,
|
||||
doRecover,
|
||||
onBulk,
|
||||
currentDrive,
|
||||
drives,
|
||||
fileInfo.driveId,
|
||||
setErrorMessage,
|
||||
setShowError,
|
||||
])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!showContext) return
|
||||
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
}
|
||||
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
const menu = contextRef.current
|
||||
|
||||
if (!menu) return
|
||||
|
||||
const menuRect = menu.getBoundingClientRect()
|
||||
const containerEl = (menu.offsetParent as HTMLElement) ?? null
|
||||
const containerRect = containerEl?.getBoundingClientRect() ?? null
|
||||
|
||||
const { safePos: s, dropDir: d } = computeContextMenuPosition({
|
||||
clickPos: pos,
|
||||
menuRect: menuRect,
|
||||
viewport: { w: window.innerWidth, h: window.innerHeight },
|
||||
margin: 8,
|
||||
containerRect,
|
||||
})
|
||||
|
||||
const topLeft = containerRect
|
||||
? { x: Math.round(s.x - containerRect.left), y: Math.round(s.y - containerRect.top) }
|
||||
: s
|
||||
setSafePos(topLeft)
|
||||
setDropDir(d)
|
||||
|
||||
rafIdRef.current = null
|
||||
})
|
||||
|
||||
return () => {
|
||||
if (rafIdRef.current !== null) {
|
||||
cancelAnimationFrame(rafIdRef.current)
|
||||
rafIdRef.current = null
|
||||
}
|
||||
}
|
||||
}, [showContext, pos, contextRef])
|
||||
|
||||
if (!currentDrive || !fm || !beeApi) {
|
||||
return <div className="fm-file-item-content">Error</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fm-file-item-content" onContextMenu={handleItemContextMenu} onClick={handleCloseContext}>
|
||||
<div className="fm-file-item-content-item fm-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={e => onToggleSelected?.(fileInfo, e.target.checked)}
|
||||
onClick={e => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fm-file-item-content-item fm-name" onDoubleClick={() => handleDownload(true)}>
|
||||
<GetIconElement icon={fileInfo.name} />
|
||||
{fileInfo.name}
|
||||
</div>
|
||||
|
||||
{showDriveColumn && (
|
||||
<div className="fm-file-item-content-item fm-drive">
|
||||
<span className="fm-drive-name">{driveName}</span>
|
||||
<span className={`fm-pill ${isTrashedFile ? 'fm-pill--trash' : 'fm-pill--active'}`} title={statusLabel}>
|
||||
{statusLabel}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fm-file-item-content-item fm-size">{size}</div>
|
||||
<div className="fm-file-item-content-item fm-date-mod">{dateMod}</div>
|
||||
|
||||
{showContext && (
|
||||
<div
|
||||
ref={contextRef}
|
||||
className="fm-file-item-context-menu"
|
||||
style={{ top: safePos.y, left: safePos.x }}
|
||||
data-drop={dropDir}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ContextMenu>{renderContextMenuItems()}</ContextMenu>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showGetInfoModal && infoGroups && (
|
||||
<GetInfoModal
|
||||
name={fileInfo.name}
|
||||
properties={infoGroups}
|
||||
onCancelClick={() => {
|
||||
setShowGetInfoModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showVersionHistory && (
|
||||
<VersionHistoryModal
|
||||
fileInfo={fileInfo}
|
||||
onCancelClick={() => {
|
||||
setShowVersionHistory(false)
|
||||
}}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDeleteModal && (
|
||||
<DeleteFileModal
|
||||
name={fileInfo.name}
|
||||
currentDriveName={currentDrive.name}
|
||||
onCancelClick={() => {
|
||||
setShowDeleteModal(false)
|
||||
}}
|
||||
onProceed={action => {
|
||||
setShowDeleteModal(false)
|
||||
switch (action) {
|
||||
case FileAction.Trash:
|
||||
doTrash()
|
||||
break
|
||||
case FileAction.Forget:
|
||||
setConfirmForget(true)
|
||||
break
|
||||
case FileAction.Destroy:
|
||||
showDestroyDrive()
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showRenameModal && (
|
||||
<RenameFileModal
|
||||
currentName={fileInfo.name}
|
||||
takenNames={(() => {
|
||||
const sameDrive = files.filter(fi => fi.driveId.toString() === currentDrive.id.toString())
|
||||
const names = sameDrive.map(fi => fi.name).filter(n => n && n !== fileInfo.name)
|
||||
|
||||
return new Set(names)
|
||||
})()}
|
||||
onCancelClick={() => {
|
||||
setShowRenameModal(false)
|
||||
}}
|
||||
onProceed={async newName => {
|
||||
try {
|
||||
setShowRenameModal(false)
|
||||
await doRename(newName)
|
||||
} catch {
|
||||
safeSetState(isMountedRef, setShowRenameModal)(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirmForget && (
|
||||
<ConfirmModal
|
||||
title="Forget permanently?"
|
||||
message={
|
||||
<>
|
||||
This removes <b title={fileInfo.name}>{fileInfo.name}</b> from your view.
|
||||
<br />
|
||||
The data remains on Swarm until the drive expires.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Forget"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={async () => {
|
||||
await doForget()
|
||||
|
||||
safeSetState(isMountedRef, setConfirmForget)(false)
|
||||
}}
|
||||
onCancel={() => {
|
||||
setConfirmForget(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDestroyDriveModal && destroyDrive && (
|
||||
<DestroyDriveModal
|
||||
drive={destroyDrive}
|
||||
onCancelClick={() => {
|
||||
setShowDestroyDriveModal(false)
|
||||
setDestroyDrive(null)
|
||||
}}
|
||||
doDestroy={async () => {
|
||||
setShowDestroyDriveModal(false)
|
||||
|
||||
await handleDestroyDrive(
|
||||
beeApi,
|
||||
fm,
|
||||
destroyDrive,
|
||||
() => {
|
||||
setShowDestroyDriveModal(false)
|
||||
setDestroyDrive(null)
|
||||
},
|
||||
e => {
|
||||
setShowDestroyDriveModal(false)
|
||||
setErrorMessage?.(`Error destroying drive: ${destroyDrive.name}: ${e}`)
|
||||
setShowError(true)
|
||||
},
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
.fm-file-progress-notification {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
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
|
||||
}
|
||||
|
||||
interface FileProgressNotificationProps {
|
||||
label?: string
|
||||
type: FileTransferType
|
||||
open?: boolean
|
||||
count?: number
|
||||
items?: ProgressItem[]
|
||||
onRowClose?: (name: string) => void
|
||||
onCloseAll?: () => void
|
||||
}
|
||||
|
||||
export function FileProgressNotification({
|
||||
label,
|
||||
type,
|
||||
open,
|
||||
count,
|
||||
items,
|
||||
onRowClose,
|
||||
onCloseAll,
|
||||
}: FileProgressNotificationProps): ReactElement | null {
|
||||
const [showFileProgressWindow, setShowFileProgressWindow] = useState(Boolean(open))
|
||||
const [openedByUser, setOpenedByUser] = useState(false)
|
||||
const autoHideTimer = useRef<number | null>(null)
|
||||
|
||||
const allDone = useMemo(() => {
|
||||
if (!items || items.length === 0) return false
|
||||
|
||||
return items.every(i => (typeof i.percent === 'number' ? i.percent >= 100 : i.status === TransferStatus.Done))
|
||||
}, [items])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setShowFileProgressWindow(true)
|
||||
setOpenedByUser(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (autoHideTimer.current) {
|
||||
window.clearTimeout(autoHideTimer.current)
|
||||
autoHideTimer.current = null
|
||||
}
|
||||
|
||||
if (showFileProgressWindow && allDone && !openedByUser) {
|
||||
autoHideTimer.current = window.setTimeout(() => {
|
||||
setShowFileProgressWindow(false)
|
||||
autoHideTimer.current = null
|
||||
}, 3000) as unknown as number
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (autoHideTimer.current) {
|
||||
window.clearTimeout(autoHideTimer.current)
|
||||
autoHideTimer.current = null
|
||||
}
|
||||
}
|
||||
}, [showFileProgressWindow, allDone, openedByUser])
|
||||
|
||||
const handleOpenClick = () => {
|
||||
setOpenedByUser(true)
|
||||
setShowFileProgressWindow(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div className="fm-file-progress-notification" onClick={handleOpenClick} role="button" aria-label={label}>
|
||||
<span>{label}</span>
|
||||
{type === FileTransferType.Upload && <UpIcon size="16px" style={{ marginLeft: 6 }} />}
|
||||
{type === FileTransferType.Download && <DownIcon size="16px" style={{ marginLeft: 6 }} />}
|
||||
</div>
|
||||
|
||||
{showFileProgressWindow && (
|
||||
<FileProgressWindow
|
||||
numberOfFiles={items && items.length ? undefined : count}
|
||||
items={items}
|
||||
type={type}
|
||||
onCancelClick={() => setShowFileProgressWindow(false)}
|
||||
onRowClose={onRowClose}
|
||||
onCloseAll={() => {
|
||||
onCloseAll?.()
|
||||
setShowFileProgressWindow(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
.fm-file-progress-window {
|
||||
position: absolute;
|
||||
border: 1px solid rgb(209, 213, 219);
|
||||
width: 275px;
|
||||
bottom: 45px;
|
||||
background-color: white;
|
||||
z-index: 1200;
|
||||
}
|
||||
|
||||
.fm-file-progress-window-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid rgb(209, 213, 219);
|
||||
}
|
||||
|
||||
.fm-file-progress-window-header-actions { display: inline-flex; gap: 6px; }
|
||||
|
||||
.fm-file-progress-window-header-btn {
|
||||
width: 22px; height: 22px; display: inline-grid; place-items: center;
|
||||
padding: 0; margin: 0; background: #f0f0f0; color: #4b5563;
|
||||
border: none; border-radius: 4px; cursor: pointer;
|
||||
&:hover { background: #e5e7eb; } &:active { background: #d1d5db; }
|
||||
&:disabled { cursor: default; opacity: .6; filter: grayscale(.3); }
|
||||
}
|
||||
|
||||
.fm-file-progress-window-file-item {
|
||||
display: flex; align-items: flex-start; gap: 8px;
|
||||
padding: 12px; border-bottom: 1px solid rgb(243, 244, 246);
|
||||
}
|
||||
|
||||
.fm-file-progress-window-file-type-icon { margin-top: 4px; }
|
||||
|
||||
.fm-file-progress-window-file-datas {
|
||||
display: flex; flex-direction: column; gap: 8px; width: 100%;
|
||||
}
|
||||
|
||||
.fm-file-progress-window-file-item-header {
|
||||
display: grid; grid-template-columns: 1fr auto auto;
|
||||
align-items: center; gap: 8px; min-width: 0;
|
||||
}
|
||||
|
||||
.fm-file-progress-window-name { min-width: 0; }
|
||||
.fm-file-progress-window-name-text {
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.fm-drive-line { margin-top: 2px; }
|
||||
|
||||
.fm-file-progress-window-percent { white-space: nowrap; }
|
||||
|
||||
.fm-file-progress-window-file-item-footer {
|
||||
display: grid; grid-template-columns: auto 1fr auto;
|
||||
align-items: center; column-gap: 8px; font-size: 11px;
|
||||
}
|
||||
.fm-file-progress-window-size { white-space: nowrap; }
|
||||
.fm-file-progress-window-center { justify-self: center; white-space: nowrap; }
|
||||
.fm-file-progress-window-status { justify-self: end; white-space: nowrap; }
|
||||
|
||||
.fm-file-progress-window-row-close {
|
||||
width: 20px; height: 20px; display: inline-grid; place-items: center;
|
||||
padding: 0; margin: 0; background: #f0f0f0; color: #4b5563;
|
||||
border: none; border-radius: 4px; cursor: pointer;
|
||||
&:hover { background: #e5e7eb; } &:active { background: #d1d5db; }
|
||||
&:disabled { cursor: default; opacity: .6; filter: grayscale(.3); }
|
||||
}
|
||||
|
||||
.fm-drive-chip {
|
||||
display: inline-block; margin-left: 0; padding: 2px 6px;
|
||||
border-radius: 999px; font-size: 11px; line-height: 1;
|
||||
background: rgba(0,0,0,.06); color: #333; vertical-align: middle;
|
||||
}
|
||||
.fm-eta { font-size: 12px; opacity: .8; }
|
||||
.fm-file-subtext { line-height: 1.2; }
|
||||
|
||||
.fm-file-progress-window-list {
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { ReactElement, useLayoutEffect, useRef } from 'react'
|
||||
import CloseIcon from 'remixicon-react/CloseLineIcon'
|
||||
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
|
||||
}
|
||||
|
||||
interface FileProgressWindowProps {
|
||||
numberOfFiles?: number
|
||||
items?: ProgressItem[]
|
||||
type: FileTransferType
|
||||
onCancelClick: () => void
|
||||
onRowClose?: (name: string) => void
|
||||
onCloseAll?: () => void
|
||||
}
|
||||
|
||||
const formatEta = (sec?: number) => {
|
||||
if (sec === undefined || sec === null) return ''
|
||||
|
||||
if (sec <= 0) return 'Done'
|
||||
const s = Math.ceil(sec)
|
||||
const mm = Math.floor(s / 60)
|
||||
const ss = s % 60
|
||||
|
||||
return mm > 0 ? `${mm}m ${ss}s left` : `${ss}s left`
|
||||
}
|
||||
|
||||
const formatDuration = (sec?: number) => {
|
||||
if (sec === undefined || sec === null) return ''
|
||||
const s = Math.max(0, Math.round(sec))
|
||||
const mm = Math.floor(s / 60)
|
||||
const ss = s % 60
|
||||
|
||||
return mm > 0 ? `${mm}m ${ss}s` : `${ss}s`
|
||||
}
|
||||
|
||||
export function FileProgressWindow({
|
||||
numberOfFiles,
|
||||
items,
|
||||
type,
|
||||
onCancelClick,
|
||||
onRowClose,
|
||||
onCloseAll,
|
||||
}: 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 getTransferInfo = (item: ProgressItem, pct?: number) => {
|
||||
const transferType = capitalizeFirstLetter(item?.kind ?? type)
|
||||
const verb = `${transferType}ing`
|
||||
const actualStatus = item.status || (pct && pct >= 100 ? TransferStatus.Done : verb)
|
||||
|
||||
return {
|
||||
statusText: capitalizeFirstLetter(actualStatus),
|
||||
barColor: TransferBarColor[transferType as keyof typeof TransferBarColor],
|
||||
}
|
||||
}
|
||||
|
||||
const allDone =
|
||||
rows.length > 0 &&
|
||||
rows.every(r => {
|
||||
const pct = Number.isFinite(r.percent) ? Math.round(r.percent as number) : undefined
|
||||
|
||||
return (
|
||||
r.status === TransferStatus.Done ||
|
||||
r.status === TransferStatus.Error ||
|
||||
r.status === TransferStatus.Cancelled ||
|
||||
(typeof pct === 'number' && pct >= 100)
|
||||
)
|
||||
})
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const rowEl = firstRowRef.current
|
||||
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`
|
||||
}, [rows.length])
|
||||
|
||||
return (
|
||||
<div className="fm-file-progress-window">
|
||||
<div className="fm-file-progress-window-header">
|
||||
<div className="fm-emphasized-text">
|
||||
{count} {type}
|
||||
{count === 1 ? '' : 's'}
|
||||
</div>
|
||||
|
||||
<div className="fm-file-progress-window-header-actions">
|
||||
<button
|
||||
className="fm-file-progress-window-header-btn fm-file-progress-window-header-dismiss"
|
||||
aria-label="Dismiss all"
|
||||
type="button"
|
||||
disabled={!allDone}
|
||||
onClick={() => onCloseAll?.()}
|
||||
>
|
||||
<CloseIcon size="16" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="fm-file-progress-window-header-btn fm-file-progress-window-header-hide"
|
||||
aria-label="Hide"
|
||||
type="button"
|
||||
onClick={onCancelClick}
|
||||
>
|
||||
<ArrowDownIcon size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-file-progress-window-list" ref={listRef}>
|
||||
{rows.map((item, idx) => {
|
||||
const pctNum = Number.isFinite(item.percent)
|
||||
? Math.max(0, Math.min(100, Math.round(item.percent as number)))
|
||||
: undefined
|
||||
|
||||
const isComplete = (pctNum ?? 0) >= 100 || item.status === TransferStatus.Done
|
||||
const isActive =
|
||||
item.status === TransferStatus.Uploading ||
|
||||
item.status === TransferStatus.Downloading ||
|
||||
item.status === TransferStatus.Queued
|
||||
|
||||
const rowActionLabel = isActive ? 'Cancel' : 'Dismiss'
|
||||
|
||||
const transferInfo = getTransferInfo(item, pctNum)
|
||||
|
||||
const getCenterText = () => {
|
||||
if (!isComplete && typeof item.etaSec === 'number') return formatEta(item.etaSec)
|
||||
|
||||
if (isComplete && typeof item.elapsedSec === 'number') return formatDuration(item.elapsedSec)
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const centerDisplay = getCenterText() || '\u00A0'
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fm-file-progress-window-file-item"
|
||||
key={`${item.name}`}
|
||||
ref={idx === 0 ? firstRowRef : undefined}
|
||||
>
|
||||
<div className="fm-file-progress-window-file-type-icon">
|
||||
<GetIconElement size="14" icon={item.name} 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>
|
||||
{item.driveName && (
|
||||
<div className="fm-drive-line">
|
||||
<span className="fm-drive-chip" title={`Drive: ${item.driveName}`}>
|
||||
{item.driveName}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fm-file-progress-window-percent" aria-live="polite">
|
||||
{typeof pctNum === 'number' ? `${pctNum}%` : ''}
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="fm-file-progress-window-row-close"
|
||||
aria-label={rowActionLabel}
|
||||
onClick={() => onRowClose?.(item.name)}
|
||||
type="button"
|
||||
>
|
||||
<CloseIcon size="14" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
value={typeof pctNum === 'number' ? pctNum : 0}
|
||||
width="100%"
|
||||
backgroundColor="rgb(229, 231, 235)"
|
||||
color={transferInfo.barColor}
|
||||
/>
|
||||
|
||||
<div className="fm-file-progress-window-file-item-footer">
|
||||
<div className="fm-file-progress-window-size">{item.size || '—'}</div>
|
||||
<div className="fm-file-progress-window-center">{centerDisplay}</div>
|
||||
<div className="fm-file-progress-window-status">{transferInfo.statusText}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import formbricks from '@formbricks/js'
|
||||
|
||||
const FM_CLICK_STORAGE_KEY = 'fm_click_count_v1'
|
||||
const FM_SURVEY_TRIGGERED_KEY = 'fm_survey_triggered_v1'
|
||||
const FM_CLICK_THRESHOLD = 25
|
||||
|
||||
interface FormbricksIntegrationProps {
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export function FormbricksIntegration({ isActive }: FormbricksIntegrationProps) {
|
||||
const location = useLocation()
|
||||
const formbricksInitRef = useRef(false)
|
||||
const formbricksReadyRef = useRef(false)
|
||||
const pendingEventRef = useRef(false)
|
||||
|
||||
const environmentId = process.env.REACT_APP_FORMBRICKS_ENV_ID
|
||||
const appUrl = process.env.REACT_APP_FORMBRICKS_APP_URL
|
||||
|
||||
const flushPendingEvent = useCallback(() => {
|
||||
if (pendingEventRef.current && localStorage.getItem(FM_SURVEY_TRIGGERED_KEY) !== 'true') {
|
||||
try {
|
||||
formbricks.track('file_manager_engagement_25_clicks')
|
||||
localStorage.setItem(FM_SURVEY_TRIGGERED_KEY, 'true')
|
||||
pendingEventRef.current = false
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!environmentId || !appUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const initializeFormbricks = async () => {
|
||||
try {
|
||||
await formbricks.setup({
|
||||
environmentId,
|
||||
appUrl,
|
||||
})
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
|
||||
if (!cancelled) {
|
||||
formbricksReadyRef.current = true
|
||||
formbricksInitRef.current = true
|
||||
flushPendingEvent()
|
||||
}
|
||||
} catch {
|
||||
formbricksReadyRef.current = false
|
||||
formbricksInitRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
void initializeFormbricks()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [environmentId, appUrl, flushPendingEvent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formbricksInitRef.current) return
|
||||
|
||||
try {
|
||||
formbricks?.registerRouteChange()
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}, [location])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isActive) return
|
||||
|
||||
const handleClick = async () => {
|
||||
if (localStorage.getItem(FM_SURVEY_TRIGGERED_KEY) === 'true') return
|
||||
|
||||
let count = 0
|
||||
try {
|
||||
const stored = localStorage.getItem(FM_CLICK_STORAGE_KEY)
|
||||
|
||||
if (stored) count = parseInt(stored, 10) || 0
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
count += 1
|
||||
try {
|
||||
localStorage.setItem(FM_CLICK_STORAGE_KEY, String(count))
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
|
||||
if (count >= FM_CLICK_THRESHOLD) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('filemanager-25-clicks', {
|
||||
detail: { count, formbricksReady: formbricksReadyRef.current },
|
||||
}),
|
||||
)
|
||||
|
||||
if (!formbricksReadyRef.current) {
|
||||
pendingEventRef.current = true
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await formbricks.track('file_manager_engagement_25_clicks')
|
||||
localStorage.setItem(FM_SURVEY_TRIGGERED_KEY, 'true')
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootEl = document.querySelector('.fm-main')
|
||||
|
||||
if (rootEl) {
|
||||
rootEl.addEventListener('click', handleClick)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (rootEl) {
|
||||
rootEl.removeEventListener('click', handleClick)
|
||||
}
|
||||
}
|
||||
}, [isActive])
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
.fm-modal-window.fm-get-info-modal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: clamp(320px, calc(100vh - 96px), 90vh);
|
||||
}
|
||||
.fm-modal-window.fm-get-info-modal .fm-modal-window-header,
|
||||
.fm-modal-window.fm-get-info-modal .fm-modal-window-footer {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.fm-modal-window.fm-get-info-modal .fm-modal-window-body,
|
||||
.fm-get-info-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
overscroll-behavior: contain;
|
||||
padding-right: 2px;
|
||||
}
|
||||
.fm-modal-window.fm-get-info-modal .fm-modal-window-body::-webkit-scrollbar,
|
||||
.fm-get-info-body::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
.fm-modal-window.fm-get-info-modal .fm-modal-window-body::-webkit-scrollbar-thumb,
|
||||
.fm-get-info-body::-webkit-scrollbar-thumb {
|
||||
background: #d1d5db;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.fm-modal-window.fm-get-info-modal .fm-modal-window-body::-webkit-scrollbar-track,
|
||||
.fm-get-info-body::-webkit-scrollbar-track {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.fm-get-info-modal-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.fm-get-info-modal-group-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fm-get-info-modal-group-properties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-get-info-modal-property-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-get-info-modal-property-label {
|
||||
color: #555;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.fm-get-info-modal-property-value {
|
||||
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;
|
||||
}
|
||||
|
||||
.fm-copy-btn {
|
||||
margin-left: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.fm-copy-btn:hover {
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { ReactElement, useState } from 'react'
|
||||
import './GetInfoModal.scss'
|
||||
import { Button } from '../Button/Button'
|
||||
import { createPortal } from 'react-dom'
|
||||
import InfoIcon from 'remixicon-react/InformationLineIcon'
|
||||
import ClipboardIcon from 'remixicon-react/FileCopyLineIcon'
|
||||
|
||||
import type { FileProperty, FilePropertyGroup } from '../../utils/infoGroups'
|
||||
|
||||
interface GetInfoModalProps {
|
||||
name: string
|
||||
properties: FilePropertyGroup[]
|
||||
onCancelClick: () => void
|
||||
}
|
||||
|
||||
export function GetInfoModal({ name, onCancelClick, properties }: GetInfoModalProps): ReactElement {
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
const handleCopy = async (prop: FileProperty) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(prop.raw ?? prop.value)
|
||||
setCopiedKey(prop.key)
|
||||
window.setTimeout(() => setCopiedKey(null), 1200)
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window fm-get-info-modal">
|
||||
<div className="fm-modal-window-header">
|
||||
<InfoIcon /> <span className="fm-main-font-color">File Information - {name}</span>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-body fm-get-info-body">
|
||||
{properties.map(group => (
|
||||
<div key={group.title} className="fm-get-info-modal-group">
|
||||
<div className="fm-get-info-modal-group-title">
|
||||
{group.icon}
|
||||
{group.title}
|
||||
</div>
|
||||
|
||||
<div className="fm-get-info-modal-group-properties">
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-footer">
|
||||
<div className="fm-get-info-modal-footer-one-button">
|
||||
<Button label="Close" variant="secondary" onClick={onCancelClick} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
$bg-900: #212121;
|
||||
$bg-800: #262626;
|
||||
$bg-700: #3e3e3e;
|
||||
$border-400: #9da3ae;
|
||||
$text-100: #e5e7eb;
|
||||
$text-300: #c7ccd4;
|
||||
$accent: #ed8131;
|
||||
|
||||
.fm-header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
height: 60px;
|
||||
padding: 10px 16px;
|
||||
background: $bg-900;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.fm-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.fm-header-logo {
|
||||
width: 40px; height: 40px;
|
||||
border-radius: 6px;
|
||||
background: $accent;
|
||||
color: $text-100;
|
||||
display: grid; place-items: center;
|
||||
font-weight: 700;
|
||||
svg { width: 18px; height: 18px; }
|
||||
}
|
||||
.fm-header-title {
|
||||
color: $text-100;
|
||||
font-weight: 600;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
|
||||
.fm-header-search {
|
||||
flex: 1 1 auto;
|
||||
max-width: 900px;
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: $bg-700;
|
||||
border: 1px solid $border-400;
|
||||
color: $text-300;
|
||||
height: 36px; padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $accent;
|
||||
box-shadow: 0 0 0 2px rgba(237,129,49,0.25);
|
||||
}
|
||||
|
||||
.fm-header-search-icon { flex: 0 0 auto; }
|
||||
|
||||
input {
|
||||
flex: 1 1 auto;
|
||||
background: transparent; border: none; outline: none;
|
||||
height: 100%;
|
||||
color: $text-100; font-size: 14px;
|
||||
|
||||
&::placeholder { color: $text-300; }
|
||||
}
|
||||
|
||||
.fm-header-search-clear {
|
||||
appearance: none; border: none; background: transparent;
|
||||
color: $text-300; font-size: 18px; line-height: 1;
|
||||
padding: 0 2px; cursor: pointer;
|
||||
&:hover { color: $text-100; }
|
||||
}
|
||||
}
|
||||
|
||||
.fm-header-actions {
|
||||
margin-left: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-filter-btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid $border-400;
|
||||
background: $bg-800;
|
||||
color: $text-100;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: mix($bg-800, #fff, 92%); }
|
||||
&:focus-visible { outline: 2px solid rgba(237,129,49,0.4); outline-offset: 2px; }
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
border-color: $accent;
|
||||
box-shadow: 0 0 0 2px rgba(237,129,49,0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-filter-menu {
|
||||
position: absolute;
|
||||
right: 0; top: calc(100% + 6px);
|
||||
min-width: 260px;
|
||||
background: $bg-800;
|
||||
border: 1px solid $border-400;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 24px rgba(0,0,0,0.25);
|
||||
padding: 10px;
|
||||
z-index: 2000;
|
||||
color: $text-100;
|
||||
}
|
||||
|
||||
.fm-filter-group + .fm-filter-group { margin-top: 10px; }
|
||||
|
||||
.fm-filter-group-title {
|
||||
font-size: 12px;
|
||||
color: $text-300;
|
||||
margin-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
}
|
||||
|
||||
.fm-filter-row {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 6px 4px; border-radius: 6px;
|
||||
cursor: default;
|
||||
color: $text-100;
|
||||
|
||||
input[type="checkbox"], input[type="radio"] {
|
||||
width: 14px; height: 14px; margin: 0;
|
||||
accent-color: $accent;
|
||||
}
|
||||
|
||||
&:hover { background: rgba(255,255,255,0.05); }
|
||||
}
|
||||
|
||||
.fm-filter-sep {
|
||||
height: 1px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.fm-header-filters { display: none; }
|
||||
.fm-header-filters-label { display: none; }
|
||||
.fm-header-chip-group { display: none; }
|
||||
.fm-chip { display: none; }
|
||||
@@ -0,0 +1,200 @@
|
||||
import { ReactElement, useMemo, useState, useEffect, useRef, useContext } from 'react'
|
||||
import SearchIcon from 'remixicon-react/SearchLineIcon'
|
||||
import FileIcon from 'remixicon-react/File2LineIcon'
|
||||
import FilterIcon from 'remixicon-react/FilterLineIcon'
|
||||
import './Header.scss'
|
||||
import { useSearch } from '../../../../pages/filemanager/SearchContext'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
|
||||
// Defaults used to determine “active filters”
|
||||
const DEFAULT_FILTERS = {
|
||||
scope: 'selected' as 'selected' | 'all',
|
||||
includeActive: true,
|
||||
includeTrashed: false,
|
||||
}
|
||||
|
||||
export function Header(): ReactElement {
|
||||
const {
|
||||
query,
|
||||
setQuery,
|
||||
clear,
|
||||
scope,
|
||||
setScope,
|
||||
includeActive,
|
||||
setIncludeActive,
|
||||
includeTrashed,
|
||||
setIncludeTrashed,
|
||||
} = useSearch()
|
||||
|
||||
const { currentDrive } = useContext(FMContext)
|
||||
|
||||
const currentDriveName = useMemo(() => {
|
||||
return currentDrive?.name || ''
|
||||
}, [currentDrive])
|
||||
|
||||
const [openFilters, setOpenFilters] = useState(false)
|
||||
const menuRef = useRef<HTMLDivElement | null>(null)
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null)
|
||||
|
||||
const filtersActive = useMemo(() => {
|
||||
return (
|
||||
scope !== DEFAULT_FILTERS.scope ||
|
||||
includeActive !== DEFAULT_FILTERS.includeActive ||
|
||||
includeTrashed !== DEFAULT_FILTERS.includeTrashed
|
||||
)
|
||||
}, [scope, includeActive, includeTrashed])
|
||||
|
||||
const resetFilters = () => {
|
||||
setScope(DEFAULT_FILTERS.scope)
|
||||
setIncludeActive(DEFAULT_FILTERS.includeActive)
|
||||
setIncludeTrashed(DEFAULT_FILTERS.includeTrashed)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!openFilters) return
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
const t = e.target as Node
|
||||
|
||||
if (menuRef.current?.contains(t) || btnRef.current?.contains(t)) return
|
||||
setOpenFilters(false)
|
||||
}
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpenFilters(false)
|
||||
}
|
||||
document.addEventListener('mousedown', onDocClick)
|
||||
document.addEventListener('keydown', onEsc)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onDocClick)
|
||||
document.removeEventListener('keydown', onEsc)
|
||||
}
|
||||
}, [openFilters])
|
||||
|
||||
return (
|
||||
<div className="fm-header-container">
|
||||
<div className="fm-header-left">
|
||||
<div className="fm-header-logo" aria-hidden>
|
||||
<FileIcon />
|
||||
</div>
|
||||
<div className="fm-header-title">File Manager</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-header-search">
|
||||
<SearchIcon className="fm-header-search-icon" size="16px" aria-hidden />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search files by name or type…"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') clear()
|
||||
}}
|
||||
aria-label="Search files"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
type="button"
|
||||
className="fm-header-search-clear"
|
||||
aria-label="Clear search"
|
||||
onClick={clear}
|
||||
title="Clear"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fm-header-actions">
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className="fm-filter-btn"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={openFilters}
|
||||
onClick={() => setOpenFilters(v => !v)}
|
||||
title={filtersActive ? 'Filters (active)' : 'Filters'}
|
||||
style={{ color: filtersActive ? 'orange' : undefined }}
|
||||
>
|
||||
<FilterIcon size="16px" />
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
|
||||
Filters
|
||||
{filtersActive && (
|
||||
<span
|
||||
aria-label="Filters active"
|
||||
title="Filters active"
|
||||
// tiny inline badge, no external CSS
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 11,
|
||||
lineHeight: 1,
|
||||
padding: '0 4px',
|
||||
borderRadius: 8,
|
||||
border: '1px solid orange',
|
||||
color: 'orange',
|
||||
marginLeft: 2,
|
||||
}}
|
||||
>
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{openFilters && (
|
||||
<div className="fm-filter-menu" role="menu" ref={menuRef}>
|
||||
<div className="fm-filter-group" role="radiogroup" aria-label="Search scope">
|
||||
<div className="fm-filter-group-title">Scope</div>
|
||||
<label className="fm-filter-row">
|
||||
<input
|
||||
type="radio"
|
||||
name="fm-scope"
|
||||
checked={scope === 'selected'}
|
||||
onChange={() => setScope('selected')}
|
||||
/>
|
||||
<span title={currentDriveName ? `Search in ${currentDriveName}` : 'Search in selected drive'}>
|
||||
Selected{currentDriveName ? ` — ${currentDriveName}` : ''}
|
||||
</span>
|
||||
</label>
|
||||
<label className="fm-filter-row">
|
||||
<input type="radio" name="fm-scope" checked={scope === 'all'} onChange={() => setScope('all')} />
|
||||
<span>All drives</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="fm-filter-sep" />
|
||||
|
||||
<div className="fm-filter-group" aria-label="Status">
|
||||
<div className="fm-filter-group-title">Status</div>
|
||||
<label className="fm-filter-row">
|
||||
<input type="checkbox" checked={includeActive} onChange={e => setIncludeActive(e.target.checked)} />
|
||||
<span>Active</span>
|
||||
</label>
|
||||
<label className="fm-filter-row">
|
||||
<input type="checkbox" checked={includeTrashed} onChange={e => setIncludeTrashed(e.target.checked)} />
|
||||
<span>Trash</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="fm-filter-sep" />
|
||||
|
||||
<div className="fm-filter-group" role="group" aria-label="Reset">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
title="Reset filters to default"
|
||||
style={{
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid #ccc',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.fm-initialization-modal-container {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
background: rgba(237, 237, 237);
|
||||
backdrop-filter: blur(5px);
|
||||
z-index: 1300;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-initilization-progress-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { ReactElement, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { BZZ, DAI, Duration, PostageBatch, RedundancyLevel, Size, Utils } from '@ethersphere/bee-js'
|
||||
import './InitialModal.scss'
|
||||
import { CustomDropdown } from '../CustomDropdown/CustomDropdown'
|
||||
import { Button } from '../Button/Button'
|
||||
import { calculateStampCapacityMetrics, fmFetchCost, getUsableStamps, handleCreateDrive } from '../../utils/bee'
|
||||
import { getExpiryDateByLifetime, safeSetState } from '../../utils/common'
|
||||
import { erasureCodeMarks } from '../../constants/common'
|
||||
import { desiredLifetimeOptions } from '../../constants/stamps'
|
||||
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||
import { Context as BeeContext } from '../../../../providers/Bee'
|
||||
|
||||
import { FMSlider } from '../Slider/Slider'
|
||||
import { Context as FMContext } from '../../../../providers/FileManager'
|
||||
import { ADMIN_STAMP_LABEL } from '@solarpunkltd/file-manager-lib'
|
||||
import { ProgressBar } from '../ProgressBar/ProgressBar'
|
||||
import { Tooltip } from '../Tooltip/Tooltip'
|
||||
import { TOOLTIPS } from '../../constants/tooltips'
|
||||
|
||||
interface InitialModalProps {
|
||||
resetState: boolean
|
||||
handleVisibility: (isVisible: boolean) => void
|
||||
handleShowError: (flag: boolean) => void
|
||||
setIsCreationInProgress: (isCreating: boolean) => void
|
||||
}
|
||||
|
||||
const minMarkValue = Math.min(...erasureCodeMarks.map(mark => mark.value))
|
||||
const maxMarkValue = Math.max(...erasureCodeMarks.map(mark => mark.value))
|
||||
|
||||
const BATCH_ID_PLACEHOLDER = 'Choose a saved Drive, or leave blank to create a new one'
|
||||
|
||||
const createBatchIdOptions = (stamps: PostageBatch[]) => [
|
||||
{ label: BATCH_ID_PLACEHOLDER, value: -1 },
|
||||
...stamps.map((stamp, index) => {
|
||||
const batchId = stamp.batchID.toHex().slice(0, 8)
|
||||
const label = `${batchId}${stamp.label ? ` - ${stamp.label}` : ''}`
|
||||
|
||||
return {
|
||||
label,
|
||||
value: index,
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
export function InitialModal({
|
||||
resetState,
|
||||
setIsCreationInProgress,
|
||||
handleVisibility,
|
||||
handleShowError,
|
||||
}: InitialModalProps): 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(0)
|
||||
const [validityEndDate, setValidityEndDate] = useState(new Date())
|
||||
const [erasureCodeLevel, setErasureCodeLevel] = useState(RedundancyLevel.OFF)
|
||||
const [cost, setCost] = useState('0')
|
||||
const [usableStamps, setUsableStamps] = useState<PostageBatch[]>([])
|
||||
const [selectedBatch, setSelectedBatch] = useState<PostageBatch | null>(null)
|
||||
const [selectedBatchIndex, setSelectedBatchIndex] = useState<number>(-1)
|
||||
|
||||
const { walletBalance } = useContext(BeeContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { fm } = useContext(FMContext)
|
||||
|
||||
const currentFetch = useRef<Promise<void> | null>(null)
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const createAdminDrive = useCallback(async () => {
|
||||
setIsCreationInProgress?.(true)
|
||||
handleVisibility(false)
|
||||
|
||||
await handleCreateDrive(
|
||||
beeApi,
|
||||
fm,
|
||||
Size.fromBytes(capacity),
|
||||
Duration.fromEndDate(validityEndDate),
|
||||
ADMIN_STAMP_LABEL,
|
||||
false,
|
||||
erasureCodeLevel,
|
||||
true,
|
||||
resetState,
|
||||
selectedBatch,
|
||||
() => {
|
||||
handleVisibility(false)
|
||||
setIsCreationInProgress(false)
|
||||
}, // onSuccess
|
||||
() => {
|
||||
handleShowError(true)
|
||||
setIsCreationInProgress(false)
|
||||
}, // onError
|
||||
)
|
||||
}, [
|
||||
beeApi,
|
||||
fm,
|
||||
capacity,
|
||||
validityEndDate,
|
||||
erasureCodeLevel,
|
||||
selectedBatch,
|
||||
handleVisibility,
|
||||
handleShowError,
|
||||
setIsCreationInProgress,
|
||||
resetState,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
const getStamps = async () => {
|
||||
const stamps = (await getUsableStamps(beeApi)).filter(s => {
|
||||
const { capacityPct } = calculateStampCapacityMetrics(s)
|
||||
|
||||
return capacityPct < 100
|
||||
})
|
||||
|
||||
safeSetState(isMountedRef, setUsableStamps)([...stamps])
|
||||
}
|
||||
|
||||
if (beeApi) {
|
||||
getStamps()
|
||||
}
|
||||
}, [beeApi])
|
||||
|
||||
useEffect(() => {
|
||||
const newSizes = Array.from(Utils.getStampEffectiveBytesBreakpoints(false, erasureCodeLevel).values())
|
||||
|
||||
setCapacity(newSizes[2])
|
||||
}, [erasureCodeLevel])
|
||||
|
||||
useEffect(() => {
|
||||
if (validityEndDate.getTime() > new Date().getTime()) {
|
||||
fmFetchCost(
|
||||
capacity,
|
||||
validityEndDate,
|
||||
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))
|
||||
},
|
||||
currentFetch,
|
||||
)
|
||||
|
||||
if (lifetimeIndex >= 0) {
|
||||
setIsCreateEnabled(true)
|
||||
}
|
||||
} else {
|
||||
setCost('0')
|
||||
setIsCreateEnabled(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [validityEndDate, beeApi, capacity, lifetimeIndex, walletBalance])
|
||||
|
||||
useEffect(() => {
|
||||
setValidityEndDate(getExpiryDateByLifetime(lifetimeIndex))
|
||||
}, [lifetimeIndex])
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedBatchIndex >= 0 && selectedBatchIndex < usableStamps.length) {
|
||||
setSelectedBatch(usableStamps[selectedBatchIndex])
|
||||
} else {
|
||||
setSelectedBatch(null)
|
||||
}
|
||||
}, [usableStamps, selectedBatchIndex])
|
||||
|
||||
const { capacityPct, usedSize, totalSize } = useMemo(
|
||||
() => calculateStampCapacityMetrics(selectedBatch),
|
||||
[selectedBatch],
|
||||
)
|
||||
|
||||
const initText = resetState ? 'Resetting' : 'Initializing'
|
||||
const createText = resetState ? 'Reset' : 'Create'
|
||||
|
||||
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 && (
|
||||
<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)}
|
||||
value={selectedBatchIndex}
|
||||
label="Link an existing Admin Drive (optional)"
|
||||
onChange={(index: number) => {
|
||||
setSelectedBatchIndex(index)
|
||||
|
||||
if (index === -1) {
|
||||
setSelectedBatch(null)
|
||||
}
|
||||
}}
|
||||
placeholder={BATCH_ID_PLACEHOLDER}
|
||||
/>
|
||||
{selectedBatch && (
|
||||
<div className="fm-drive-item-content">
|
||||
<div className="fm-drive-item-capacity">
|
||||
Capacity <ProgressBar value={capacityPct} width="64px" /> {usedSize} / {totalSize}
|
||||
</div>
|
||||
<div className="fm-drive-item-capacity">
|
||||
Expiry date: {selectedBatch.duration.toEndDate().toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!selectedBatch && (
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-window-input-container">
|
||||
<label htmlFor="admin-desired-lifetime" className="fm-input-label">
|
||||
Create a new Admin Drive with desired lifetime: <Tooltip label={TOOLTIPS.ADMIN_DESIRED_LIFETIME} />
|
||||
</label>
|
||||
<CustomDropdown
|
||||
id="admin-desired-lifetime"
|
||||
options={desiredLifetimeOptions}
|
||||
value={lifetimeIndex}
|
||||
onChange={setLifetimeIndex}
|
||||
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>
|
||||
<div className="fm-modal-window-input-container">
|
||||
<div className="fm-modal-estimated-cost-container">
|
||||
<div className="fm-emphasized-text">Estimated Cost:</div>
|
||||
<div>
|
||||
{cost} BZZ {isBalanceSufficient ? '' : '(Insufficient balance)'}
|
||||
{isxDaiBalanceSufficient ? '' : ' (Insufficient xDAI balance)'}
|
||||
</div>
|
||||
<Tooltip label={TOOLTIPS.ADMIN_ESTIMATED_COST} />
|
||||
</div>
|
||||
<div>(Based on current network conditions)</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="fm-modal-window-footer">
|
||||
<Button
|
||||
label={selectedBatch ? `${createText} Drive` : `Purchase Stamp & ${createText} Drive`}
|
||||
variant="primary"
|
||||
disabled={selectedBatch ? false : !isCreateEnabled || !isBalanceSufficient || !isxDaiBalanceSufficient}
|
||||
onClick={createAdminDrive}
|
||||
/>
|
||||
<Tooltip
|
||||
label={
|
||||
selectedBatch
|
||||
? TOOLTIPS.ADMIN_PURCHASE_BUTTON_ALREADY_EXISTED_ADMIN_DRIVE
|
||||
: TOOLTIPS.ADMIN_PURCHASE_BUTTON
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
.fm-notification-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ReactElement, useContext, useEffect, useState } from 'react'
|
||||
import './NotificationBar.scss'
|
||||
import UpIcon from 'remixicon-react/ArrowUpSLineIcon'
|
||||
import { ExpiringNotificationModal } from '../ExpiringNotificationModal/ExpiringNotificationModal'
|
||||
import { getUsableStamps } from '../../utils/bee'
|
||||
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'
|
||||
|
||||
const NUMBER_OF_DAYS_WARNING = 7
|
||||
const DAYS_TO_MILLISECONDS_MULTIPLIER = 24 * 60 * 60 * 1000
|
||||
|
||||
interface NotificationBarProps {
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function NotificationBar({ setErrorMessage }: NotificationBarProps): ReactElement | null {
|
||||
const [showExpiringModal, setShowExpiringModal] = useState(false)
|
||||
const [stampsToExpire, setStampsToExpire] = useState<PostageBatch[]>([])
|
||||
const [drivesToExpire, setDrivesToExpire] = useState<DriveInfo[]>([])
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { drives, adminDrive } = useContext(FMContext)
|
||||
|
||||
const showExpiration = stampsToExpire.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const getStamps = async () => {
|
||||
const allStamps = await getUsableStamps(beeApi)
|
||||
const expiringStamps: PostageBatch[] = []
|
||||
const expiringDrives: DriveInfo[] = []
|
||||
|
||||
allStamps.forEach(stamp => {
|
||||
const matchingDrive =
|
||||
drives.find(d => d.batchId.toString() === stamp.batchID.toString()) ||
|
||||
(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) {
|
||||
expiringStamps.push(stamp)
|
||||
expiringDrives.push(matchingDrive)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if (isMounted) {
|
||||
setStampsToExpire(expiringStamps)
|
||||
setDrivesToExpire(expiringDrives)
|
||||
}
|
||||
}
|
||||
|
||||
getStamps()
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [beeApi, drives, adminDrive])
|
||||
|
||||
if (!showExpiration) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fm-notification-bar fm-red-font" onClick={() => setShowExpiringModal(true)}>
|
||||
{stampsToExpire.length} drive{stampsToExpire.length > 1 ? 's' : ''} expiring soon <UpIcon size="16px" />
|
||||
</div>
|
||||
{showExpiringModal && (
|
||||
<ExpiringNotificationModal
|
||||
stamps={stampsToExpire}
|
||||
drives={drivesToExpire}
|
||||
onCancelClick={() => {
|
||||
setShowExpiringModal(false)
|
||||
}}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
.fm-private-key-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.fm-generate-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 8px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.fm-generate-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.fm-private-key-input-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-private-key-input {
|
||||
padding-right: 37px !important;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
padding-right: 40px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fm-confirm-key-label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.fm-confirm-key-input {
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fm-confirm-key-hint {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.fm-input.has-error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
.fm-input-hint-error {
|
||||
color: #ef4444;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
min-height: 24px;
|
||||
display: block;
|
||||
}
|
||||
.fm-input-hint {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.fm-copy-btn {
|
||||
position: absolute;
|
||||
right: 27px;
|
||||
margin-left: 6px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
line-height: 0;
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.fm-copy-btn:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.fm-initialization-modal-container {
|
||||
height: 100vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.fm-initialization-modal-container .fm-modal-window {
|
||||
width: 600px;
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { useState, ReactElement, useEffect } from 'react'
|
||||
import './PrivateKeyModal.scss'
|
||||
import { Button } from '../Button/Button'
|
||||
import { setSignerPk, getSigner } from '../../utils/common'
|
||||
import { PrivateKey } from '@ethersphere/bee-js'
|
||||
import ClipboardIcon from 'remixicon-react/FileCopyLineIcon'
|
||||
import CheckDoubleLineIcon from 'remixicon-react/CheckDoubleLineIcon'
|
||||
import { Tooltip } from '../Tooltip/Tooltip'
|
||||
import { TOOLTIPS } from '../../constants/tooltips'
|
||||
|
||||
type Props = { onSaved: () => void }
|
||||
|
||||
export function PrivateKeyModal({ onSaved }: Props): ReactElement {
|
||||
const [value, setValue] = useState('')
|
||||
const [confirmValue, setConfirmValue] = useState('')
|
||||
const [showError, setShowError] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
handleGenerateNew()
|
||||
}, [])
|
||||
|
||||
const handleCopyPrivateKey = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value)
|
||||
setCopied(true)
|
||||
} catch {
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug('Failed to copy private key to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
const handleGenerateNew = () => {
|
||||
const id = crypto.randomUUID()
|
||||
const signer = getSigner(id)
|
||||
const privKey = signer.toHex()
|
||||
|
||||
setValue(privKey)
|
||||
setConfirmValue('')
|
||||
setCopied(false)
|
||||
setShowError(false)
|
||||
}
|
||||
|
||||
const handleBlur = () => {
|
||||
if (!value.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
new PrivateKey(value)
|
||||
setShowError(false)
|
||||
} catch {
|
||||
setShowError(true)
|
||||
setCopied(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
try {
|
||||
new PrivateKey(value)
|
||||
setSignerPk(value)
|
||||
onSaved()
|
||||
} catch {
|
||||
setShowError(true)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fm-initialization-modal-container">
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header">
|
||||
<div>Create Private Key</div>
|
||||
</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}
|
||||
/>
|
||||
{
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
<div className="fm-input-hint fm-confirm-key-hint">
|
||||
{confirmValue && value === confirmValue ? '✓ Private keys match!' : ''}
|
||||
</div>
|
||||
</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"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!value || !confirmValue || value !== confirmValue || showError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrivateKeyModal
|
||||
@@ -0,0 +1,14 @@
|
||||
.fm-progress-bar {
|
||||
width: 20%;
|
||||
height: 6px;
|
||||
background: rgb(255, 255, 255);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-progress-bar-fill {
|
||||
width: '20px';
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ReactElement } from 'react'
|
||||
|
||||
import './ProgressBar.scss'
|
||||
|
||||
interface ProgressBarProps {
|
||||
value: number
|
||||
width?: string
|
||||
color?: string
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
value,
|
||||
width = '200px',
|
||||
color = '#ed8131',
|
||||
backgroundColor = 'white',
|
||||
}: ProgressBarProps): ReactElement {
|
||||
return (
|
||||
<div className="fm-progress-bar" style={{ width: `${width}`, backgroundColor: `${backgroundColor}` }}>
|
||||
<div className="fm-progress-bar-fill" style={{ width: `${value}%`, backgroundColor: `${color}` }}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
.fm-rename-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(10, 10, 10, 0.55);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.fm-rename-modal .fm-modal-window {
|
||||
.fm-modal-window-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
svg {
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-modal-window-body {
|
||||
.fm-modal-white-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--fm-border-color, rgba(255, 255, 255, 0.08));
|
||||
background: var(--fm-input-bg, rgba(255, 255, 255, 0.04));
|
||||
color: var(--fm-text-color, #fff);
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
transition: border-color 120ms ease, background-color 120ms ease;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--fm-accent, #6aa7ff);
|
||||
background: var(--fm-input-bg-focus, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: var(--fm-placeholder, rgba(255, 255, 255, 0.5));
|
||||
}
|
||||
}
|
||||
|
||||
.fm-rename-input {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
color: #fff;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--fm-accent, #6aa7ff);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-soft-text {
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.fm-error-text {
|
||||
color: var(--fm-error, #ff6b6b);
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.fm-modal-window-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.fm-button + .fm-button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.fm-rename-modal .fm-modal-white-section input#fm-rename-input.fm-input {
|
||||
background: #fff !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
color: #111 !important;
|
||||
-webkit-text-fill-color: #111 !important;
|
||||
caret-color: #111 !important;
|
||||
}
|
||||
|
||||
.fm-rename-modal .fm-modal-white-section input#fm-rename-input.fm-input::placeholder {
|
||||
color: rgba(0, 0, 0, 0.5) !important;
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { ReactElement, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import '../../styles/global.scss'
|
||||
import './RenameFileModal.scss'
|
||||
|
||||
import { Button } from '../Button/Button'
|
||||
import EditIcon from 'remixicon-react/EditLineIcon'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { safeSetState } from '../../utils/common'
|
||||
|
||||
interface RenameFileModalProps {
|
||||
currentName: string
|
||||
takenNames?: Set<string> | string[]
|
||||
onCancelClick: () => void
|
||||
onProceed: (newName: string) => void | Promise<void>
|
||||
}
|
||||
|
||||
export function RenameFileModal({
|
||||
currentName,
|
||||
takenNames,
|
||||
onCancelClick,
|
||||
onProceed,
|
||||
}: RenameFileModalProps): ReactElement {
|
||||
const [value, setValue] = useState(currentName)
|
||||
const [touched, setTouched] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0)
|
||||
|
||||
return () => clearTimeout(t)
|
||||
}, [])
|
||||
|
||||
const taken = useMemo(() => {
|
||||
if (!takenNames) return new Set<string>()
|
||||
|
||||
return Array.isArray(takenNames) ? new Set(takenNames) : takenNames
|
||||
}, [takenNames])
|
||||
|
||||
const trimmed = useMemo(() => value.trim(), [value])
|
||||
|
||||
const error = useMemo(() => {
|
||||
if (!touched) return ''
|
||||
|
||||
if (!trimmed) return 'Name is required.'
|
||||
|
||||
if (trimmed === currentName) return 'Enter a different name.'
|
||||
|
||||
if (/[\\/:*?"<>|]+/.test(trimmed)) return 'Name contains invalid characters.'
|
||||
|
||||
if (taken.has(trimmed)) return 'A different file already uses this name. Please choose another.'
|
||||
|
||||
return ''
|
||||
}, [touched, trimmed, currentName, taken])
|
||||
|
||||
const canSubmit =
|
||||
trimmed.length > 0 && trimmed !== currentName && !/[\\/:*?"<>|]+/.test(trimmed) && !taken.has(trimmed)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!canSubmit || submitting) {
|
||||
setTouched(true)
|
||||
|
||||
return
|
||||
}
|
||||
try {
|
||||
setSubmitting(true)
|
||||
await onProceed(trimmed)
|
||||
} finally {
|
||||
safeSetState(isMountedRef, setSubmitting)(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown: React.KeyboardEventHandler<HTMLInputElement> = e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
void handleSubmit()
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
onCancelClick()
|
||||
}
|
||||
}
|
||||
|
||||
const modalRoot = (document.querySelector('.fm-main') as HTMLElement) || document.body
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container fm-rename-modal">
|
||||
<div className="fm-modal-window">
|
||||
<div className="fm-modal-window-header">
|
||||
<EditIcon size="21px" />
|
||||
<span className="fm-main-font-color">Rename file</span>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-white-section">
|
||||
<label htmlFor="fm-rename-input" className="fm-soft-text" style={{ display: 'block', marginBottom: 8 }}>
|
||||
New name
|
||||
</label>
|
||||
<input
|
||||
id="fm-rename-input"
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="fm-input fm-rename-input"
|
||||
value={value}
|
||||
onChange={e => setValue(e.target.value)}
|
||||
onBlur={() => setTouched(true)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Enter a new file name"
|
||||
/>
|
||||
{error && (
|
||||
<div className="fm-error-text" style={{ marginTop: 8 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div className="fm-soft-text" style={{ marginTop: 10, fontSize: 12 }}>
|
||||
This creates a new version that only changes metadata (no re-upload).
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-footer fm-space-between">
|
||||
<Button label="Cancel" variant="secondary" onClick={onCancelClick} />
|
||||
<Button
|
||||
label="Rename"
|
||||
variant="primary"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={!canSubmit || submitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
.fm-drive-item-info {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-direction: column;
|
||||
color: rgb(31, 41, 55);
|
||||
font-weight: 500;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
.fm-drive-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgb(237, 129, 49);
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-drive-item-header {
|
||||
display: flex;
|
||||
font-weight: 700;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-drive-item-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.fm-drive-item-container {
|
||||
display: flex;
|
||||
position: relative;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
padding-left: 40px;
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(209, 209, 209);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.fm-drive-item-container-selected {
|
||||
background-color: rgb(209, 209, 209);
|
||||
}
|
||||
}
|
||||
|
||||
.fm-drive-item-capacity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-drive-item-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.fm-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fm-drive-item-context-menu {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.fm-disabled { opacity: 0.5; pointer-events: none; }
|
||||
@@ -0,0 +1,218 @@
|
||||
import { ReactElement, useState, useContext, useEffect, useRef, useMemo } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Drive from 'remixicon-react/HardDrive2LineIcon'
|
||||
import DriveFill from 'remixicon-react/HardDrive2FillIcon'
|
||||
import MoreFill from 'remixicon-react/MoreFillIcon'
|
||||
import './DriveItem.scss'
|
||||
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 { UpgradeDriveModal } from '../../UpgradeDriveModal/UpgradeDriveModal'
|
||||
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 { Context as SettingsContext } from '../../../../../providers/Settings'
|
||||
|
||||
interface DriveItemProps {
|
||||
drive: DriveInfo
|
||||
stamp: PostageBatch
|
||||
isSelected: boolean
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function DriveItem({ drive, stamp, isSelected, setErrorMessage }: DriveItemProps): ReactElement {
|
||||
const { fm, setShowError, refreshStamp } = useContext(FMContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [isDestroyDriveModalOpen, setIsDestroyDriveModalOpen] = useState(false)
|
||||
const [isUpgradeDriveModalOpen, setIsUpgradeDriveModalOpen] = useState(false)
|
||||
const isMountedRef = useRef(true)
|
||||
const [isUpgrading, setIsUpgrading] = useState(false)
|
||||
const [actualStamp, setActualStamp] = useState<PostageBatch>(stamp)
|
||||
|
||||
const { showContext, pos, contextRef, setPos, setShowContext } = useContextMenu<HTMLDivElement>()
|
||||
|
||||
const { setView, setActualItemView } = useView()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
setActualStamp(stamp)
|
||||
}, [stamp])
|
||||
|
||||
function handleMenuClick(e: React.MouseEvent) {
|
||||
setShowContext(true)
|
||||
setPos({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
function handleDestroyDriveClick() {
|
||||
setShowContext(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const id = drive.id.toString()
|
||||
const batchId = stamp.batchID.toString()
|
||||
|
||||
const onStart = (e: Event) => {
|
||||
const { driveId } = (e as CustomEvent).detail || {}
|
||||
|
||||
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) {
|
||||
setIsUpgrading(false)
|
||||
|
||||
const upgradedStamp = await refreshStamp(batchId)
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
if (upgradedStamp) {
|
||||
setActualStamp(upgradedStamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('fm:drive-upgrade-start', onStart as EventListener)
|
||||
window.addEventListener('fm:drive-upgrade-end', onEnd as EventListener)
|
||||
|
||||
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])
|
||||
|
||||
const { capacityPct, usedSize, totalSize } = useMemo(
|
||||
() => calculateStampCapacityMetrics(actualStamp, drive),
|
||||
[actualStamp, drive],
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fm-drive-item-container${isSelected ? ' fm-drive-item-container-selected' : ''}`}
|
||||
onClick={() => {
|
||||
setView(ViewType.File)
|
||||
setActualItemView?.(drive.name)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<div className="fm-drive-item-content">
|
||||
<div className="fm-drive-item-capacity">
|
||||
Capacity <ProgressBar value={capacityPct} width="64px" /> {usedSize} / {totalSize}
|
||||
</div>
|
||||
<div className="fm-drive-item-capacity">
|
||||
Expiry date: {actualStamp.duration.toEndDate().toLocaleDateString()}
|
||||
</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'}
|
||||
/>
|
||||
{showContext &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={contextRef}
|
||||
className="fm-drive-item-context-menu"
|
||||
style={{
|
||||
top: pos.y,
|
||||
left: pos.x,
|
||||
}}
|
||||
>
|
||||
<ContextMenu>
|
||||
<div
|
||||
className="fm-context-item red"
|
||||
onClick={() => {
|
||||
handleDestroyDriveClick()
|
||||
setIsDestroyDriveModalOpen(true)
|
||||
}}
|
||||
>
|
||||
Destroy entire drive
|
||||
</div>
|
||||
</ContextMenu>
|
||||
</div>,
|
||||
|
||||
document.body,
|
||||
)}
|
||||
<Button
|
||||
label="Upgrade"
|
||||
variant="primary"
|
||||
size="small"
|
||||
disabled={isUpgrading}
|
||||
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)
|
||||
},
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ReactElement, useState, useContext } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Drive from 'remixicon-react/HardDrive2LineIcon'
|
||||
import DriveFill from 'remixicon-react/HardDrive2FillIcon'
|
||||
import MoreFill from 'remixicon-react/MoreFillIcon'
|
||||
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 { ConfirmModal } from '../../ConfirmModal/ConfirmModal'
|
||||
import './DriveItem.scss'
|
||||
|
||||
interface Props {
|
||||
drive: DriveInfo
|
||||
onForgot?: () => Promise<void> | void
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function ExpiredDriveItem({ drive, onForgot, setErrorMessage }: Props): ReactElement {
|
||||
const { fm, setShowError } = useContext(FMContext)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [showForgetConfirm, setShowForgetConfirm] = useState(false)
|
||||
const { showContext, pos, contextRef, setPos, setShowContext } = useContextMenu<HTMLDivElement>()
|
||||
|
||||
function handleMenuClick(e: React.MouseEvent) {
|
||||
setShowContext(true)
|
||||
setPos({ x: e.clientX, y: e.clientY })
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fm-drive-item-container fm-expired"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<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>
|
||||
<div className="fm-drive-item-content">
|
||||
<div className="fm-drive-item-capacity">Stamp expired — files unavailable</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-drive-item-actions">
|
||||
<MoreFill
|
||||
size="13"
|
||||
className="fm-pointer"
|
||||
onClick={handleMenuClick}
|
||||
aria-label={`More actions for ${drive.name}`}
|
||||
/>
|
||||
|
||||
{showContext &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={contextRef}
|
||||
className="fm-drive-item-context-menu"
|
||||
style={{ top: pos.y, left: pos.x }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<ContextMenu>
|
||||
<div
|
||||
className="fm-context-item red"
|
||||
onClick={() => {
|
||||
setShowContext(false)
|
||||
setShowForgetConfirm(true)
|
||||
}}
|
||||
>
|
||||
Forget drive
|
||||
</div>
|
||||
</ContextMenu>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showForgetConfirm && (
|
||||
<ConfirmModal
|
||||
title="Forget drive?"
|
||||
message={
|
||||
<>
|
||||
This will remove metadata for the drive with expired stamp <b>Drive Name: {drive.name}</b>{' '}
|
||||
<b>Batch Id: {`${drive.batchId.toString().slice(0, 4)}...${drive.batchId.toString().slice(-4)}`}</b>
|
||||
</>
|
||||
}
|
||||
confirmLabel="Forget drive"
|
||||
cancelLabel="Keep"
|
||||
onCancel={() => setShowForgetConfirm(false)}
|
||||
onConfirm={async () => {
|
||||
if (!fm) return
|
||||
|
||||
await handleForgetDrive(
|
||||
fm,
|
||||
drive,
|
||||
async () => {
|
||||
setShowForgetConfirm(false)
|
||||
await onForgot?.()
|
||||
},
|
||||
() => {
|
||||
setShowForgetConfirm(false)
|
||||
setErrorMessage?.(`Failed to forget drive ${drive.name}`)
|
||||
setShowError(true)
|
||||
},
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
.fm-sidebar {
|
||||
min-width: 370px;
|
||||
box-sizing: border-box;
|
||||
height: calc(100vh - 120px);
|
||||
background-color: rgb(237, 237, 237);
|
||||
border-right: 1px solid rgb(146, 146, 146);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.fm-sidebar-content {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.fm-sidebar-item {
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: rgb(31, 41, 55);
|
||||
font-weight: 700;
|
||||
transition: background-color 0.3s;
|
||||
|
||||
.fm-sidebar-item-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgb(237, 129, 49);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: rgb(209, 209, 209);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.fm-trash-item {
|
||||
padding: 8px;
|
||||
padding-left: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-sidebar-drive-creation {
|
||||
padding: 12px;
|
||||
border-top: 1px solid rgb(146, 146, 146);
|
||||
}
|
||||
|
||||
.fm-drive-items-container {
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: opacity 0.3s, max-height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
will-change: opacity, max-height;
|
||||
|
||||
&.fm-drive-items-container-open {
|
||||
opacity: 1;
|
||||
max-height: 1000px;
|
||||
}
|
||||
}
|
||||
|
||||
.fm-trash-item {
|
||||
color: rgb(31, 41, 55);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fm-drive-item-creating {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fm-drive-item-creating .fm-drive-item-info,
|
||||
.fm-drive-item-creating .fm-drive-item-actions {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.fm-drive-item-creating-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
background: rgba(255, 255, 255, 0.55);
|
||||
-webkit-backdrop-filter: blur(2px);
|
||||
backdrop-filter: blur(2px);
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.fm-mini-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(0, 0, 0, 0.2);
|
||||
border-top-color: rgba(0, 0, 0, 0.7);
|
||||
animation: fmSpin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.fm-drive-item-container.fm-expired {
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
border: 1px solid rgba(220, 38, 38, 0.2);
|
||||
}
|
||||
|
||||
.fm-drive-item-container.fm-expired .fm-drive-item-capacity {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@keyframes fmSpin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { ReactElement, useContext, useEffect, useState } from 'react'
|
||||
|
||||
import './Sidebar.scss'
|
||||
import Add from 'remixicon-react/AddLineIcon'
|
||||
import Folder from 'remixicon-react/Folder3LineIcon'
|
||||
import FolderFill from 'remixicon-react/Folder3FillIcon'
|
||||
import ArrowRight from 'remixicon-react/ArrowRightSLineIcon'
|
||||
import ArrowDown from 'remixicon-react/ArrowDownSLineIcon'
|
||||
import Delete from 'remixicon-react/DeleteBin6LineIcon'
|
||||
import DeleteFill from 'remixicon-react/DeleteBin6FillIcon'
|
||||
import History from 'remixicon-react/HistoryLineIcon'
|
||||
import HistoryFill from 'remixicon-react/HistoryFillIcon'
|
||||
import { DriveItem } from './DriveItem/DriveItem'
|
||||
import { ExpiredDriveItem } from './DriveItem/ExpiredDriveItem'
|
||||
import { CreateDriveModal } from '../CreateDriveModal/CreateDriveModal'
|
||||
import { ViewType } from '../../constants/transfers'
|
||||
import { PostageBatch } from '@ethersphere/bee-js'
|
||||
import { Context as SettingsContext } from '../../../../providers/Settings'
|
||||
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'
|
||||
|
||||
interface SidebarProps {
|
||||
loading: boolean
|
||||
errorMessage?: string
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
export function Sidebar({ setErrorMessage, loading }: SidebarProps): ReactElement {
|
||||
const [hovered, setHovered] = useState<string | null>(null)
|
||||
const [isMyDrivesOpen, setIsMyDriveOpen] = useState(true)
|
||||
const [isTrashOpen, setIsTrashOpen] = useState(false)
|
||||
const [isCreateDriveOpen, setIsCreateDriveOpen] = useState(false)
|
||||
const [usableStamps, setUsableStamps] = useState<PostageBatch[]>([])
|
||||
const [isDriveCreationInProgress, setIsDriveCreationInProgress] = useState(false)
|
||||
const [isExpiredOpen, setIsExpiredOpen] = useState(false)
|
||||
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { setView, view } = useView()
|
||||
const {
|
||||
fm,
|
||||
currentDrive,
|
||||
currentStamp,
|
||||
drives,
|
||||
expiredDrives,
|
||||
setCurrentDrive,
|
||||
setCurrentStamp,
|
||||
setShowError,
|
||||
syncDrives,
|
||||
} = useContext(FMContext)
|
||||
|
||||
useEffect(() => {
|
||||
let isMounted = true
|
||||
|
||||
const getStamps = async () => {
|
||||
const stamps = await getUsableStamps(beeApi)
|
||||
|
||||
if (isMounted) {
|
||||
setUsableStamps([...stamps])
|
||||
}
|
||||
}
|
||||
|
||||
if (beeApi) {
|
||||
getStamps()
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false
|
||||
}
|
||||
}, [beeApi, drives])
|
||||
|
||||
useEffect(() => {
|
||||
if (!fm || drives.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!currentDrive) {
|
||||
const firstDrive = drives[0]
|
||||
setCurrentDrive(firstDrive)
|
||||
setView(ViewType.File)
|
||||
}
|
||||
|
||||
if (currentDrive && !currentStamp && 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])
|
||||
|
||||
const isCurrent = (di: DriveInfo) => currentDrive?.id.toString() === di.id.toString()
|
||||
|
||||
return (
|
||||
<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>
|
||||
<div>Create new drive</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isCreateDriveOpen && (
|
||||
<CreateDriveModal
|
||||
onCancelClick={() => setIsCreateDriveOpen(false)}
|
||||
onDriveCreated={() => {
|
||||
setIsCreateDriveOpen(false)
|
||||
setIsDriveCreationInProgress(false)
|
||||
}}
|
||||
onCreationStarted={() => setIsDriveCreationInProgress(true)}
|
||||
onCreationError={(name: string) => {
|
||||
setIsDriveCreationInProgress(false)
|
||||
setErrorMessage?.(`Error creating drive: ${name}`)
|
||||
setShowError(true)
|
||||
|
||||
return
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="fm-sidebar-item"
|
||||
onMouseEnter={() => setHovered('my-drives')}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => setIsMyDriveOpen(!isMyDrivesOpen)}
|
||||
>
|
||||
<div className="fm-sidebar-item-icon">
|
||||
{isMyDrivesOpen ? <ArrowDown size="16px" /> : <ArrowRight size="16px" />}
|
||||
</div>
|
||||
<div className="fm-sidebar-item-icon" style={{ opacity: hovered === 'my-drives' ? 1 : 1 }}>
|
||||
{hovered === 'my-drives' ? <FolderFill size="16px" /> : <Folder size="16px" />}
|
||||
</div>
|
||||
<div>My Drives</div>
|
||||
</div>
|
||||
|
||||
{isMyDrivesOpen && isDriveCreationInProgress && (
|
||||
<div className="fm-drive-item-container fm-drive-item-creating" aria-live="polite">
|
||||
<div className="fm-drive-item-info">
|
||||
<div className="fm-drive-item-header">
|
||||
<div className="fm-drive-item-icon">
|
||||
<Folder size="16px" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-drive-item-content">
|
||||
<div className="fm-drive-item-capacity">Initializing drive metadata</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="fm-drive-item-actions" />
|
||||
<div className="fm-drive-item-creating-overlay">
|
||||
<div className="fm-mini-spinner" />
|
||||
<span>Please wait…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isMyDrivesOpen &&
|
||||
drives.map(d => {
|
||||
const isSelected = isCurrent(d) && view === ViewType.File
|
||||
const localStamp = usableStamps.find(s => s.batchID.toString() === d.batchId.toString() && !d.isAdmin)
|
||||
const stamp = isSelected && currentStamp ? currentStamp : localStamp
|
||||
|
||||
return (
|
||||
stamp && (
|
||||
<div
|
||||
key={d.id.toString()}
|
||||
onClick={() => {
|
||||
setCurrentDrive(d)
|
||||
setCurrentStamp(stamp)
|
||||
setView(ViewType.File)
|
||||
}}
|
||||
>
|
||||
<DriveItem drive={d} stamp={stamp} isSelected={isSelected} setErrorMessage={setErrorMessage} />
|
||||
</div>
|
||||
)
|
||||
)
|
||||
})}
|
||||
|
||||
{expiredDrives.length > 0 && (
|
||||
<>
|
||||
<div
|
||||
className="fm-sidebar-item"
|
||||
onMouseEnter={() => setHovered('expired')}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => setIsExpiredOpen(prev => !prev)}
|
||||
>
|
||||
<div className="fm-sidebar-item-icon">
|
||||
{isExpiredOpen ? <ArrowDown size="16px" /> : <ArrowRight size="16px" />}
|
||||
</div>
|
||||
<div className="fm-sidebar-item-icon">
|
||||
{hovered === 'expired' ? <HistoryFill size="16px" /> : <History size="16px" />}
|
||||
</div>
|
||||
<div>Expired drives</div>
|
||||
</div>
|
||||
|
||||
{isExpiredOpen && (
|
||||
<div className="fm-drive-items-container fm-drive-items-container-open">
|
||||
{expiredDrives.map(d => (
|
||||
<div
|
||||
key={`${d.id.toString()}-expired`}
|
||||
onClick={() => {
|
||||
setCurrentDrive(d)
|
||||
setView(ViewType.Expired)
|
||||
}}
|
||||
>
|
||||
<ExpiredDriveItem
|
||||
drive={d}
|
||||
onForgot={async () => {
|
||||
await syncDrives()
|
||||
setCurrentDrive(drives.length > 0 ? drives[0] : undefined)
|
||||
setView(ViewType.File)
|
||||
}}
|
||||
setErrorMessage={setErrorMessage}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="fm-sidebar-item"
|
||||
onMouseEnter={() => setHovered(ViewType.Trash)}
|
||||
onMouseLeave={() => setHovered(null)}
|
||||
onClick={() => setIsTrashOpen(!isTrashOpen)}
|
||||
>
|
||||
<div className="fm-sidebar-item-icon">
|
||||
{isTrashOpen ? <ArrowDown size="16px" /> : <ArrowRight size="16px" />}
|
||||
</div>
|
||||
<div className="fm-sidebar-item-icon">
|
||||
{hovered === ViewType.Trash ? <DeleteFill size="16px" /> : <Delete size="16px" />}
|
||||
</div>
|
||||
<div>Trash</div>
|
||||
</div>
|
||||
|
||||
{isTrashOpen && (
|
||||
<div className="fm-drive-items-container fm-drive-items-container-open">
|
||||
{drives.map(d => {
|
||||
const selected = isCurrent(d) && view === ViewType.Trash
|
||||
const stamp = usableStamps.find(s => s.batchID.toString() === d.batchId.toString() && !d.isAdmin)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${d.id.toString()}-trash`}
|
||||
className={`fm-sidebar-item fm-trash-item${selected ? ' is-selected' : ''}`}
|
||||
onClick={() => {
|
||||
setCurrentDrive(d)
|
||||
setCurrentStamp(stamp)
|
||||
setView(ViewType.Trash)
|
||||
}}
|
||||
title={`${d.name} Trash`}
|
||||
>
|
||||
{d.name} Trash
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDriveCreationInProgress && (
|
||||
<div className="fm-sidebar-drive-creation">Creating drive, please do not reload</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.fm-slider {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
& .MuiSlider-markLabel {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
& .MuiSlider-mark {
|
||||
background-color: rgb(237, 129, 49);
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
& .MuiSlider-markLabel {
|
||||
top: 14px;
|
||||
font-size: 12px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
& .MuiSlider-markLabel[data-index='4'] {
|
||||
left: 98% !important;
|
||||
}
|
||||
|
||||
& .MuiSlider-thumb:focus,
|
||||
& .MuiSlider-thumb.Mui-focusVisible {
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ReactElement, useState } from 'react'
|
||||
import './Slider.scss'
|
||||
import Slider from '@material-ui/core/Slider'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
root: {
|
||||
width: '98%',
|
||||
marginLeft: '-3px',
|
||||
},
|
||||
rail: {
|
||||
color: 'rgb(229, 231, 235)',
|
||||
opacity: 1,
|
||||
height: 4,
|
||||
},
|
||||
track: {
|
||||
color: 'rgb(229, 231, 235)',
|
||||
height: 4,
|
||||
},
|
||||
thumb: {
|
||||
color: 'white',
|
||||
height: 18,
|
||||
width: 18,
|
||||
border: '3px solid rgb(237, 129, 49)',
|
||||
position: 'relative',
|
||||
top: -1,
|
||||
marginRight: '-10px',
|
||||
},
|
||||
})
|
||||
|
||||
interface FMSliderProps {
|
||||
id?: string
|
||||
label?: string
|
||||
marks?: { value: number; label: string }[]
|
||||
defaultValue?: number
|
||||
onChange: (value: number) => void
|
||||
minValue?: number
|
||||
maxValue?: number
|
||||
step?: number
|
||||
}
|
||||
|
||||
export function FMSlider({
|
||||
id,
|
||||
label,
|
||||
marks,
|
||||
defaultValue,
|
||||
onChange,
|
||||
minValue,
|
||||
maxValue,
|
||||
step,
|
||||
}: FMSliderProps): ReactElement {
|
||||
const [value, setValue] = useState(defaultValue || 0)
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>
|
||||
{`
|
||||
.fm-slider .MuiSlider-markLabel[data-index="${value}"].MuiSlider-markLabelActive {
|
||||
color: rgb(237, 129, 49);
|
||||
font-weight: bold;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div className="fm-input-label">
|
||||
{label && (
|
||||
<label htmlFor={id} className="fm-dropdown-label">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
<div className="fm-slider">
|
||||
<Slider
|
||||
classes={{
|
||||
root: classes.root,
|
||||
rail: classes.rail,
|
||||
track: classes.track,
|
||||
thumb: classes.thumb,
|
||||
}}
|
||||
value={value || 0}
|
||||
onChange={(_, val) => {
|
||||
setValue(Number(val))
|
||||
onChange(Number(val))
|
||||
}}
|
||||
defaultValue={defaultValue || 0}
|
||||
min={minValue || 0}
|
||||
max={maxValue || 100}
|
||||
step={step || 1}
|
||||
marks={marks}
|
||||
valueLabelDisplay="off"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
.fm-tooltip-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.fm-tooltip-wrapper.no-margin {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.fm-tooltip-text {
|
||||
display: inline-block;
|
||||
margin-right: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.fm-tooltip-trigger {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: rgb(107, 114, 128);
|
||||
}
|
||||
|
||||
.fm-tooltip-wrapper:hover .fm-tooltip-trigger svg {
|
||||
color: rgb(55, 65, 81);
|
||||
}
|
||||
|
||||
.fm-tooltip-container {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
width: max-content;
|
||||
max-width: 360px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
position: absolute;
|
||||
z-index: 30;
|
||||
top: 50%;
|
||||
left: calc(100% + 6px);
|
||||
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 */
|
||||
pointer-events: none;
|
||||
transition: opacity 0.16s ease-in-out, visibility 0.16s ease-in-out, transform 0.16s ease-in-out;
|
||||
border: 1px solid rgb(209, 213, 219);
|
||||
transform: translateY(-50%) translateX(4px);
|
||||
}
|
||||
|
||||
.fm-tooltip-container.bottom {
|
||||
transform: translateY(-60%) !important;
|
||||
}
|
||||
|
||||
.fm-tooltip-wrapper:hover .fm-tooltip-container {
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fm-flex-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ReactElement, useState, useRef, useCallback } from 'react'
|
||||
import InfoIcon from 'remixicon-react/InformationLineIcon'
|
||||
import './Tooltip.scss'
|
||||
|
||||
interface TooltipProps {
|
||||
label: string
|
||||
iconSize?: string
|
||||
edgeOffsetPx?: number
|
||||
gapPx?: number
|
||||
children?: React.ReactNode
|
||||
disableMargin?: boolean
|
||||
bottomTooltip?: boolean
|
||||
}
|
||||
|
||||
export function Tooltip({
|
||||
label,
|
||||
iconSize = '16px',
|
||||
edgeOffsetPx = 12,
|
||||
gapPx = 6,
|
||||
children,
|
||||
disableMargin = false,
|
||||
bottomTooltip = false,
|
||||
}: TooltipProps): ReactElement {
|
||||
const [alignLeft, setAlignLeft] = useState(false)
|
||||
const wrapperRef = useRef<HTMLSpanElement>(null)
|
||||
|
||||
const evaluateAlignment = useCallback(() => {
|
||||
const wrapper = wrapperRef.current
|
||||
|
||||
if (!wrapper) return
|
||||
const container = wrapper.querySelector('.fm-tooltip-container') as HTMLElement | null
|
||||
|
||||
if (!container) return
|
||||
|
||||
const wrapperRect = wrapper.getBoundingClientRect()
|
||||
const tooltipWidth = container.offsetWidth || 0
|
||||
const projectedRight = wrapperRect.right + gapPx + tooltipWidth + edgeOffsetPx
|
||||
const viewportWidth = window.innerWidth
|
||||
|
||||
if (projectedRight > viewportWidth) {
|
||||
setAlignLeft(true)
|
||||
} else {
|
||||
setAlignLeft(false)
|
||||
}
|
||||
}, [edgeOffsetPx, gapPx])
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={wrapperRef}
|
||||
className={`fm-tooltip-wrapper${alignLeft ? ' left' : ''}${disableMargin ? ' no-margin' : ''}`}
|
||||
aria-label="info tooltip"
|
||||
onMouseEnter={evaluateAlignment}
|
||||
style={{ ['--fm-tooltip-gap' as string]: `${gapPx}px` }}
|
||||
>
|
||||
{children && <span className="fm-tooltip-text">{children}</span>}
|
||||
<span className="fm-tooltip-trigger" role="button" tabIndex={0}>
|
||||
<InfoIcon size={iconSize} />
|
||||
</span>
|
||||
<div
|
||||
className={`fm-tooltip-container${bottomTooltip ? ' bottom' : ''}`}
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{ __html: label }}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
.fm-upgrade-drive-modal {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-wallet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-wallet-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-wallet-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgb(229, 231, 235);
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-info-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: max-content;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-input-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-estimated-cost {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
background-color: white;
|
||||
border: 1px solid rgb(229, 231, 235);
|
||||
}
|
||||
|
||||
.fm-upgrade-drive-modal-wallet-info-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
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 { 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,
|
||||
BZZ,
|
||||
capacityBreakpoints,
|
||||
Duration,
|
||||
PostageBatch,
|
||||
RedundancyLevel,
|
||||
Size,
|
||||
Utils,
|
||||
} from '@ethersphere/bee-js'
|
||||
import { DriveInfo } from '@solarpunkltd/file-manager-lib'
|
||||
import { getHumanReadableFileSize } from '../../../../utils/file'
|
||||
import { Warning } from '@material-ui/icons'
|
||||
|
||||
interface UpgradeDriveModalProps {
|
||||
stamp: PostageBatch
|
||||
drive: DriveInfo
|
||||
onCancelClick: () => void
|
||||
containerColor?: string
|
||||
setErrorMessage?: (error: string) => void
|
||||
}
|
||||
|
||||
const defaultErasureCodeLevel = RedundancyLevel.OFF
|
||||
const encryption_off = 'ENCRYPTION_OFF'
|
||||
|
||||
export function UpgradeDriveModal({
|
||||
stamp,
|
||||
onCancelClick,
|
||||
containerColor,
|
||||
drive,
|
||||
setErrorMessage,
|
||||
}: UpgradeDriveModalProps): ReactElement {
|
||||
const { nodeAddresses, walletBalance } = useContext(BeeContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const { setShowError } = useContext(FMContext)
|
||||
|
||||
const [isBalanceSufficient, setIsBalanceSufficient] = useState(true)
|
||||
const [capacity, setCapacity] = useState(Size.fromBytes(0))
|
||||
const [capacityExtensionCost, setCapacityExtensionCost] = useState('')
|
||||
const [capacityIndex, setCapacityIndex] = useState(0)
|
||||
const [durationExtensionCost, setDurationExtensionCost] = useState('')
|
||||
const [lifetimeIndex, setLifetimeIndex] = useState(0)
|
||||
const [validityEndDate, setValidityEndDate] = useState(new Date())
|
||||
const [sizeMarks, setSizeMarks] = useState<{ value: number; label: string }[]>([])
|
||||
const [extensionCost, setExtensionCost] = useState('0')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
const isMountedRef = useRef(true)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
isMountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleCapacityChange = (value: number, index: number) => {
|
||||
setCapacity(Size.fromBytes(value === -1 ? 0 : value))
|
||||
setCapacityIndex(index)
|
||||
}
|
||||
|
||||
const handleCostCalculation = useCallback(
|
||||
async (
|
||||
batchId: BatchId,
|
||||
capacity: Size,
|
||||
duration: Duration,
|
||||
options: BeeRequestOptions | undefined,
|
||||
encryption: boolean,
|
||||
erasureCodeLevel: RedundancyLevel,
|
||||
isCapacityExtensionSet: boolean,
|
||||
isDurationExtensionSet: boolean,
|
||||
) => {
|
||||
setIsBalanceSufficient(true)
|
||||
|
||||
let cost: BZZ | undefined
|
||||
|
||||
try {
|
||||
cost = await beeApi?.getExtensionCost(batchId, capacity, duration, options, encryption, erasureCodeLevel)
|
||||
} catch (e) {
|
||||
setErrorMessage?.('Failed to calculate extension cost')
|
||||
setShowError(true)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const costText = cost ? cost.toSignificantDigits(2) : '0'
|
||||
|
||||
if (!isMountedRef.current) return
|
||||
|
||||
if ((walletBalance && cost && cost.gte(walletBalance.bzzBalance)) || !walletBalance) {
|
||||
setIsBalanceSufficient(false)
|
||||
}
|
||||
|
||||
const bothExtensions = isCapacityExtensionSet && isDurationExtensionSet
|
||||
const capacityOnly = isCapacityExtensionSet && !isDurationExtensionSet
|
||||
const durationOnly = !isCapacityExtensionSet && isDurationExtensionSet
|
||||
const noExtensions = !isCapacityExtensionSet && !isDurationExtensionSet
|
||||
|
||||
if (bothExtensions) {
|
||||
setCapacityExtensionCost('')
|
||||
setDurationExtensionCost('')
|
||||
} else if (capacityOnly) {
|
||||
setCapacityExtensionCost(costText)
|
||||
setDurationExtensionCost('0')
|
||||
} else if (durationOnly) {
|
||||
setCapacityExtensionCost('0')
|
||||
setDurationExtensionCost(costText)
|
||||
} else {
|
||||
setCapacityExtensionCost('0')
|
||||
setDurationExtensionCost('0')
|
||||
}
|
||||
|
||||
setExtensionCost(noExtensions ? '0' : costText)
|
||||
},
|
||||
[beeApi, walletBalance, setErrorMessage, setShowError],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSizes = () => {
|
||||
const sizes = Array.from(Utils.getStampEffectiveBytesBreakpoints(false, defaultErasureCodeLevel).values())
|
||||
|
||||
const capacityValues = capacityBreakpoints[encryption_off][defaultErasureCodeLevel]
|
||||
const fromIndex = capacityValues.findIndex(item => item.batchDepth === stamp.depth)
|
||||
|
||||
const newSizes = sizes.slice(fromIndex + 1)
|
||||
|
||||
const updatedSizes = [
|
||||
{ value: -1, label: 'No additional storage (0 GB)' },
|
||||
...newSizes.map(size => ({
|
||||
value: size,
|
||||
label: getHumanReadableFileSize(size - stamp.size.toBytes()),
|
||||
})),
|
||||
]
|
||||
setSizeMarks(updatedSizes)
|
||||
}
|
||||
|
||||
fetchSizes()
|
||||
}, [stamp.depth, stamp.size])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchExtensionCost = () => {
|
||||
const isCapacitySet = capacityIndex > 0
|
||||
const isDurationSet = true
|
||||
const duration = Duration.fromEndDate(validityEndDate)
|
||||
|
||||
handleCostCalculation(
|
||||
stamp.batchID,
|
||||
capacity,
|
||||
duration,
|
||||
undefined,
|
||||
false,
|
||||
defaultErasureCodeLevel,
|
||||
isCapacitySet,
|
||||
isDurationSet,
|
||||
)
|
||||
}
|
||||
|
||||
fetchExtensionCost()
|
||||
}, [capacity, validityEndDate, capacityIndex, handleCostCalculation, lifetimeIndex, stamp.batchID])
|
||||
|
||||
useEffect(() => {
|
||||
setValidityEndDate(getExpiryDateByLifetime(lifetimeIndex, stamp.duration.toEndDate()))
|
||||
}, [lifetimeIndex, stamp.duration])
|
||||
|
||||
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-window fm-upgrade-drive-modal">
|
||||
<div className="fm-modal-window-header">
|
||||
<DriveIcon size="18px" /> Upgrade {drive.name || stamp.label || shortBatchId}
|
||||
</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>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
<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 className="fm-modal-window-footer">
|
||||
<Button
|
||||
label={isSubmitting ? 'Confirming…' : 'Confirm upgrade'}
|
||||
variant="primary"
|
||||
disabled={isSubmitting || !isBalanceSufficient || !walletBalance || !beeApi}
|
||||
onClick={async () => {
|
||||
if (!beeApi || !walletBalance) return
|
||||
|
||||
try {
|
||||
setIsSubmitting(true)
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('fm:drive-upgrade-start', {
|
||||
detail: { driveId: drive.id.toString() },
|
||||
}),
|
||||
)
|
||||
|
||||
onCancelClick()
|
||||
|
||||
await beeApi.extendStorage(
|
||||
stamp.batchID,
|
||||
capacity,
|
||||
durationExtensionCost === '0'
|
||||
? Duration.ZERO
|
||||
: Duration.fromEndDate(validityEndDate, stamp.duration.toEndDate()),
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : 'Upgrade failed'
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('fm:drive-upgrade-end', {
|
||||
detail: {
|
||||
driveId: drive.id.toString(),
|
||||
success: false,
|
||||
error: msg + ' (drive: ' + drive.name + ')',
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button label="Cancel" variant="secondary" disabled={isSubmitting} onClick={onCancelClick} />
|
||||
</div>
|
||||
|
||||
{isSubmitting && (
|
||||
<div className="fm-drive-item-creating-overlay">
|
||||
<div className="fm-mini-spinner" />
|
||||
<span>Please wait…</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
.fm-upload-conflict-modal .fm-modal-window-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fm-conflict-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.fm-conflict-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.fm-conflict-option-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fm-conflict-option-sub {
|
||||
color: #6b7280;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.fm-conflict-rename-row {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.fm-conflict-sep {
|
||||
height: 1px;
|
||||
background: #e5e7eb;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.fm-input {
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.fm-input:focus {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.fm-callout {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.fm-callout__icon {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.fm-callout--warning {
|
||||
background: #fff7e6;
|
||||
border: 1px solid #ffe3b3;
|
||||
border-left: 4px solid #ff9900;
|
||||
color: #5f4b00;
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { ReactElement, useMemo, useState } from 'react'
|
||||
import './UploadConflictModal.scss'
|
||||
import '../../styles/global.scss'
|
||||
import { Button } from '../Button/Button'
|
||||
import WarningIcon from 'remixicon-react/ErrorWarningLineIcon'
|
||||
|
||||
interface Props {
|
||||
filename: string
|
||||
suggestedName: string
|
||||
existingNames: Set<string>
|
||||
isTrashedExisting?: boolean
|
||||
onKeepBoth: (newName: string) => void
|
||||
onReplace: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
export function UploadConflictModal({
|
||||
filename,
|
||||
suggestedName,
|
||||
existingNames,
|
||||
isTrashedExisting,
|
||||
onKeepBoth,
|
||||
onReplace,
|
||||
onCancel,
|
||||
}: Props): ReactElement {
|
||||
const [customName, setCustomName] = useState<string>(suggestedName)
|
||||
const isNameValid = useMemo(() => {
|
||||
const n = (customName || '').trim()
|
||||
|
||||
return n.length > 0 && !existingNames.has(n)
|
||||
}, [customName, existingNames])
|
||||
|
||||
return (
|
||||
<div className="fm-modal-container">
|
||||
<div className="fm-modal-window fm-upload-conflict-modal">
|
||||
<div className="fm-modal-window-header">
|
||||
<WarningIcon size="18px" />
|
||||
<span className="fm-main-font-color">File already exists</span>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-body">
|
||||
<div className="fm-modal-white-section">
|
||||
<div className="fm-conflict-row">
|
||||
<div className="fm-emphasized-text">A file named “{filename}” already exists in this drive.</div>
|
||||
<div className="fm-soft-text">What would you like to do?</div>
|
||||
</div>
|
||||
|
||||
<div className="fm-conflict-option">
|
||||
<div className="fm-conflict-option-title">Keep both</div>
|
||||
<div className="fm-conflict-option-sub">
|
||||
Upload the new file as a separate item with a different name.
|
||||
</div>
|
||||
<div className="fm-conflict-rename-row">
|
||||
<label htmlFor="conflict-newname">New name</label>
|
||||
<input
|
||||
id="conflict-newname"
|
||||
type="text"
|
||||
value={customName}
|
||||
onChange={e => setCustomName(e.target.value)}
|
||||
className="fm-input"
|
||||
placeholder={suggestedName}
|
||||
/>
|
||||
{!isNameValid && customName.trim().length > 0 && existingNames.has(customName.trim()) && (
|
||||
<div className="fm-soft-text" style={{ marginTop: 6 }}>
|
||||
That name already exists.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
label="Keep both"
|
||||
variant="secondary"
|
||||
onClick={() => isNameValid && onKeepBoth(customName.trim())}
|
||||
disabled={!isNameValid}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fm-conflict-sep" />
|
||||
|
||||
<div className="fm-conflict-option">
|
||||
<div className="fm-conflict-option-title">Replace</div>
|
||||
<div className="fm-conflict-option-sub">
|
||||
Replace the existing file by uploading this as a new version of “{filename}”.
|
||||
</div>
|
||||
<Button label="Replace" variant="primary" onClick={onReplace} />
|
||||
</div>
|
||||
</div>
|
||||
{isTrashedExisting && (
|
||||
<div className="fm-callout fm-callout--warning" role="note" aria-live="polite" style={{ marginTop: 12 }}>
|
||||
<span className="fm-callout__icon" aria-hidden>
|
||||
<WarningIcon size="16px" />
|
||||
</span>
|
||||
<span className="fm-callout__text">
|
||||
<b>Heads up:</b> The existing '{filename}' is currently in <b>Trash</b>.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-footer">
|
||||
<div className="fm-expiring-notification-modal-footer-one-button">
|
||||
<Button label="Cancel" variant="secondary" onClick={onCancel} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
.vh-title {
|
||||
display: inline-block;
|
||||
max-width: min(48vw, 520px);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
.vh-title-sub {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.vh-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vh-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vh-footer-left {
|
||||
margin-right: auto;
|
||||
}
|
||||
.vh-footer-left .fm-button {
|
||||
width: auto;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.vh-footer-right {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vh-footer-right .fm-button {
|
||||
min-width: 120px;
|
||||
height: var(--fm-button-height, 40px);
|
||||
}
|
||||
|
||||
.vh-page {
|
||||
opacity: 0.7;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vh-footer {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vh-footer-right {
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
margin-left: 0;
|
||||
}
|
||||
.vh-footer-right .fm-button {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.vh-page {
|
||||
order: -1;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { ReactElement, useEffect, useMemo, useState, useCallback, useContext } from 'react'
|
||||
import './VersionHistoryModal.scss'
|
||||
import '../../styles/global.scss'
|
||||
|
||||
import { Button } from '../Button/Button'
|
||||
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 { FeedIndex } from '@ethersphere/bee-js'
|
||||
import { ConflictAction, useUploadConflictDialog } from '../../hooks/useUploadConflictDialog'
|
||||
import { ConfirmModal } from '../ConfirmModal/ConfirmModal'
|
||||
|
||||
import { indexStrToBigint } from '../../utils/common'
|
||||
import { VersionsList, truncateNameMiddle } from './VersionList/VersionList'
|
||||
import { ActionTag, DownloadProgress, TrackDownloadProps } from '../../constants/transfers'
|
||||
import { useTransfers } from '../../hooks/useTransfers'
|
||||
|
||||
const VERSION_HISTORY_PAGE_SIZE = 5
|
||||
|
||||
type RenameConfirmState = {
|
||||
version: FileInfo
|
||||
headName: string
|
||||
targetName: string
|
||||
}
|
||||
|
||||
interface VersionHistoryModalProps {
|
||||
fileInfo: FileInfo
|
||||
onCancelClick: () => void
|
||||
onDownload?: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
|
||||
}
|
||||
|
||||
export function VersionHistoryModal({ fileInfo, onCancelClick, onDownload }: VersionHistoryModalProps): ReactElement {
|
||||
const { fm, files, currentDrive } = useContext(FMContext)
|
||||
|
||||
const localTransfers = useTransfers({})
|
||||
const trackDownload = onDownload ?? localTransfers.trackDownload
|
||||
|
||||
const [openConflict, conflictPortal] = useUploadConflictDialog()
|
||||
const modalRoot = document.querySelector('.fm-main') || document.body
|
||||
|
||||
const [allVersions, setAllVersions] = useState<FileInfo[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [conflictWarning, setConflictWarning] = useState<string | null>(null)
|
||||
const [totalVersionsCount, setTotalVersionsCount] = useState<number>(0)
|
||||
|
||||
const [renameConfirm, setRenameConfirm] = useState<RenameConfirmState | null>(null)
|
||||
const [currentPage, setCurrentPage] = useState(0)
|
||||
|
||||
const currentVersion = useMemo(() => {
|
||||
return indexStrToBigint(fileInfo.version) ?? BigInt(0)
|
||||
}, [fileInfo])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalVersionsCount / VERSION_HISTORY_PAGE_SIZE))
|
||||
const pageVersions = useMemo(() => {
|
||||
const startIndex = currentPage * VERSION_HISTORY_PAGE_SIZE
|
||||
const endIndex = startIndex + VERSION_HISTORY_PAGE_SIZE
|
||||
|
||||
return allVersions.slice(startIndex, endIndex)
|
||||
}, [allVersions, currentPage])
|
||||
|
||||
const loadVersionsForPage = useCallback(
|
||||
async (page: number) => {
|
||||
if (!fm) return
|
||||
|
||||
const startIndex = page * VERSION_HISTORY_PAGE_SIZE
|
||||
const endIndex = startIndex + VERSION_HISTORY_PAGE_SIZE
|
||||
|
||||
let hasVersionsForPage = false
|
||||
setAllVersions(prevVersions => {
|
||||
const currentTotal = Number(currentVersion + BigInt(1))
|
||||
hasVersionsForPage =
|
||||
prevVersions.slice(startIndex, endIndex).length === VERSION_HISTORY_PAGE_SIZE ||
|
||||
(page === Math.floor(currentTotal / VERSION_HISTORY_PAGE_SIZE) &&
|
||||
currentTotal % VERSION_HISTORY_PAGE_SIZE > 0)
|
||||
|
||||
return prevVersions
|
||||
})
|
||||
|
||||
if (hasVersionsForPage) {
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
const startVersion = currentVersion - BigInt(page * VERSION_HISTORY_PAGE_SIZE)
|
||||
const endVersion = startVersion - BigInt(VERSION_HISTORY_PAGE_SIZE - 1)
|
||||
const versions: FileInfo[] = []
|
||||
|
||||
for (let i = startVersion; i >= BigInt(0) && i >= endVersion; i--) {
|
||||
try {
|
||||
const version = await fm.getVersion(fileInfo, FeedIndex.fromBigInt(i).toString())
|
||||
versions.push(version)
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Failed to get version: ${i}, err: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
setAllVersions(prev => {
|
||||
const updated = [...prev]
|
||||
const insertIndex = page * VERSION_HISTORY_PAGE_SIZE
|
||||
|
||||
for (let idx = 0; idx < versions.length; idx++) {
|
||||
updated[insertIndex + idx] = versions[idx]
|
||||
}
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
setLoading(false)
|
||||
},
|
||||
[fm, currentVersion, fileInfo],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(0)
|
||||
setError(null)
|
||||
setAllVersions([])
|
||||
|
||||
const totalCount = Number(currentVersion + BigInt(1))
|
||||
setTotalVersionsCount(totalCount)
|
||||
|
||||
if (!fm) {
|
||||
return
|
||||
}
|
||||
|
||||
loadVersionsForPage(0)
|
||||
}, [fm, fileInfo, currentVersion, loadVersionsForPage])
|
||||
|
||||
useEffect(() => {
|
||||
if (fm && currentPage > 0) {
|
||||
loadVersionsForPage(currentPage)
|
||||
}
|
||||
}, [currentPage, fm, loadVersionsForPage])
|
||||
|
||||
// TODO: why max not infinite?
|
||||
const promptUniqueName = useCallback(
|
||||
async (
|
||||
initial: string,
|
||||
taken: Set<string>,
|
||||
forbidReplaceMsg: string,
|
||||
maxAttempts = 6,
|
||||
): Promise<{ cancelled: boolean; name?: string }> => {
|
||||
let proposed = initial
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const choice = await openConflict({
|
||||
originalName: proposed,
|
||||
existingNames: taken,
|
||||
})
|
||||
|
||||
if (!choice || choice.action === ConflictAction.Cancel) return { cancelled: true }
|
||||
|
||||
if (choice.action === ConflictAction.KeepBoth) {
|
||||
if (!choice.newName || choice.newName.length === 0) {
|
||||
setConflictWarning('Empty new name. Please enter one.')
|
||||
|
||||
return { cancelled: false }
|
||||
}
|
||||
|
||||
const candidate = choice.newName.trim()
|
||||
|
||||
if (candidate && !taken.has(candidate)) {
|
||||
return { cancelled: false, name: candidate }
|
||||
}
|
||||
setConflictWarning('That name is already taken. Please enter a different one.')
|
||||
proposed = candidate || proposed
|
||||
} else {
|
||||
setConflictWarning(forbidReplaceMsg)
|
||||
}
|
||||
}
|
||||
|
||||
return { cancelled: true }
|
||||
},
|
||||
[openConflict],
|
||||
)
|
||||
|
||||
const doRestore = useCallback(
|
||||
async (versionFi: FileInfo): Promise<void> => {
|
||||
if (!fm || !currentDrive) return
|
||||
|
||||
try {
|
||||
const restoredFrom = indexStrToBigint(versionFi.version)
|
||||
|
||||
const srcLifecycleRaw = (versionFi.customMetadata?.lifecycle || '').trim().toLowerCase()
|
||||
const srcLifecycle: ActionTag | undefined =
|
||||
srcLifecycleRaw === ActionTag.Trashed || srcLifecycleRaw === ActionTag.Recovered
|
||||
? (srcLifecycleRaw as ActionTag)
|
||||
: undefined
|
||||
|
||||
const srcLifecycleAt =
|
||||
versionFi.customMetadata?.lifecycleAt ||
|
||||
(versionFi.timestamp ? new Date(versionFi.timestamp).toISOString() : undefined)
|
||||
|
||||
const withMeta: FileInfo = {
|
||||
...versionFi,
|
||||
customMetadata: {
|
||||
...(versionFi.customMetadata ?? {}),
|
||||
lifecycle: ActionTag.Restored,
|
||||
lifecycleFrom: restoredFrom !== undefined ? `v${restoredFrom}` : '',
|
||||
lifecycleAt: new Date().toISOString(),
|
||||
restoredFromLifecycle: srcLifecycle ?? '',
|
||||
restoredFromLifecycleAt: srcLifecycleAt ?? '',
|
||||
},
|
||||
}
|
||||
|
||||
await fm.restoreVersion(withMeta)
|
||||
onCancelClick()
|
||||
} catch (e) {
|
||||
const msg = (e as Error)?.message || JSON.stringify(e)
|
||||
setError(msg)
|
||||
}
|
||||
},
|
||||
[fm, onCancelClick, currentDrive],
|
||||
)
|
||||
|
||||
const restoreVersion = useCallback(
|
||||
async (versionFi: FileInfo): Promise<void> => {
|
||||
if (!fm) return
|
||||
|
||||
const targetName = versionFi.name
|
||||
const headName = fileInfo.name
|
||||
|
||||
const sameDrive = files.filter(fi => {
|
||||
return fi.driveId === versionFi.driveId.toString()
|
||||
})
|
||||
|
||||
const nameConflicts = sameDrive.filter(fi => fi.name === targetName)
|
||||
const otherHistoryConflicts = nameConflicts.filter(fi => fi.topic.toString() !== fileInfo.topic.toString())
|
||||
|
||||
if (targetName !== headName && otherHistoryConflicts.length === 0) {
|
||||
setRenameConfirm({ version: versionFi, headName, targetName })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (otherHistoryConflicts.length > 0) {
|
||||
const taken = new Set<string>(sameDrive.map(fi => fi.name))
|
||||
const forbidMsg =
|
||||
'Replace is not available because another file with that name belongs to a different history. Please choose “Keep both” and enter a different name.'
|
||||
const res = await promptUniqueName(targetName, taken, forbidMsg, 8)
|
||||
|
||||
if (res.cancelled || !res.name) return
|
||||
|
||||
versionFi.name = res.name
|
||||
}
|
||||
|
||||
await doRestore(versionFi)
|
||||
},
|
||||
[fm, fileInfo, files, promptUniqueName, doRestore],
|
||||
)
|
||||
|
||||
return createPortal(
|
||||
<div className="fm-modal-container">
|
||||
{conflictPortal}
|
||||
<div className="fm-modal-window fm-upgrade-drive-modal">
|
||||
<div className="fm-modal-window-header">
|
||||
<HistoryIcon size="21px" />
|
||||
<span className="fm-main-font-color">
|
||||
<>
|
||||
Version history –{' '}
|
||||
<span className="vh-title" title={fileInfo.name}>
|
||||
{truncateNameMiddle(fileInfo.name, 56)}
|
||||
</span>
|
||||
{fileInfo && (
|
||||
<span
|
||||
className="vh-title-sub"
|
||||
title={`Version v${(indexStrToBigint(fileInfo.version) ?? 0).toString()}`}
|
||||
>
|
||||
{' '}
|
||||
(version v{(indexStrToBigint(fileInfo.version) ?? 0).toString()})
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-body fm-expiring-notification-modal-body">
|
||||
{error && <div className="fm-modal-white-section fm-soft-text">{error}</div>}
|
||||
|
||||
{loading && <div className="fm-loading">Loading…</div>}
|
||||
{!error && !loading && pageVersions.length === 0 && (
|
||||
<div className="fm-empty">No versions found for this file.</div>
|
||||
)}
|
||||
{conflictWarning && (
|
||||
<div
|
||||
className="fm-modal-white-section fm-soft-text"
|
||||
style={{ borderLeft: '3px solid var(--fm-accent, #6aa7ff)' }}
|
||||
>
|
||||
{conflictWarning}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renameConfirm && (
|
||||
<ConfirmModal
|
||||
title="Restore this version?"
|
||||
message={
|
||||
<>
|
||||
Restoring will rename:
|
||||
<b className="vh-name" title={renameConfirm.headName}>
|
||||
{truncateNameMiddle(renameConfirm.headName, 44)}
|
||||
</b>{' '}
|
||||
→{' '}
|
||||
<b className="vh-name" title={renameConfirm.targetName}>
|
||||
{truncateNameMiddle(renameConfirm.targetName, 44)}
|
||||
</b>
|
||||
.
|
||||
</>
|
||||
}
|
||||
confirmLabel="Restore"
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={async () => {
|
||||
await doRestore(renameConfirm.version)
|
||||
setRenameConfirm(null)
|
||||
}}
|
||||
onCancel={() => setRenameConfirm(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<VersionsList
|
||||
versions={!error && !loading ? pageVersions : []}
|
||||
headFi={fileInfo}
|
||||
restoreVersion={restoreVersion}
|
||||
onDownload={trackDownload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fm-modal-window-footer vh-footer">
|
||||
<div className="vh-footer-left">
|
||||
<Button label="Close" variant="secondary" onClick={onCancelClick} />
|
||||
</div>
|
||||
<div className="vh-footer-right">
|
||||
<span className="vh-page">
|
||||
Page {Math.min(currentPage + 1, totalPages)} / {totalPages} · total {totalVersionsCount}
|
||||
</span>
|
||||
{currentPage > 0 && (
|
||||
<Button label="Previous" variant="secondary" onClick={() => setCurrentPage(p => p - 1)} />
|
||||
)}
|
||||
{currentPage + 1 < totalPages && (
|
||||
<Button label="Next" variant="primary" onClick={() => setCurrentPage(p => p + 1)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
modalRoot,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
.fm-version-history-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vh-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vh-left {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.vh-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
color: #4b5563;
|
||||
font-size: 13px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vh-meta-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.vh-dot {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.vh-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
background: #eef2ff;
|
||||
color: #3730a3;
|
||||
border: 1px solid #c7d2fe;
|
||||
}
|
||||
.vh-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 22px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
background: #f3f4f6;
|
||||
color: #111827;
|
||||
border: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.vh-tag--current {
|
||||
background: #dcfce7;
|
||||
border-color: #bbf7d0;
|
||||
color: #065f46;
|
||||
height: 18px;
|
||||
font-size: 11px;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
align-self: flex-start;
|
||||
flex: 0 0 auto;
|
||||
width: fit-content;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.vh-row:has(.vh-tag--current) .vh-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.vh-row:has(.vh-tag--current) .vh-chip {
|
||||
order: 1;
|
||||
}
|
||||
.vh-row:has(.vh-tag--current) .vh-meta-item,
|
||||
.vh-row:has(.vh-tag--current) .vh-dot {
|
||||
order: 2;
|
||||
}
|
||||
.vh-row:has(.vh-tag--current) .vh-meta::after {
|
||||
content: '';
|
||||
order: 2;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
.vh-row:has(.vh-tag--current) .vh-tag--current {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.vh-rename {
|
||||
font-size: 12.5px;
|
||||
color: #6b7280;
|
||||
max-width: 100%;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.vh-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.vh-actions {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
justify-self: end;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.vh-actions .fm-button {
|
||||
width: clamp(180px, 28ch, 180px);
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.vh-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.vh-actions {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
.vh-tag--trashed { background: #ffeaea; color: #b30000; }
|
||||
.vh-tag--recovered { background: #eaffea; color: #0b7a0b; }
|
||||
.vh-tag--restored { background: #e8f0ff; color: #1b4fd6; }
|
||||
.vh-lifecycle-note { margin-top: 6px; font-size: 12px; opacity: .8; }
|
||||
.vh-soft { opacity: .6; }
|
||||
|
||||
.vh-row:has(.vh-tag--current) .vh-meta {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.vh-row:has(.vh-tag--current) .vh-chip { order: 1; }
|
||||
|
||||
.vh-row:has(.vh-tag--current) .vh-tag--lifecycle,
|
||||
.vh-row:has(.vh-tag--current) .vh-meta-item,
|
||||
.vh-row:has(.vh-tag--current) .vh-dot { order: 2; }
|
||||
|
||||
.vh-row:has(.vh-tag--current) .vh-meta::after {
|
||||
content: '';
|
||||
order: 2;
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.vh-row:has(.vh-tag--current) .vh-tag--current { order: 3; }
|
||||
|
||||
.vh-toggle {
|
||||
appearance: none;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
margin-right: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vh-toggle-icon {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
display: inline-block;
|
||||
border-right: 2px solid currentColor;
|
||||
border-bottom: 2px solid currentColor;
|
||||
transform: rotate(-45deg);
|
||||
transition: transform .15s ease-in-out;
|
||||
opacity: .75;
|
||||
}
|
||||
|
||||
.vh-toggle.is-open .vh-toggle-icon {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.vh-row.is-collapsed {
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.vh-row.is-collapsed .vh-meta {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vh-row:hover .vh-toggle-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import './VersionList.scss'
|
||||
import '../../../styles/global.scss'
|
||||
import { memo, useState, useCallback, useContext } from 'react'
|
||||
|
||||
import { Button } from '../../Button/Button'
|
||||
import CalendarIcon from 'remixicon-react/CalendarLineIcon'
|
||||
import UserIcon from 'remixicon-react/UserLineIcon'
|
||||
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 { 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)}`
|
||||
}
|
||||
|
||||
interface VersionListProps {
|
||||
versions: FileInfo[]
|
||||
headFi: FileInfo
|
||||
onDownload: (props: TrackDownloadProps) => (dp: DownloadProgress) => void
|
||||
restoreVersion: (fi: FileInfo) => Promise<void>
|
||||
}
|
||||
|
||||
function formatMaybeIso(s?: string): string | undefined {
|
||||
if (!s) return undefined
|
||||
const d = new Date(s)
|
||||
|
||||
return isNaN(d.getTime()) ? undefined : d.toLocaleString()
|
||||
}
|
||||
|
||||
type LifecycleMetaConf = { className: string; label: (fi: FileInfo) => string }
|
||||
const LIFECYCLE_META: Record<ActionTag, LifecycleMetaConf> = {
|
||||
trashed: { className: 'vh-tag--trashed', label: () => capitalizeFirstLetter(ActionTag.Trashed) },
|
||||
recovered: { className: 'vh-tag--recovered', label: () => capitalizeFirstLetter(ActionTag.Recovered) },
|
||||
restored: {
|
||||
className: 'vh-tag--restored',
|
||||
label: fi => {
|
||||
const from = fi.customMetadata?.lifecycleFrom?.trim()
|
||||
|
||||
return from ? `Restored ${from}` : capitalizeFirstLetter(ActionTag.Restored)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function readLifecycleRaw(fi: FileInfo): string {
|
||||
return (fi.customMetadata?.lifecycle || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function hasSecondaryTrashOrRecovered(fi: FileInfo): boolean {
|
||||
if (readLifecycleRaw(fi) !== ActionTag.Restored) return false
|
||||
const src = (fi.customMetadata?.restoredFromLifecycle || '').trim().toLowerCase()
|
||||
|
||||
return src === ActionTag.Trashed || src === ActionTag.Recovered
|
||||
}
|
||||
|
||||
function isMinimizable(fi: FileInfo): boolean {
|
||||
const raw = readLifecycleRaw(fi)
|
||||
|
||||
return raw === ActionTag.Trashed || raw === ActionTag.Recovered || hasSecondaryTrashOrRecovered(fi)
|
||||
}
|
||||
|
||||
function getPrimaryLifecycle(fi: FileInfo) {
|
||||
const raw = readLifecycleRaw(fi) as keyof typeof LIFECYCLE_META
|
||||
const conf = LIFECYCLE_META[raw as ActionTag] as LifecycleMetaConf | undefined
|
||||
const label = conf?.label(fi)
|
||||
const cls = conf?.className
|
||||
const at =
|
||||
formatMaybeIso(fi.customMetadata?.lifecycleAt) ||
|
||||
(fi.timestamp ? new Date(fi.timestamp).toLocaleString() : undefined)
|
||||
|
||||
return { label, cls, at }
|
||||
}
|
||||
|
||||
function getSecondaryLifecycleIfRestored(fi: FileInfo) {
|
||||
const raw = readLifecycleRaw(fi)
|
||||
|
||||
if (raw !== ActionTag.Restored) return { label: undefined, cls: undefined, at: undefined }
|
||||
|
||||
const src = (fi.customMetadata?.restoredFromLifecycle || '').trim().toLowerCase()
|
||||
const isSupported = src === ActionTag.Trashed || src === ActionTag.Recovered
|
||||
|
||||
if (!isSupported) return { label: undefined, cls: undefined, at: undefined }
|
||||
|
||||
const conf = LIFECYCLE_META[src as ActionTag.Trashed | ActionTag.Recovered]
|
||||
const at =
|
||||
formatMaybeIso(fi.customMetadata?.restoredFromLifecycleAt) ||
|
||||
(fi.timestamp ? new Date(fi.timestamp).toLocaleString() : undefined)
|
||||
|
||||
return { label: conf.label(fi), cls: conf.className, at }
|
||||
}
|
||||
|
||||
type VersionRowProps = {
|
||||
item: FileInfo
|
||||
headFi: FileInfo
|
||||
isCurrent: boolean
|
||||
fmDownload: (fi: FileInfo) => void
|
||||
onRestore: (fi: FileInfo) => void
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}
|
||||
|
||||
function ToggleButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`vh-toggle ${collapsed ? '' : 'is-open'}`}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={collapsed ? 'Expand version details' : 'Collapse version details'}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="vh-toggle-icon" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function LifecycleBadges({
|
||||
lifeLabel,
|
||||
lifeClass,
|
||||
lifeAt,
|
||||
secLabel,
|
||||
secClass,
|
||||
secAt,
|
||||
}: {
|
||||
lifeLabel?: string
|
||||
lifeClass?: string
|
||||
lifeAt?: string
|
||||
secLabel?: string
|
||||
secClass?: string
|
||||
secAt?: string
|
||||
}) {
|
||||
if (!lifeLabel) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="vh-dot">•</span>
|
||||
<span
|
||||
className={`vh-tag vh-tag--lifecycle ${lifeClass ?? ''}`}
|
||||
title={lifeAt ? `${lifeLabel} at ${lifeAt}` : lifeLabel}
|
||||
>
|
||||
{lifeLabel}
|
||||
</span>
|
||||
|
||||
{secLabel && (
|
||||
<>
|
||||
<span className="vh-dot">•</span>
|
||||
<span
|
||||
className={`vh-tag vh-tag--lifecycle ${secClass ?? ''}`}
|
||||
title={secAt ? `${secLabel} at ${secAt}` : secLabel}
|
||||
>
|
||||
{secLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MetaItems({ modified }: { modified: string }) {
|
||||
return (
|
||||
<>
|
||||
<span className="vh-dot">•</span>
|
||||
<span className="vh-meta-item" title={modified}>
|
||||
<CalendarIcon size="12" /> {modified}
|
||||
</span>
|
||||
<span className="vh-dot">•</span>
|
||||
<span className="vh-meta-item" title="Publisher">
|
||||
<UserIcon size="12" />
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function MinimizedRow({
|
||||
idx,
|
||||
onToggle,
|
||||
lifeLabel,
|
||||
lifeClass,
|
||||
lifeAt,
|
||||
secLabel,
|
||||
secClass,
|
||||
secAt,
|
||||
}: {
|
||||
idx: bigint
|
||||
onToggle: () => void
|
||||
lifeLabel?: string
|
||||
lifeClass?: string
|
||||
lifeAt?: string
|
||||
secLabel?: string
|
||||
secClass?: string
|
||||
secAt?: string
|
||||
}) {
|
||||
return (
|
||||
<div className="fm-modal-white-section vh-row is-collapsed minimized">
|
||||
<div className="vh-left">
|
||||
<div className="vh-meta">
|
||||
<button
|
||||
type="button"
|
||||
className="vh-toggle"
|
||||
aria-expanded="false"
|
||||
aria-label="Expand version details"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span className="vh-toggle-icon" />
|
||||
</button>
|
||||
|
||||
<span className="vh-chip" title={`Version ${idx.toString()}`}>
|
||||
v{idx.toString()}
|
||||
</span>
|
||||
|
||||
{(lifeLabel === capitalizeFirstLetter(ActionTag.Trashed) ||
|
||||
lifeLabel === capitalizeFirstLetter(ActionTag.Recovered)) && (
|
||||
<>
|
||||
<span className="vh-dot">•</span>
|
||||
<span
|
||||
className={`vh-tag vh-tag--lifecycle ${lifeClass ?? ''}`}
|
||||
title={lifeAt ? `${lifeLabel} at ${lifeAt}` : lifeLabel}
|
||||
>
|
||||
{lifeLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{secLabel &&
|
||||
(secLabel === capitalizeFirstLetter(ActionTag.Trashed) ||
|
||||
secLabel === capitalizeFirstLetter(ActionTag.Recovered)) && (
|
||||
<>
|
||||
<span className="vh-dot">•</span>
|
||||
<span
|
||||
className={`vh-tag vh-tag--lifecycle ${secClass ?? ''}`}
|
||||
title={secAt ? `${secLabel} at ${secAt}` : secLabel}
|
||||
>
|
||||
{secLabel}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type RowFullProps = {
|
||||
idx: bigint
|
||||
item: FileInfo
|
||||
headFi: FileInfo
|
||||
isCurrent: boolean
|
||||
fmDownload: (fi: FileInfo) => void
|
||||
onRestore: (fi: FileInfo) => void
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
modified: string
|
||||
lifeLabel?: string
|
||||
lifeClass?: string
|
||||
lifeAt?: string
|
||||
secLabel?: string
|
||||
secClass?: string
|
||||
secAt?: string
|
||||
}
|
||||
|
||||
const RowFull = memo(
|
||||
({
|
||||
idx,
|
||||
item,
|
||||
headFi,
|
||||
isCurrent,
|
||||
fmDownload,
|
||||
onRestore,
|
||||
collapsed,
|
||||
onToggle,
|
||||
modified,
|
||||
lifeLabel,
|
||||
lifeClass,
|
||||
lifeAt,
|
||||
secLabel,
|
||||
secClass,
|
||||
secAt,
|
||||
}: RowFullProps) => {
|
||||
const willRename = headFi.name !== item.name
|
||||
|
||||
return (
|
||||
<div className={`fm-modal-white-section vh-row ${collapsed ? 'is-collapsed' : ''}`}>
|
||||
<div className="vh-left">
|
||||
<div className="vh-meta">
|
||||
<ToggleButton collapsed={collapsed} onToggle={onToggle} />
|
||||
|
||||
<span className="vh-chip" title={`Version ${idx.toString()}`}>
|
||||
v{idx.toString()}
|
||||
</span>
|
||||
|
||||
{isCurrent && <span className="vh-tag vh-tag--current">Current</span>}
|
||||
|
||||
<LifecycleBadges
|
||||
lifeLabel={lifeLabel}
|
||||
lifeClass={lifeClass}
|
||||
lifeAt={lifeAt}
|
||||
secLabel={secLabel}
|
||||
secClass={secClass}
|
||||
secAt={secAt}
|
||||
/>
|
||||
|
||||
<MetaItems modified={modified} />
|
||||
</div>
|
||||
|
||||
{!collapsed && lifeAt && lifeLabel && (
|
||||
<div className="vh-lifecycle-note">
|
||||
{lifeLabel} <span className="vh-soft">·</span> {lifeAt}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && willRename && !isCurrent && (
|
||||
<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)}
|
||||
</b>{' '}
|
||||
→{' '}
|
||||
<b className="vh-name" title={item.name}>
|
||||
{truncateNameMiddle(item.name, 44)}
|
||||
</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="vh-actions">
|
||||
<Button
|
||||
label="Download"
|
||||
variant="secondary"
|
||||
icon={<DownloadIcon size="15" />}
|
||||
onClick={() => fmDownload(item)}
|
||||
/>
|
||||
{!isCurrent && <Button label="Restore" variant="primary" onClick={() => onRestore(item)} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
RowFull.displayName = 'RowFull'
|
||||
|
||||
const VersionRow = memo(({ item, headFi, isCurrent, fmDownload, onRestore, collapsed, onToggle }: VersionRowProps) => {
|
||||
const idx = indexStrToBigint(item.version)
|
||||
|
||||
if (idx === undefined) return null
|
||||
|
||||
const modified = item.timestamp !== undefined ? new Date(item.timestamp).toLocaleString() : '—'
|
||||
const { label: lifeLabel, cls: lifeClass, at: lifeAt } = getPrimaryLifecycle(item)
|
||||
const { label: secLabel, cls: secClass, at: secAt } = getSecondaryLifecycleIfRestored(item)
|
||||
const minimized = collapsed && isMinimizable(item)
|
||||
|
||||
if (minimized) {
|
||||
return (
|
||||
<MinimizedRow
|
||||
idx={idx}
|
||||
onToggle={onToggle}
|
||||
lifeLabel={lifeLabel}
|
||||
lifeClass={lifeClass}
|
||||
lifeAt={lifeAt}
|
||||
secLabel={secLabel}
|
||||
secClass={secClass}
|
||||
secAt={secAt}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<RowFull
|
||||
idx={idx}
|
||||
item={item}
|
||||
headFi={headFi}
|
||||
isCurrent={isCurrent}
|
||||
fmDownload={fmDownload}
|
||||
onRestore={onRestore}
|
||||
collapsed={collapsed}
|
||||
onToggle={onToggle}
|
||||
modified={modified}
|
||||
lifeLabel={lifeLabel}
|
||||
lifeClass={lifeClass}
|
||||
lifeAt={lifeAt}
|
||||
secLabel={secLabel}
|
||||
secClass={secClass}
|
||||
secAt={secAt}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
VersionRow.displayName = 'VersionRow'
|
||||
|
||||
export function VersionsList({ versions, headFi, restoreVersion, onDownload }: VersionListProps) {
|
||||
const { handleCloseContext } = useContextMenu<HTMLDivElement>()
|
||||
|
||||
const { fm } = useContext(FMContext)
|
||||
const { beeApi } = useContext(SettingsContext)
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>({})
|
||||
|
||||
const toggle = useCallback(
|
||||
(key: string, fi: FileInfo) => {
|
||||
setExpanded(prev => {
|
||||
const next = { ...prev }
|
||||
const hasValue = Object.prototype.hasOwnProperty.call(next, key)
|
||||
|
||||
if (hasValue) {
|
||||
next[key] = !next[key]
|
||||
} else {
|
||||
const isCurrent = indexStrToBigint(fi.version) === indexStrToBigint(headFi.version)
|
||||
const defaultCollapsed = !isCurrent
|
||||
|
||||
next[key] = defaultCollapsed
|
||||
}
|
||||
|
||||
return next
|
||||
})
|
||||
},
|
||||
[headFi],
|
||||
)
|
||||
|
||||
const handleDownload = useCallback(
|
||||
async (fileInfo: FileInfo) => {
|
||||
handleCloseContext()
|
||||
|
||||
if (!fm || !beeApi) return
|
||||
const rawSize = fileInfo.customMetadata?.size
|
||||
const expectedSize = rawSize ? Number(rawSize) : undefined
|
||||
await startDownloadingQueue(
|
||||
fm,
|
||||
[fileInfo],
|
||||
[onDownload({ name: fileInfo.name, size: formatBytes(rawSize), expectedSize })],
|
||||
)
|
||||
},
|
||||
[handleCloseContext, fm, beeApi, onDownload],
|
||||
)
|
||||
|
||||
if (!versions.length || !fm) return null
|
||||
|
||||
return (
|
||||
<div className="fm-version-history-list">
|
||||
{versions.map(item => {
|
||||
const idx = indexStrToBigint(item.version)
|
||||
|
||||
if (idx === undefined) return null
|
||||
|
||||
const key = `${item.topic.toString()}:${idx.toString()}`
|
||||
const hasExplicit = Object.prototype.hasOwnProperty.call(expanded, key)
|
||||
|
||||
const isCurrent = indexStrToBigint(headFi.version) === idx
|
||||
const defaultCollapsed = !isCurrent
|
||||
|
||||
const collapsed = hasExplicit ? !expanded[key] : defaultCollapsed
|
||||
|
||||
return (
|
||||
<VersionRow
|
||||
key={key}
|
||||
item={item}
|
||||
headFi={headFi}
|
||||
isCurrent={Boolean(isCurrent)}
|
||||
fmDownload={() => handleDownload(item)}
|
||||
onRestore={restoreVersion}
|
||||
collapsed={collapsed}
|
||||
onToggle={() => toggle(key, item)}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user