Feat: FileManager (#98) (#703)

* feat: add file manager module

- Complete file manager implementation with UI/UX
- Add drive management functionality
- Add file upload/download with progress tracking
- Add stamp integration and handling
- Add bulk operations and context menus

Co-authored-by: Roland Seres <roland.seres90@gmail.com>
Co-authored-by: nidishk <nidishkrishnan45@gmail.com>
This commit is contained in:
Bálint Ujvári
2025-11-12 11:26:00 +01:00
committed by GitHub
parent 1249c0df71
commit 5bfe2a0331
107 changed files with 21529 additions and 5578 deletions
@@ -63,7 +63,7 @@ export function AccountChequebook(): ReactElement {
/>
<ExpandableListItemKey
label="Chequebook contract address"
value={chequebookAddress?.chequebookAddress || ''}
value={chequebookAddress?.chequebookAddress.toString() || ''}
/>
</ExpandableList>
<PeerBalances accounting={accounting} isLoadingUncashed={isLoadingUncashed} totalUncashed={totalUncashed} />
+52
View File
@@ -0,0 +1,52 @@
.fm-main {
position: relative;
border-left: 1px solid rgb(146, 146, 146);
min-height: 100vh;
overflow: hidden;
}
.fm-main-content {
display: flex;
}
.fm-main-content-file-browser {
width: 100%;
background-color: rgb(255, 255, 255) !important;
}
.fm-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 12px;
background: rgb(255, 255, 255);
overflow: hidden;
}
.fm-spinner {
width: 32px;
height: 32px;
border-radius: 9999px;
border: 3px solid rgb(229, 231, 235);
border-top-color: rgb(255, 140, 0);
animation: fm-spin 0.8s linear infinite;
}
@keyframes fm-spin {
to {
transform: rotate(360deg);
}
}
.fm-loading-title {
font-size: 16px;
font-weight: 600;
color: rgb(17, 24, 39);
}
.fm-loading-subtitle {
font-size: 13px;
color: rgb(107, 114, 128);
}
+82
View File
@@ -0,0 +1,82 @@
import { createContext, useContext, useMemo, useRef, useState, ReactNode, useCallback } from 'react'
type Scope = 'selected' | 'all'
export interface SearchState {
query: string
scope: Scope
includeActive: boolean
includeTrashed: boolean
setQuery: (q: string) => void
clear: () => void
setScope: (s: Scope) => void
setIncludeActive: (v: boolean) => void
setIncludeTrashed: (v: boolean) => void
}
const Ctx = createContext<SearchState | undefined>(undefined)
export function SearchProvider({ children }: { children: ReactNode }) {
const [query, _setQuery] = useState('')
const [scope, setScope] = useState<Scope>('all')
const [includeActive, setIncludeActive] = useState(true)
const [includeTrashed, setIncludeTrashed] = useState(true)
const preSearchState = useRef<{ scope: Scope; includeActive: boolean; includeTrashed: boolean } | null>(null)
const inSearch = useRef(false)
const setQuery = useCallback(
(q: string) => {
const trimmed = q.trim()
if (!inSearch.current && trimmed.length > 0) {
preSearchState.current = { scope, includeActive, includeTrashed }
inSearch.current = true
}
if (inSearch.current && trimmed.length === 0) {
const prev = preSearchState.current
if (prev) {
setScope(prev.scope)
setIncludeActive(prev.includeActive)
setIncludeTrashed(prev.includeTrashed)
}
preSearchState.current = null
inSearch.current = false
}
_setQuery(q)
},
[scope, includeActive, includeTrashed],
)
const clear = useCallback(() => {
setQuery('')
}, [setQuery])
const value = useMemo<SearchState>(
() => ({
query,
scope,
includeActive,
includeTrashed,
setQuery,
clear,
setScope,
setIncludeActive,
setIncludeTrashed,
}),
[query, scope, includeActive, includeTrashed, clear, setQuery],
)
return <Ctx.Provider value={value}>{children}</Ctx.Provider>
}
export function useSearch(): SearchState {
const v = useContext(Ctx)
if (!v) throw new Error('useFMSearch must be used within SearchProvider')
return v
}
+39
View File
@@ -0,0 +1,39 @@
import { createContext, useContext, useState, ReactNode } from 'react'
import { ViewType } from '../../modules/filemanager/constants/transfers'
interface ViewContextProps {
view: ViewType
setView: (view: ViewType) => void
actualItemView?: string
setActualItemView?: (view: string) => void
}
const ViewContext = createContext<ViewContextProps | undefined>(undefined)
export function ViewProvider({ children }: { children: ReactNode }) {
const [view, setView] = useState<ViewType>(ViewType.File)
const [actualItemView, setActualItemView] = useState<string | undefined>(undefined)
return (
<ViewContext.Provider
value={{
view,
setView,
actualItemView,
setActualItemView,
}}
>
{children}
</ViewContext.Provider>
)
}
export function useView() {
const context = useContext(ViewContext)
if (!context) {
throw new Error('useView must be used within a ViewProvider')
}
return context
}
+229
View File
@@ -0,0 +1,229 @@
import { ReactElement, useContext, useEffect, useState, useRef, useCallback, useMemo } from 'react'
import './FileManager.scss'
import { SearchProvider } from './SearchContext'
import { ViewProvider } from './ViewContext'
import { Header } from '../../modules/filemanager/components/Header/Header'
import { Sidebar } from '../../modules/filemanager/components/Sidebar/Sidebar'
import { AdminStatusBar } from '../../modules/filemanager/components/AdminStatusBar/AdminStatusBar'
import { FileBrowser } from '../../modules/filemanager/components/FileBrowser/FileBrowser'
import { InitialModal } from '../../modules/filemanager/components/InitialModal/InitialModal'
import { Context as FMContext } from '../../providers/FileManager'
import { Context as BeeContext, CheckState } from '../../providers/Bee'
import { PrivateKeyModal } from '../../modules/filemanager/components/PrivateKeyModal/PrivateKeyModal'
import { getSignerPk, removeSignerPk } from '../../../src/modules/filemanager/utils/common'
import { ErrorModal } from '../../../src/modules/filemanager/components/ErrorModal/ErrorModal'
import { ConfirmModal } from '../../modules/filemanager/components/ConfirmModal/ConfirmModal'
import { Button } from '../../modules/filemanager/components/Button/Button'
import { FormbricksIntegration } from '../../modules/filemanager/components/FormbricksIntegration/FormbricksIntegration'
export function FileManagerPage(): ReactElement {
const isMountedRef = useRef(true)
const [showInitialModal, setShowInitialModal] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [hasAdminDrive, setHasAdminDrive] = useState(false)
const [hasPk, setHasPk] = useState<boolean>(getSignerPk() !== undefined)
const [showErrorModal, setShowErrorModal] = useState<boolean>(false)
const [errorMessage, setErrorMessage] = useState<string>('')
const [showResetModal, setShowResetModal] = useState<boolean>(false)
const [isCreationInProgress, setIsCreationInProgress] = useState<boolean>(false)
const { status } = useContext(BeeContext)
const { fm, shallReset, adminDrive, initializationError, init } = useContext(FMContext)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
}
}, [])
useEffect(() => {
if (!hasPk) {
setIsLoading(false)
return
}
setShowResetModal(shallReset)
if (shallReset) {
setShowInitialModal(true)
return
}
if (initializationError) {
setIsLoading(false)
return
}
if (fm) {
const hasAdminStamp = Boolean(fm.adminStamp)
const tmpHasAdminDrive = Boolean(adminDrive)
setHasAdminDrive(hasAdminStamp || tmpHasAdminDrive)
setIsLoading(false)
setShowInitialModal(!(hasAdminStamp || tmpHasAdminDrive))
return
}
setIsLoading(true)
}, [fm, hasPk, initializationError, adminDrive, shallReset])
const handlePrivateKeySaved = useCallback(async () => {
if (!isMountedRef.current) return
setHasPk(true)
if (fm) {
if (!isMountedRef.current) return
setIsLoading(false)
return
}
setIsLoading(true)
const manager = await init()
if (!isMountedRef.current) return
setIsLoading(false)
const hasAdminStamp = Boolean(manager?.adminStamp)
const tmpHasAdminDrive = Boolean(adminDrive)
setShowInitialModal(!(hasAdminStamp || tmpHasAdminDrive))
}, [fm, adminDrive, init])
const isEmptyState = useMemo(() => {
return showInitialModal && !isLoading && !hasAdminDrive && !isCreationInProgress
}, [showInitialModal, isLoading, hasAdminDrive, isCreationInProgress])
const isInvalidState = useMemo(
() => shallReset && fm && !isCreationInProgress,
[shallReset, fm, isCreationInProgress],
)
const loading = !fm?.adminStamp || !adminDrive
const isFormbricksActive = Boolean(fm && fm.adminStamp && adminDrive && !showInitialModal && !loading)
if (status.all !== CheckState.OK) {
return (
<div className="fm-main">
<div className="fm-loading">
<div className="fm-loading-title">Bee node error - cannot load File Manager</div>
</div>
</div>
)
}
if (!hasPk) {
return (
<div className="fm-main">
<PrivateKeyModal onSaved={handlePrivateKeySaved} />
</div>
)
}
if (initializationError && !isLoading && !shallReset) {
return (
<div className="fm-main">
<div className="fm-loading">
<div className="fm-loading-title">Failed to initialize File Manager, reload and try again </div>
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '16px' }}>
<div style={{ minWidth: '120px' }}>
<Button
label={'OK'}
variant="primary"
disabled={false}
onClick={() => {
removeSignerPk()
setHasPk(false)
}}
/>
</div>
</div>
</div>
</div>
)
}
if (showResetModal) {
return (
<div className="fm-main">
<ConfirmModal
title="Reset File Manager State"
message="Your File Manager state appears invalid. Please reset it to continue."
confirmLabel="Proceed"
onConfirm={() => {
setShowResetModal(false)
}}
background={false}
/>
</div>
)
}
if (!showErrorModal && (isEmptyState || isInvalidState)) {
return (
<div className="fm-main">
<InitialModal
resetState={shallReset}
handleVisibility={(isVisible: boolean) => setShowInitialModal(isVisible)}
handleShowError={(flag: boolean) => setShowErrorModal(flag)}
setIsCreationInProgress={(isCreating: boolean) => setIsCreationInProgress(isCreating)}
/>
</div>
)
}
if (!fm) {
return (
<div className="fm-main">
<div className="fm-loading" aria-live="polite">
<div className="fm-spinner" aria-hidden="true" />
<div className="fm-loading-title">File manager loading</div>
<div className="fm-loading-subtitle">Please wait a few seconds</div>
</div>
</div>
)
}
if (showErrorModal) {
return (
<ErrorModal
label={'Error during admin state creation, try again'}
onClick={() => {
setShowErrorModal(false)
setShowInitialModal(true)
}}
/>
)
}
return (
<SearchProvider>
<ViewProvider>
<div className="fm-main">
<FormbricksIntegration isActive={isFormbricksActive} />
<Header />
<div className="fm-main-content">
<Sidebar errorMessage={errorMessage} setErrorMessage={setErrorMessage} loading={loading} />
<FileBrowser errorMessage={errorMessage} setErrorMessage={setErrorMessage} />
</div>
<AdminStatusBar
adminStamp={fm?.adminStamp || null}
adminDrive={adminDrive}
loading={loading}
isCreationInProgress={isCreationInProgress}
setErrorMessage={setErrorMessage}
/>
</div>
</ViewProvider>
</SearchProvider>
)
}
+5 -3
View File
@@ -12,7 +12,7 @@ interface Props {
export function AssetSyncing({ reference }: Props): ReactElement {
const { beeApi } = useContext(SettingsContext)
const syncTimer = useRef<NodeJS.Timer>()
const syncTimer = useRef<ReturnType<typeof setInterval> | null>(null)
const [isRetrieveChecking, setIsRetrieveChecking] = useState<boolean>(false)
const [syncProgress, setSyncProgress] = useState<number>(0)
@@ -22,7 +22,7 @@ export function AssetSyncing({ reference }: Props): ReactElement {
let allTags: Tag[] = []
let offset = 0
const limit = 1000
let tagsBatch
let tagsBatch: Tag[]
do {
tagsBatch = await beeApi.getAllTags({ limit, offset })
@@ -32,7 +32,7 @@ export function AssetSyncing({ reference }: Props): ReactElement {
const tag = allTags.find(t => t.address === reference)
if (tag) {
if (tag && tag.split > 0) {
const progress = ((tag.seen + tag.synced) / tag.split) * 100
setSyncProgress(progress)
}
@@ -44,6 +44,7 @@ export function AssetSyncing({ reference }: Props): ReactElement {
return () => {
if (syncTimer.current) {
clearInterval(syncTimer.current)
syncTimer.current = null
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -52,6 +53,7 @@ export function AssetSyncing({ reference }: Props): ReactElement {
useEffect(() => {
if (syncProgress === 100 && syncTimer.current) {
clearInterval(syncTimer.current)
syncTimer.current = null
}
}, [syncProgress])
@@ -55,7 +55,7 @@ const ChequebookDeployFund = (): ReactElement | null => {
<ExpandableListItemNote>{text}</ExpandableListItemNote>
{chequebookAddress && (
<>
<ExpandableListItemKey label="Chequebook Address" value={chequebookAddress.chequebookAddress} />
<ExpandableListItemKey label="Chequebook Address" value={chequebookAddress.chequebookAddress.toString()} />
<ExpandableListItemActions>
<DepositModal />
</ExpandableListItemActions>