Added better archive functionalty (backend side atm)

Code refactoring
This commit is contained in:
2024-12-18 11:59:17 +01:00
parent d9cb018132
commit 9d3861ab39
29 changed files with 1401 additions and 417 deletions

View File

@@ -1,5 +1,6 @@
import { ThemeProvider } from '@emotion/react'
import ArchiveIcon from '@mui/icons-material/Archive'
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
import ChevronLeft from '@mui/icons-material/ChevronLeft'
import Dashboard from '@mui/icons-material/Dashboard'
import LiveTvIcon from '@mui/icons-material/LiveTv'
@@ -42,7 +43,7 @@ export default function Layout() {
palette: {
mode: settings.theme,
primary: {
main: getAccentValue(settings.accent)
main: getAccentValue(settings.accent, settings.theme)
},
background: {
default: settings.theme === 'light' ? grey[50] : '#121212'
@@ -113,7 +114,7 @@ export default function Layout() {
<ListItemText primary={i18n.t('homeButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/archive'} style={
{/* <Link to={'/archive'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
@@ -125,6 +126,19 @@ export default function Layout() {
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link> */}
<Link to={'/filebrowser'} style={
{
textDecoration: 'none',
color: mode === 'dark' ? '#ffffff' : '#000000DE'
}
}>
<ListItemButton>
<ListItemIcon>
<CloudDownloadIcon />
</ListItemIcon>
<ListItemText primary={i18n.t('archiveButtonLabel')} />
</ListItemButton>
</Link>
<Link to={'/monitor'} style={
{

View File

@@ -0,0 +1,98 @@
import DeleteIcon from '@mui/icons-material/Delete'
import DeleteForeverIcon from '@mui/icons-material/DeleteForever'
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'
import SaveAltIcon from '@mui/icons-material/SaveAlt'
import {
Card,
CardActionArea,
CardActions,
CardContent,
CardMedia,
IconButton,
Skeleton,
Tooltip,
Typography
} from '@mui/material'
import { useAtomValue } from 'jotai'
import { serverURL } from '../atoms/settings'
import { ArchiveEntry } from '../types'
import { base64URLEncode, ellipsis } from '../utils'
type Props = {
entry: ArchiveEntry
onDelete: (id: string) => void
onHardDelete: (id: string) => void
}
const ArchiveCard: React.FC<Props> = ({ entry, onDelete, onHardDelete }) => {
const serverAddr = useAtomValue(serverURL)
const viewFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
}
const downloadFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
}
return (
<Card>
<CardActionArea onClick={() => navigator.clipboard.writeText(entry.source)}>
{entry.thumbnail !== '' ?
<CardMedia
component="img"
height={180}
image={entry.thumbnail}
/> :
<Skeleton variant="rectangular" height={180} />
}
<CardContent>
{entry.title !== '' ?
<Typography gutterBottom variant="h6" component="div">
{ellipsis(entry.title, 60)}
</Typography> :
<Skeleton />
}
{/* <code>
{JSON.stringify(JSON.parse(entry.metadata), null, 2)}
</code> */}
<p>{new Date(entry.created_at).toLocaleString()}</p>
</CardContent>
</CardActionArea>
<CardActions>
<Tooltip title="Open in browser">
<IconButton
onClick={() => viewFile(entry.path)}
>
<OpenInBrowserIcon />
</IconButton>
</Tooltip>
<Tooltip title="Download this file">
<IconButton
onClick={() => downloadFile(entry.path)}
>
<SaveAltIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete from archive">
<IconButton
onClick={() => onDelete(entry.id)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
<Tooltip title="Delete from disk">
<IconButton
onClick={() => onHardDelete(entry.id)}
>
<DeleteForeverIcon />
</IconButton>
</Tooltip>
</CardActions>
</Card>
)
}
export default ArchiveCard

View File

@@ -1,7 +1,3 @@
import EightK from '@mui/icons-material/EightK'
import FourK from '@mui/icons-material/FourK'
import Hd from '@mui/icons-material/Hd'
import Sd from '@mui/icons-material/Sd'
import {
Button,
Card,
@@ -10,16 +6,23 @@ import {
CardContent,
CardMedia,
Chip,
IconButton,
LinearProgress,
Skeleton,
Stack,
Tooltip,
Typography
} from '@mui/material'
import { useAtomValue } from 'jotai'
import { useCallback } from 'react'
import { serverURL } from '../atoms/settings'
import { RPCResult } from '../types'
import { base64URLEncode, ellipsis, formatSize, formatSpeedMiB, mapProcessStatus } from '../utils'
import { useAtomValue } from 'jotai'
import ResolutionBadge from './ResolutionBadge'
import ClearIcon from '@mui/icons-material/Clear'
import StopCircleIcon from '@mui/icons-material/StopCircle'
import OpenInBrowserIcon from '@mui/icons-material/OpenInBrowser'
import SaveAltIcon from '@mui/icons-material/SaveAlt'
type Props = {
download: RPCResult
@@ -27,15 +30,6 @@ type Props = {
onCopy: () => void
}
const Resolution: React.FC<{ resolution?: string }> = ({ resolution }) => {
if (!resolution) return null
if (resolution.includes('4320')) return <EightK color="primary" />
if (resolution.includes('2160')) return <FourK color="primary" />
if (resolution.includes('1080')) return <Hd color="primary" />
if (resolution.includes('720')) return <Sd color="primary" />
return null
}
const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
const serverAddr = useAtomValue(serverURL)
@@ -53,12 +47,12 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
const viewFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
}
const downloadFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
}
return (
@@ -110,37 +104,44 @@ const DownloadCard: React.FC<Props> = ({ download, onStop, onCopy }) => {
<Typography>
{formatSize(download.info.filesize_approx ?? 0)}
</Typography>
<Resolution resolution={download.info.resolution} />
<ResolutionBadge resolution={download.info.resolution} />
</Stack>
</CardContent>
</CardActionArea>
<CardActions>
<Button
variant="contained"
size="small"
color="primary"
onClick={onStop}
>
{isCompleted() ? "Clear" : "Stop"}
</Button>
{isCompleted() ?
<Tooltip title="Clear from the view">
<IconButton
onClick={onStop}
>
<ClearIcon />
</IconButton>
</Tooltip>
:
<Tooltip title="Stop this download">
<IconButton
onClick={onStop}
>
<StopCircleIcon />
</IconButton>
</Tooltip>
}
{isCompleted() &&
<>
<Button
variant="contained"
size="small"
color="primary"
onClick={() => downloadFile(download.output.savedFilePath)}
>
Download
</Button>
<Button
variant="contained"
size="small"
color="primary"
onClick={() => viewFile(download.output.savedFilePath)}
>
View
</Button>
<Tooltip title="Download this file">
<IconButton
onClick={() => downloadFile(download.output.savedFilePath)}
>
<SaveAltIcon />
</IconButton>
</Tooltip>
<Tooltip title="Open in a new tab">
<IconButton
onClick={() => viewFile(download.output.savedFilePath)}
>
<OpenInBrowserIcon />
</IconButton>
</Tooltip>
</>
}
</CardActions>

View File

@@ -348,7 +348,7 @@ const DownloadDialog: FC<Props> = ({ open, onClose, onDownloadStart }) => {
disabled={url === ''}
onClick={() => settings.formatSelection
? startTransition(() => sendUrlFormatSelection())
: sendUrl()
: startTransition(async () => await sendUrl())
}
>
{

View File

@@ -1,11 +1,13 @@
import { Grid } from '@mui/material'
import { Grid2 } from '@mui/material'
import { useAtomValue } from 'jotai'
import { useTransition } from 'react'
import { activeDownloadsState } from '../atoms/downloads'
import { useToast } from '../hooks/toast'
import { useI18n } from '../hooks/useI18n'
import { useRPC } from '../hooks/useRPC'
import { ProcessStatus, RPCResult } from '../types'
import DownloadCard from './DownloadCard'
import LoadingBackdrop from './LoadingBackdrop'
const DownloadsGridView: React.FC = () => {
const downloads = useAtomValue(activeDownloadsState)
@@ -14,24 +16,31 @@ const DownloadsGridView: React.FC = () => {
const { client } = useRPC()
const { pushMessage } = useToast()
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
? client.clear(r.id)
: client.kill(r.id)
const [isPending, startTransition] = useTransition()
const stop = async (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED
? await client.clear(r.id)
: await client.kill(r.id)
return (
<Grid container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
{
downloads.map(download => (
<Grid item xs={4} sm={8} md={6} xl={4} key={download.id}>
<DownloadCard
download={download}
onStop={() => stop(download)}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/>
</Grid>
))
}
</Grid>
<>
<LoadingBackdrop isLoading={isPending} />
<Grid2 container spacing={{ xs: 2, md: 2 }} columns={{ xs: 4, sm: 8, md: 12, xl: 12 }} pt={2}>
{
downloads.map(download => (
<Grid2 size={{ xs: 4, sm: 8, md: 6, xl: 4 }} key={download.id}>
<DownloadCard
download={download}
onStop={() => startTransition(async () => {
await stop(download)
})}
onCopy={() => pushMessage(i18n.t('clipboardAction'), 'info')}
/>
</Grid2>
))
}
</Grid2>
</>
)
}

View File

@@ -125,12 +125,12 @@ const DownloadsTableView: React.FC = () => {
const viewFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/v/${encoded}?token=${localStorage.getItem('token')}`)
window.open(`${serverAddr}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
}
const downloadFile = (path: string) => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/archive/d/${encoded}?token=${localStorage.getItem('token')}`)
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
}
const stop = (r: RPCResult) => r.progress.process_status === ProcessStatus.COMPLETED

View File

@@ -0,0 +1,45 @@
import ArchiveIcon from '@mui/icons-material/Archive'
import { Container, SvgIcon, Typography, styled } from '@mui/material'
import { activeDownloadsState } from '../atoms/downloads'
import { useI18n } from '../hooks/useI18n'
import { useAtomValue } from 'jotai'
const FlexContainer = styled(Container)({
display: 'flex',
minWidth: '100%',
minHeight: '80vh',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column'
})
const Title = styled(Typography)({
display: 'flex',
width: '100%',
alignItems: 'center',
justifyContent: 'center',
paddingBottom: '0.5rem'
})
export default function EmptyArchive() {
const { i18n } = useI18n()
const activeDownloads = useAtomValue(activeDownloadsState)
if (activeDownloads.length !== 0) {
return null
}
return (
<FlexContainer>
<Title fontWeight={'500'} fontSize={72} color={'gray'}>
<SvgIcon sx={{ fontSize: '200px' }}>
<ArchiveIcon />
</SvgIcon>
</Title>
<Title fontWeight={'500'} fontSize={36} color={'gray'}>
{/* {i18n.t('splashText')} */}
Empty Archive
</Title>
</FlexContainer>
)
}

View File

@@ -0,0 +1,15 @@
import EightK from '@mui/icons-material/EightK'
import FourK from '@mui/icons-material/FourK'
import Hd from '@mui/icons-material/Hd'
import Sd from '@mui/icons-material/Sd'
const ResolutionBadge: React.FC<{ resolution?: string }> = ({ resolution }) => {
if (!resolution) return null
if (resolution.includes('4320')) return <EightK color="primary" />
if (resolution.includes('2160')) return <FourK color="primary" />
if (resolution.includes('1080')) return <Hd color="primary" />
if (resolution.includes('720')) return <Sd color="primary" />
return null
}
export default ResolutionBadge

View File

@@ -201,7 +201,7 @@ const TemplatesEditor: React.FC<Props> = ({ open, onClose }) => {
InputProps={{
endAdornment: <Button
variant='contained'
onClick={() => startTransition(() => { addTemplate() })}
onClick={() => startTransition(async () => await addTemplate())}
>
<AddIcon />
</Button>

View File

@@ -128,21 +128,21 @@ export class RPCClient {
}
public kill(id: string) {
this.sendHTTP({
return this.sendHTTP({
method: 'Service.Kill',
params: [id],
})
}
public clear(id: string) {
this.sendHTTP({
return this.sendHTTP({
method: 'Service.Clear',
params: [id],
})
}
public killAll() {
this.sendHTTP({
return this.sendHTTP({
method: 'Service.KillAll',
params: [],
})

View File

@@ -9,6 +9,7 @@ const Login = lazy(() => import('./views/Login'))
const Archive = lazy(() => import('./views/Archive'))
const Settings = lazy(() => import('./views/Settings'))
const LiveStream = lazy(() => import('./views/Livestream'))
const Filebrowser = lazy(() => import('./views/Filebrowser'))
const ErrorBoundary = lazy(() => import('./components/ErrorBoundary'))
@@ -59,6 +60,19 @@ export const router = createHashRouter([
</Suspense >
)
},
{
path: '/filebrowser',
element: (
<Suspense fallback={<CircularProgress />}>
<Filebrowser />
</Suspense >
),
errorElement: (
<Suspense fallback={<CircularProgress />}>
<ErrorBoundary />
</Suspense >
)
},
{
path: '/login',
element: (

View File

@@ -122,4 +122,20 @@ export type LiveStreamProgress = Record<string, {
export type RPCVersion = {
rpcVersion: string
ytdlpVersion: string
}
export type ArchiveEntry = {
id: string
title: string
path: string
thumbnail: string
source: string
metadata: string
created_at: string
}
export type PaginatedResponse<T> = {
first: number
next: number
data: T
}

View File

@@ -1,6 +1,6 @@
import { blue, red } from '@mui/material/colors'
import { pipe } from 'fp-ts/lib/function'
import { Accent } from './atoms/settings'
import { Accent, ThemeNarrowed } from './atoms/settings'
import type { RPCResponse } from "./types"
import { ProcessStatus } from './types'
@@ -83,13 +83,13 @@ export const base64URLEncode = (s: string) => pipe(
encodeURIComponent
)
export const getAccentValue = (accent: Accent) => {
export const getAccentValue = (accent: Accent, mode: ThemeNarrowed) => {
switch (accent) {
case 'default':
return blue[700]
return mode === 'light' ? blue[700] : blue[300]
case 'red':
return red[600]
return mode === 'light' ? red[600] : red[400]
default:
return blue[700]
return mode === 'light' ? blue[700] : blue[300]
}
}

View File

@@ -1,363 +1,112 @@
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 { Container, FormControl, Grid2, InputLabel, MenuItem, Pagination, Select } from '@mui/material'
import { pipe } from 'fp-ts/lib/function'
import { useEffect, useMemo, useState, useTransition } from 'react'
import { useNavigate } from 'react-router-dom'
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, formatSize } from '../utils'
import { matchW } from 'fp-ts/lib/TaskEither'
import { useAtomValue } from 'jotai'
import { useEffect, useState, useTransition } from 'react'
import { serverURL } from '../atoms/settings'
import ArchiveCard from '../components/ArchiveCard'
import EmptyArchive from '../components/EmptyArchive'
import { useToast } from '../hooks/toast'
import { ffetch } from '../lib/httpClient'
import { ArchiveEntry, PaginatedResponse } from '../types'
import LoadingBackdrop from '../components/LoadingBackdrop'
export default function Downloaded() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
const [showMenu, setShowMenu] = useState(false)
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
const Archive: React.FC = () => {
const [isLoading, setLoading] = useState(true)
const [archiveEntries, setArchiveEntries] = useState<ArchiveEntry[]>()
const serverAddr = useAtomValue(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 [currentCursor, setCurrentCursor] = useState(0)
const [cursor, setCursor] = useState({ first: 0, next: 0 })
const [pageSize, setPageSize] = useState(25)
const [isPending, startTransition] = useTransition()
const fetcher = () => pipe(
ffetch<DirectoryEntry[]>(
`${serverAddr}/archive/downloaded`,
{
method: 'POST',
body: JSON.stringify({
subdir: '',
})
}
),
const serverAddr = useAtomValue(serverURL)
const { pushMessage } = useToast()
const fetchArchived = (startCursor = 0) => pipe(
ffetch<PaginatedResponse<ArchiveEntry[]>>(`${serverAddr}/archive?id=${startCursor}&limit=${pageSize}`),
matchW(
(e) => {
pushMessage(e, 'error')
navigate('/login')
},
(d) => files$.next(d ?? []),
(l) => pushMessage(l, 'error'),
(r) => {
setArchiveEntries(r.data)
setCursor({ ...cursor, first: r.first, next: r.next })
}
)
)()
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,
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,
})
const softDelete = (id: string) => pipe(
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/soft/${id}`, {
method: 'DELETE'
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => fetcher()
(_) => startTransition(async () => await fetchArchived())
)
)()
const deleteSelected = () => {
Promise.all(selectable
.filter(entry => entry.selected)
.map(deleteFile)
).then(fetcher)
}
const hardDelete = (id: string) => pipe(
ffetch<ArchiveEntry[]>(`${serverAddr}/archive/hard/${id}`, {
method: 'DELETE'
}),
matchW(
(l) => pushMessage(l, 'error'),
(_) => startTransition(async () => await fetchArchived())
)
)()
const setPage = (page: number) => setCurrentCursor(pageSize * (page - 1))
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)
})
fetchArchived(currentCursor).then(() => setLoading(false))
}, [currentCursor])
return (
<Container
maxWidth="xl"
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"
>
{formatSize(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='archive actions'
sx={{ position: 'absolute', bottom: 64, right: 24 }}
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
<Container maxWidth="xl" sx={{ mt: 4, mb: 8, minHeight: '80vh' }}>
<LoadingBackdrop isLoading={isPending || isLoading} />
{
archiveEntries && archiveEntries.length !== 0 ?
<Grid2
container
spacing={{ xs: 2, md: 2 }}
columns={{ xs: 4, sm: 8, md: 12, xl: 12 }}
pt={2}
sx={{ minHeight: '77.5vh' }}
>
Ok
</Button>
</DialogActions>
</Dialog>
{
archiveEntries.map((entry) => (
<Grid2 size={{ xs: 4, sm: 8, md: 4, xl: 3 }} key={entry.id}>
<ArchiveCard
entry={entry}
onDelete={() => startTransition(async () => await softDelete(entry.id))}
onHardDelete={() => startTransition(async () => await hardDelete(entry.id))}
/>
</Grid2>
))
}
</Grid2>
: <EmptyArchive />
}
<Pagination
sx={{ mx: 'auto', pt: 2 }}
count={Math.floor(cursor.next / pageSize) + 1}
onChange={(_, v) => setPage(v)}
/>
<FormControl variant="standard" sx={{ m: 1, minWidth: 120 }}>
<InputLabel id="page-size-select-label">Page size</InputLabel>
<Select
labelId="page-size-select-label"
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
label="Page size"
>
<MenuItem value={25}>25</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value={100}>100</MenuItem>
</Select>
</FormControl>
</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>
)
}
export default Archive

View File

@@ -0,0 +1,360 @@
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 { 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, formatSize } from '../utils'
import { useAtomValue } from 'jotai'
export default function Downloaded() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 })
const [showMenu, setShowMenu] = useState(false)
const [currentFile, setCurrentFile] = useState<DirectoryEntry>()
const serverAddr = useAtomValue(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}/filebrowser/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}/filebrowser/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,
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}/filebrowser/delete`, {
method: 'POST',
body: JSON.stringify({
path: entry.path,
})
}),
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}/filebrowser/v/${encoded}?token=${localStorage.getItem('token')}`)
})
const downloadFile = (path: string) => startTransition(() => {
const encoded = base64URLEncode(path)
window.open(`${serverAddr}/filebrowser/d/${encoded}?token=${localStorage.getItem('token')}`)
})
const onFolderClick = (path: string) => startTransition(() => {
fetcherSubfolder(path)
})
return (
<Container
maxWidth="xl"
sx={{ mt: 4, mb: 4, minHeight: '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)}
>
<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"
>
{formatSize(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='archive actions'
sx={{ position: 'absolute', bottom: 64, right: 24 }}
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>
)
}