* test logging * test impl for logging * implemented "live logging", restyle templates dropdown * moved extract audio to downloadDialog, fixed labels * code refactoring * buffering logs
365 lines
9.7 KiB
TypeScript
365 lines
9.7 KiB
TypeScript
import {
|
|
Backdrop,
|
|
Button,
|
|
Checkbox,
|
|
CircularProgress,
|
|
Container,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogContentText,
|
|
DialogTitle,
|
|
List,
|
|
ListItem,
|
|
ListItemButton,
|
|
ListItemIcon,
|
|
ListItemText,
|
|
MenuItem,
|
|
MenuList,
|
|
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 DownloadIcon from '@mui/icons-material/Download'
|
|
import { matchW } from 'fp-ts/lib/TaskEither'
|
|
import { pipe } from 'fp-ts/lib/function'
|
|
import { useEffect, useMemo, useState, useTransition } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { useRecoilValue } from 'recoil'
|
|
import { BehaviorSubject, Subject, combineLatestWith, map, share } from 'rxjs'
|
|
import { serverURL } from '../atoms/settings'
|
|
import { useObservable } from '../hooks/observable'
|
|
import { useToast } from '../hooks/toast'
|
|
import { useI18n } from '../hooks/useI18n'
|
|
import { ffetch } from '../lib/httpClient'
|
|
import { DirectoryEntry } from '../types'
|
|
import { base64URLEncode, roundMiB } from '../utils'
|
|
|
|
export default function Downloaded() {
|
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
|
|
const [showMenu, setShowMenu] = useState(false)
|
|
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
|
|
|
|
const serverAddr = useRecoilValue(serverURL)
|
|
const navigate = useNavigate()
|
|
|
|
const { i18n } = useI18n()
|
|
const { pushMessage } = useToast()
|
|
|
|
const [openDialog, setOpenDialog] = useState(false)
|
|
|
|
const files$ = useMemo(() => new Subject<DirectoryEntry[]>(), [])
|
|
const selected$ = useMemo(() => new BehaviorSubject<string[]>([]), [])
|
|
|
|
const [isPending, startTransition] = useTransition()
|
|
|
|
const fetcher = () => pipe(
|
|
ffetch<DirectoryEntry[]>(
|
|
`${serverAddr}/archive/downloaded`,
|
|
{
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
subdir: '',
|
|
})
|
|
}
|
|
),
|
|
matchW(
|
|
(e) => {
|
|
pushMessage(e, 'error')
|
|
navigate('/login')
|
|
},
|
|
(d) => files$.next(d ?? []),
|
|
)
|
|
)()
|
|
|
|
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('/')
|
|
|
|
const task = ffetch<DirectoryEntry[]>(`${serverAddr}/archive/downloaded`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ subdir: relpath })
|
|
})
|
|
|
|
pipe(
|
|
task,
|
|
matchW(
|
|
(l) => pushMessage(l, 'error'),
|
|
(r) => files$.next(sub
|
|
? [{
|
|
isDirectory: true,
|
|
isVideo: false,
|
|
modTime: '',
|
|
name: '..',
|
|
path: upperLevel,
|
|
shaSum: '',
|
|
size: 0,
|
|
}, ...r.filter(f => f.name !== '')]
|
|
: r.filter(f => f.name !== '')
|
|
)
|
|
)
|
|
)()
|
|
}
|
|
|
|
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 deleteFile = (entry: DirectoryEntry) => pipe(
|
|
ffetch(`${serverAddr}/archive/delete`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({
|
|
path: entry.path,
|
|
shaSum: entry.shaSum,
|
|
})
|
|
}),
|
|
matchW(
|
|
(l) => pushMessage(l, 'error'),
|
|
(_) => fetcher()
|
|
)
|
|
)()
|
|
|
|
const deleteSelected = () => {
|
|
Promise.all(selectable
|
|
.filter(entry => entry.selected)
|
|
.map(deleteFile)
|
|
).then(fetcher)
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetcher()
|
|
}, [serverAddr])
|
|
|
|
const onFileClick = (path: string) => startTransition(() => {
|
|
const encoded = base64URLEncode(path)
|
|
|
|
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
|
|
})
|
|
|
|
const downloadFile = (path: string) => startTransition(() => {
|
|
const encoded = base64URLEncode(path)
|
|
|
|
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
|
|
})
|
|
|
|
const onFolderClick = (path: string) => startTransition(() => {
|
|
fetcherSubfolder(path)
|
|
})
|
|
|
|
return (
|
|
<Container
|
|
maxWidth="lg"
|
|
sx={{ mt: 4, mb: 4, height: '100%' }}
|
|
onClick={() => setShowMenu(false)}
|
|
>
|
|
<IconMenu
|
|
posX={menuPos.x}
|
|
posY={menuPos.y}
|
|
hide={!showMenu}
|
|
onDownload={() => {
|
|
if (currentFile) {
|
|
downloadFile(currentFile?.path)
|
|
setCurrentFile(undefined)
|
|
}
|
|
}}
|
|
onDelete={() => {
|
|
if (currentFile) {
|
|
deleteFile(currentFile)
|
|
setCurrentFile(undefined)
|
|
}
|
|
}}
|
|
/>
|
|
<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',
|
|
}}
|
|
onClick={() => setShowMenu(false)}
|
|
>
|
|
<Typography py={1} variant="h5" color="primary">
|
|
{i18n.t('archiveTitle')}
|
|
</Typography>
|
|
<List sx={{ width: '100%', bgcolor: 'background.paper' }}>
|
|
{selectable.length === 0 && 'No files found'}
|
|
{selectable.map((file, idx) => (
|
|
<ListItem
|
|
onContextMenu={(e) => {
|
|
e.preventDefault()
|
|
setCurrentFile(file)
|
|
setMenuPos({ x: e.clientX, y: e.clientY })
|
|
setShowMenu(true)
|
|
}}
|
|
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>
|
|
)
|
|
}
|
|
|
|
const IconMenu: React.FC<{
|
|
posX: number
|
|
posY: number
|
|
hide: boolean
|
|
onDownload: () => void
|
|
onDelete: () => void
|
|
}> = ({ posX, posY, hide, onDelete, onDownload }) => {
|
|
return (
|
|
<Paper sx={{
|
|
width: 320,
|
|
maxWidth: '100%',
|
|
position: 'absolute',
|
|
top: posY,
|
|
left: posX,
|
|
display: hide ? 'none' : 'block',
|
|
zIndex: (theme) => theme.zIndex.drawer + 1,
|
|
}}>
|
|
<MenuList>
|
|
<MenuItem onClick={onDownload}>
|
|
<ListItemIcon>
|
|
<DownloadIcon fontSize="small" />
|
|
</ListItemIcon>
|
|
<ListItemText>
|
|
Download
|
|
</ListItemText>
|
|
</MenuItem>
|
|
<MenuItem onClick={onDelete}>
|
|
<ListItemIcon>
|
|
<DeleteForeverIcon fontSize="small" />
|
|
</ListItemIcon>
|
|
<ListItemText>
|
|
Delete
|
|
</ListItemText>
|
|
</MenuItem>
|
|
</MenuList>
|
|
</Paper>
|
|
)
|
|
} |