frontend performance, rpc/rest jwt authentication
This commit is contained in:
256
frontend/src/views/Archive.tsx
Normal file
256
frontend/src/views/Archive.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import {
|
||||
Backdrop,
|
||||
Button,
|
||||
Checkbox,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Paper,
|
||||
SpeedDial,
|
||||
SpeedDialAction,
|
||||
SpeedDialIcon,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
|
||||
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
|
||||
import FolderIcon from '@mui/icons-material/Folder'
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||
import VideoFileIcon from '@mui/icons-material/VideoFile'
|
||||
|
||||
import { Buffer } from 'buffer'
|
||||
import { useEffect, useMemo, useState, useTransition } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
||||
import { useObservable } from '../hooks/observable'
|
||||
import { RootState } from '../stores/store'
|
||||
import { DeleteRequest, DirectoryEntry } from '../types'
|
||||
import { roundMiB } from '../utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ffetch } from '../lib/httpClient'
|
||||
|
||||
export default function Downloaded() {
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const [openDialog, setOpenDialog] = useState(false)
|
||||
|
||||
const serverAddr =
|
||||
`${window.location.protocol}//${settings.serverAddr}:${settings.serverPort}`
|
||||
|
||||
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
||||
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
||||
|
||||
const [isPending, startTransition] = useTransition()
|
||||
|
||||
const fetcher = () => ffetch<DirectoryEntry[]>(
|
||||
`${serverAddr}/archive/downloaded`,
|
||||
(d) => files$.next(d),
|
||||
() => navigate('/login'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
subdir: '',
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
const fetcherSubfolder = (sub: string) => {
|
||||
const folders = sub.startsWith('/')
|
||||
? sub.substring(1).split('/')
|
||||
: sub.split('/')
|
||||
|
||||
const relpath = folders.length >= 2
|
||||
? folders.slice(-(folders.length - 1)).join('/')
|
||||
: folders.pop()
|
||||
|
||||
const _upperLevel = folders.slice(1, -1)
|
||||
const upperLevel = _upperLevel.length === 2
|
||||
? ['.', ..._upperLevel].join('/')
|
||||
: _upperLevel.join('/')
|
||||
|
||||
fetch(`${serverAddr}/archive/downloaded`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ subdir: relpath })
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
files$.next(sub
|
||||
? [{
|
||||
name: '..',
|
||||
isDirectory: true,
|
||||
path: upperLevel,
|
||||
}, ...data]
|
||||
: data
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const selectable$ = useMemo(() => files$.pipe(
|
||||
combineLatestWith(selected$),
|
||||
map(([data, selected]) => data.map(x => ({
|
||||
...x,
|
||||
selected: selected.includes(x.name)
|
||||
}))),
|
||||
share()
|
||||
), [])
|
||||
|
||||
const selectable = useObservable(selectable$, [])
|
||||
|
||||
const addSelected = (name: string) => {
|
||||
selected$.value.includes(name)
|
||||
? selected$.next(selected$.value.filter(val => val !== name))
|
||||
: selected$.next([...selected$.value, name])
|
||||
}
|
||||
|
||||
const deleteSelected = () => {
|
||||
Promise.all(selectable
|
||||
.filter(entry => entry.selected)
|
||||
.map(entry => fetch(`${serverAddr}/archive/delete`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
path: entry.path,
|
||||
shaSum: entry.shaSum,
|
||||
} as DeleteRequest)
|
||||
}))
|
||||
).then(fetcher)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetcher()
|
||||
}, [settings.serverAddr, settings.serverPort])
|
||||
|
||||
|
||||
const onFileClick = (path: string) => startTransition(() => {
|
||||
window.open(`${serverAddr}/archive/d/${Buffer.from(path).toString('hex')}`)
|
||||
})
|
||||
|
||||
const onFolderClick = (path: string) => startTransition(() => {
|
||||
fetcherSubfolder(path)
|
||||
})
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={!(files$.observed) || isPending}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Backdrop>
|
||||
<Paper sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}>
|
||||
<Typography py={1} variant="h5" color="primary">
|
||||
{'Archive'}
|
||||
</Typography>
|
||||
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
||||
{selectable.length === 0 && 'No files found'}
|
||||
{selectable.map((file, idx) => (
|
||||
<ListItem
|
||||
key={idx}
|
||||
secondaryAction={
|
||||
<div>
|
||||
{!file.isDirectory && <Typography
|
||||
variant="caption"
|
||||
component="span"
|
||||
>
|
||||
{roundMiB(file.size)}
|
||||
</Typography>
|
||||
}
|
||||
{!file.isDirectory && <Checkbox
|
||||
edge="end"
|
||||
checked={file.selected}
|
||||
onChange={() => addSelected(file.name)}
|
||||
/>}
|
||||
</div>
|
||||
}
|
||||
disablePadding
|
||||
>
|
||||
<ListItemButton onClick={
|
||||
() => file.isDirectory
|
||||
? onFolderClick(file.path)
|
||||
: onFileClick(file.path)
|
||||
}>
|
||||
<ListItemIcon>
|
||||
{file.isDirectory
|
||||
? <FolderIcon />
|
||||
: file.isVideo
|
||||
? <VideoFileIcon />
|
||||
: <InsertDriveFileIcon />
|
||||
}
|
||||
</ListItemIcon>
|
||||
<ListItemText
|
||||
primary={file.name}
|
||||
secondary={file.name != '..' && new Date(file.modTime).toLocaleString()}
|
||||
/>
|
||||
</ListItemButton>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
</Paper>
|
||||
<SpeedDial
|
||||
ariaLabel="SpeedDial basic example"
|
||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||
icon={<SpeedDialIcon />}
|
||||
>
|
||||
<SpeedDialAction
|
||||
icon={<DeleteForeverIcon />}
|
||||
tooltipTitle={`Delete selected`}
|
||||
tooltipOpen
|
||||
onClick={() => {
|
||||
if (selected$.value.length > 0) {
|
||||
setOpenDialog(true)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</SpeedDial>
|
||||
<Dialog
|
||||
open={openDialog}
|
||||
onClose={() => setOpenDialog(false)}
|
||||
>
|
||||
<DialogTitle>
|
||||
Are you sure?
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
You're deleting:
|
||||
</DialogContentText>
|
||||
<ul>
|
||||
{selected$.value.map((entry, idx) => (
|
||||
<li key={idx}>{entry}</li>
|
||||
))}
|
||||
</ul>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpenDialog(false)}>Cancel</Button>
|
||||
<Button onClick={() => {
|
||||
deleteSelected()
|
||||
setOpenDialog(false)
|
||||
}} autoFocus
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user