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>
|
||||
)
|
||||
}
|
||||
427
frontend/src/views/Home.tsx
Normal file
427
frontend/src/views/Home.tsx
Normal file
@@ -0,0 +1,427 @@
|
||||
import { FileUpload } from '@mui/icons-material'
|
||||
import FormatListBulleted from '@mui/icons-material/FormatListBulleted'
|
||||
import {
|
||||
Alert,
|
||||
Backdrop,
|
||||
Button,
|
||||
CircularProgress,
|
||||
Container,
|
||||
FormControl,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Snackbar,
|
||||
SpeedDial,
|
||||
SpeedDialAction,
|
||||
SpeedDialIcon,
|
||||
styled,
|
||||
TextField
|
||||
} from '@mui/material'
|
||||
import { Buffer } from 'buffer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import { DownloadsCardView } from '../components/DownloadsCardView'
|
||||
import { DownloadsListView } from '../components/DownloadsListView'
|
||||
import FormatsGrid from '../components/FormatsGrid'
|
||||
import { CliArguments } from '../lib/argsParser'
|
||||
import I18nBuilder from '../lib/intl'
|
||||
import { RPCClient, socket$ } from '../lib/rpcClient'
|
||||
import { toggleListView } from '../features/settings/settingsSlice'
|
||||
import { connected, setFreeSpace } from '../features/status/statusSlice'
|
||||
import { RootState } from '../stores/store'
|
||||
import type { DLMetadata, RPCResponse, RPCResult } from '../types'
|
||||
import { isValidURL, toFormatArgs } from '../utils'
|
||||
|
||||
export default function Home() {
|
||||
// redux state
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
const dispatch = useDispatch()
|
||||
|
||||
// ephemeral state
|
||||
const [activeDownloads, setActiveDownloads] = useState<Array<RPCResult>>()
|
||||
const [downloadFormats, setDownloadFormats] = useState<DLMetadata>()
|
||||
const [pickedVideoFormat, setPickedVideoFormat] = useState('')
|
||||
const [pickedAudioFormat, setPickedAudioFormat] = useState('')
|
||||
const [pickedBestFormat, setPickedBestFormat] = useState('')
|
||||
|
||||
const [customArgs, setCustomArgs] = useState('')
|
||||
const [downloadPath, setDownloadPath] = useState(0)
|
||||
const [availableDownloadPaths, setAvailableDownloadPaths] = useState<string[]>([])
|
||||
|
||||
const [fileNameOverride, setFilenameOverride] = useState('')
|
||||
|
||||
const [url, setUrl] = useState('')
|
||||
const [workingUrl, setWorkingUrl] = useState('')
|
||||
|
||||
const [showBackdrop, setShowBackdrop] = useState(true)
|
||||
const [showToast, setShowToast] = useState(true)
|
||||
|
||||
const [socketHasError, setSocketHasError] = useState(false)
|
||||
|
||||
// memos
|
||||
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||
const client = useMemo(() => new RPCClient(), [settings.serverAddr, settings.serverPort])
|
||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [settings.cliArgs])
|
||||
|
||||
// refs
|
||||
const urlInputRef = useRef<HTMLInputElement>(null)
|
||||
const customFilenameInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/* -------------------- Effects -------------------- */
|
||||
|
||||
/* WebSocket connect event handler*/
|
||||
useEffect(() => {
|
||||
if (status.connected) { return }
|
||||
|
||||
const sub = socket$.subscribe({
|
||||
next: () => {
|
||||
dispatch(connected())
|
||||
setCustomArgs(localStorage.getItem('last-input-args') ?? '')
|
||||
setFilenameOverride(localStorage.getItem('last-filename-override') ?? '')
|
||||
},
|
||||
error: () => {
|
||||
setSocketHasError(true)
|
||||
setShowBackdrop(false)
|
||||
},
|
||||
complete: () => {
|
||||
setSocketHasError(true)
|
||||
setShowBackdrop(false)
|
||||
},
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}, [socket$, status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
if (status.connected) {
|
||||
client.running()
|
||||
const interval = setInterval(() => client.running(), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
client.freeSpace().then(bytes => dispatch(setFreeSpace(bytes.result)))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!status.connected) { return }
|
||||
|
||||
const sub = socket$.subscribe((event: RPCResponse<RPCResult[]>) => {
|
||||
switch (typeof event.result) {
|
||||
case 'object':
|
||||
setActiveDownloads(
|
||||
(event.result ?? [])
|
||||
.filter((r) => !!r.info.url)
|
||||
.sort((a, b) => a.info.title.localeCompare(b.info.title))
|
||||
)
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}, [socket$, status.connected])
|
||||
|
||||
useEffect(() => {
|
||||
if (activeDownloads && activeDownloads.length >= 0) {
|
||||
setShowBackdrop(false)
|
||||
}
|
||||
}, [activeDownloads?.length])
|
||||
|
||||
useEffect(() => {
|
||||
client.directoryTree()
|
||||
.then(data => {
|
||||
setAvailableDownloadPaths(data.result)
|
||||
})
|
||||
}, [])
|
||||
|
||||
/* -------------------- callbacks-------------------- */
|
||||
|
||||
/**
|
||||
* Retrive url from input, cli-arguments from checkboxes and emits via WebSocket
|
||||
*/
|
||||
const sendUrl = (immediate?: string) => {
|
||||
const codes = new Array<string>();
|
||||
if (pickedVideoFormat !== '') codes.push(pickedVideoFormat);
|
||||
if (pickedAudioFormat !== '') codes.push(pickedAudioFormat);
|
||||
if (pickedBestFormat !== '') codes.push(pickedBestFormat);
|
||||
|
||||
client.download(
|
||||
immediate || url || workingUrl,
|
||||
`${cliArgs.toString()} ${toFormatArgs(codes)} ${customArgs}`,
|
||||
availableDownloadPaths[downloadPath] ?? '',
|
||||
fileNameOverride
|
||||
)
|
||||
|
||||
setUrl('')
|
||||
setWorkingUrl('')
|
||||
setShowBackdrop(true)
|
||||
|
||||
setTimeout(() => {
|
||||
resetInput()
|
||||
setShowBackdrop(true)
|
||||
setDownloadFormats(undefined)
|
||||
}, 250)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrive url from input and display the formats selection view
|
||||
*/
|
||||
const sendUrlFormatSelection = () => {
|
||||
setWorkingUrl(url)
|
||||
setUrl('')
|
||||
setPickedAudioFormat('')
|
||||
setPickedVideoFormat('')
|
||||
setPickedBestFormat('')
|
||||
|
||||
setShowBackdrop(true)
|
||||
|
||||
client.formats(url)
|
||||
?.then(formats => {
|
||||
setDownloadFormats(formats.result)
|
||||
setShowBackdrop(false)
|
||||
resetInput()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the url state whenever the input value changes
|
||||
* @param e Input change event
|
||||
*/
|
||||
const handleUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setUrl(e.target.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the filename override state whenever the input value changes
|
||||
* @param e Input change event
|
||||
*/
|
||||
const handleFilenameOverrideChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFilenameOverride(e.target.value)
|
||||
localStorage.setItem('last-filename-override', e.target.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the custom args state whenever the input value changes
|
||||
* @param e Input change event
|
||||
*/
|
||||
const handleCustomArgsChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCustomArgs(e.target.value)
|
||||
localStorage.setItem("last-input-args", e.target.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abort a specific download if id's provided, other wise abort all running ones.
|
||||
* @param id The download id / pid
|
||||
* @returns void
|
||||
*/
|
||||
const abort = (id?: string) => {
|
||||
if (id) {
|
||||
client.kill(id)
|
||||
return
|
||||
}
|
||||
client.killAll()
|
||||
}
|
||||
|
||||
const parseUrlListFile = (event: any) => {
|
||||
const urlList = event.target.files
|
||||
const reader = new FileReader()
|
||||
reader.addEventListener('load', $event => {
|
||||
const base64 = $event.target?.result!.toString().split(',')[1]
|
||||
Buffer.from(base64!, 'base64')
|
||||
.toString()
|
||||
.trimEnd()
|
||||
.split('\n')
|
||||
.filter(_url => isValidURL(_url))
|
||||
.forEach(_url => sendUrl(_url))
|
||||
})
|
||||
reader.readAsDataURL(urlList[0])
|
||||
}
|
||||
|
||||
const resetInput = () => {
|
||||
urlInputRef.current!.value = '';
|
||||
if (customFilenameInputRef.current) {
|
||||
customFilenameInputRef.current!.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------- styled components -------------------- */
|
||||
|
||||
const Input = styled('input')({
|
||||
display: 'none',
|
||||
})
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Backdrop
|
||||
sx={{ color: '#fff', zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
||||
open={showBackdrop}
|
||||
>
|
||||
<CircularProgress color="primary" />
|
||||
</Backdrop>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Grid container>
|
||||
<TextField
|
||||
fullWidth
|
||||
ref={urlInputRef}
|
||||
label={i18n.t('urlInput')}
|
||||
variant="outlined"
|
||||
onChange={handleUrlChange}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
InputProps={{
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<label htmlFor="icon-button-file">
|
||||
<Input id="icon-button-file" type="file" accept=".txt" onChange={parseUrlListFile} />
|
||||
<IconButton color="primary" aria-label="upload file" component="span">
|
||||
<FileUpload />
|
||||
</IconButton>
|
||||
</label>
|
||||
</InputAdornment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid container spacing={1} sx={{ mt: 1 }}>
|
||||
{
|
||||
settings.enableCustomArgs &&
|
||||
<Grid item xs={12}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={i18n.t('customArgsInput')}
|
||||
variant="outlined"
|
||||
onChange={handleCustomArgsChange}
|
||||
value={customArgs}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.fileRenaming &&
|
||||
<Grid item xs={8}>
|
||||
<TextField
|
||||
ref={customFilenameInputRef}
|
||||
fullWidth
|
||||
label={i18n.t('customFilename')}
|
||||
variant="outlined"
|
||||
value={fileNameOverride}
|
||||
onChange={handleFilenameOverrideChange}
|
||||
disabled={!status.connected || (settings.formatSelection && downloadFormats != null)}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
{
|
||||
settings.pathOverriding &&
|
||||
<Grid item xs={4}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{i18n.t('customPath')}</InputLabel>
|
||||
<Select
|
||||
label={i18n.t('customPath')}
|
||||
defaultValue={0}
|
||||
variant={'outlined'}
|
||||
value={downloadPath}
|
||||
onChange={(e) => setDownloadPath(Number(e.target.value))}
|
||||
>
|
||||
{availableDownloadPaths.map((val: string, idx: number) => (
|
||||
<MenuItem key={idx} value={idx}>{val}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
}
|
||||
</Grid>
|
||||
<Grid container spacing={1} pt={2}>
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={url === ''}
|
||||
onClick={() => settings.formatSelection ? sendUrlFormatSelection() : sendUrl()}
|
||||
>
|
||||
{settings.formatSelection ? i18n.t('selectFormatButton') : i18n.t('startButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
<Grid item>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => abort()}
|
||||
>
|
||||
{i18n.t('abortAllButton')}
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid >
|
||||
{/* Format Selection grid */}
|
||||
{downloadFormats && <FormatsGrid
|
||||
downloadFormats={downloadFormats}
|
||||
onBestQualitySelected={(id) => {
|
||||
setPickedBestFormat(id)
|
||||
setPickedVideoFormat('')
|
||||
setPickedAudioFormat('')
|
||||
}}
|
||||
onVideoSelected={(id) => {
|
||||
setPickedVideoFormat(id)
|
||||
setPickedBestFormat('')
|
||||
}}
|
||||
onAudioSelected={(id) => {
|
||||
setPickedAudioFormat(id)
|
||||
setPickedBestFormat('')
|
||||
}}
|
||||
onClear={() => {
|
||||
setPickedAudioFormat('');
|
||||
setPickedVideoFormat('');
|
||||
setPickedBestFormat('');
|
||||
}}
|
||||
onSubmit={sendUrl}
|
||||
pickedBestFormat={pickedBestFormat}
|
||||
pickedVideoFormat={pickedVideoFormat}
|
||||
pickedAudioFormat={pickedAudioFormat}
|
||||
/>}
|
||||
{
|
||||
settings.listView ?
|
||||
<DownloadsListView downloads={activeDownloads ?? []} abortFunction={abort} /> :
|
||||
<DownloadsCardView downloads={activeDownloads ?? []} abortFunction={abort} />
|
||||
}
|
||||
<Snackbar
|
||||
open={showToast === status.connected}
|
||||
autoHideDuration={1500}
|
||||
onClose={() => setShowToast(false)}
|
||||
>
|
||||
<Alert variant="filled" severity="success">
|
||||
{`Connected to (${settings.serverAddr}:${settings.serverPort})`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<Snackbar open={socketHasError}>
|
||||
<Alert variant="filled" severity="error">
|
||||
{`${i18n.t('rpcConnErr')} (${settings.serverAddr}:${settings.serverPort})`}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
<SpeedDial
|
||||
ariaLabel="SpeedDial basic example"
|
||||
sx={{ position: 'absolute', bottom: 32, right: 32 }}
|
||||
icon={<SpeedDialIcon />}
|
||||
>
|
||||
<SpeedDialAction
|
||||
icon={<FormatListBulleted />}
|
||||
tooltipTitle={`Table view`}
|
||||
tooltipOpen
|
||||
onClick={() => dispatch(toggleListView())}
|
||||
/>
|
||||
</SpeedDial>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
78
frontend/src/views/Login.tsx
Normal file
78
frontend/src/views/Login.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
Login view component
|
||||
*/
|
||||
|
||||
import styled from '@emotion/styled'
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
Paper,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { getHttpEndpoint } from '../utils'
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
const LoginContainer = styled(Container)({
|
||||
display: 'flex',
|
||||
minWidth: '100%',
|
||||
minHeight: '100vh',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})
|
||||
|
||||
const Title = styled(Typography)({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingBottom: '0.5rem'
|
||||
})
|
||||
|
||||
export default function Login() {
|
||||
const [secret, setSecret] = useState('')
|
||||
const [formHasError, setFormHasError] = useState(false)
|
||||
|
||||
const navigate = useNavigate()
|
||||
|
||||
const login = async () => {
|
||||
const res = await fetch(`${getHttpEndpoint()}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
secret
|
||||
})
|
||||
})
|
||||
res.ok ? navigate('/') : setFormHasError(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<LoginContainer>
|
||||
<Paper sx={{ padding: '1.5rem', minWidth: '25%' }}>
|
||||
<Stack direction="column" spacing={2}>
|
||||
<Title fontWeight={'700'} fontSize={32} color={'primary'}>
|
||||
yt-dlp WebUI
|
||||
</Title>
|
||||
<Title fontWeight={'500'} fontSize={16} color={'gray'}>
|
||||
Authentication token will expire after 30 days.
|
||||
</Title>
|
||||
<TextField
|
||||
id="outlined-password-input"
|
||||
label="RPC secret"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
error={formHasError}
|
||||
onChange={e => setSecret(e.currentTarget.value)}
|
||||
/>
|
||||
<Button variant="contained" size="large" onClick={() => login()}>
|
||||
Submit
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</LoginContainer>
|
||||
)
|
||||
}
|
||||
288
frontend/src/views/Settings.tsx
Normal file
288
frontend/src/views/Settings.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
Button,
|
||||
Container,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
Grid,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
SelectChangeEvent,
|
||||
Snackbar,
|
||||
Stack,
|
||||
Switch,
|
||||
TextField,
|
||||
Typography
|
||||
} from '@mui/material'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import {
|
||||
Subject,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
takeWhile
|
||||
} from 'rxjs'
|
||||
import { CliArguments } from '../lib/argsParser'
|
||||
import I18nBuilder from '../lib/intl'
|
||||
import { RPCClient } from '../lib/rpcClient'
|
||||
import {
|
||||
LanguageUnion,
|
||||
ThemeUnion,
|
||||
setCliArgs,
|
||||
setEnableCustomArgs,
|
||||
setFileRenaming,
|
||||
setFormatSelection,
|
||||
setLanguage,
|
||||
setPathOverriding,
|
||||
setServerAddr,
|
||||
setServerPort,
|
||||
setTheme
|
||||
} from '../features/settings/settingsSlice'
|
||||
import { updated } from '../features/status/statusSlice'
|
||||
import { RootState } from '../stores/store'
|
||||
import { validateDomain, validateIP } from '../utils'
|
||||
|
||||
export default function Settings() {
|
||||
const dispatch = useDispatch()
|
||||
|
||||
const status = useSelector((state: RootState) => state.status)
|
||||
const settings = useSelector((state: RootState) => state.settings)
|
||||
|
||||
const [invalidIP, setInvalidIP] = useState(false);
|
||||
|
||||
const i18n = useMemo(() => new I18nBuilder(settings.language), [settings.language])
|
||||
|
||||
const client = useMemo(() => new RPCClient(), [])
|
||||
const cliArgs = useMemo(() => new CliArguments().fromString(settings.cliArgs), [])
|
||||
|
||||
const serverAddr$ = useMemo(() => new Subject<string>(), [])
|
||||
const serverPort$ = useMemo(() => new Subject<string>(), [])
|
||||
|
||||
useEffect(() => {
|
||||
const sub = serverAddr$
|
||||
.pipe(
|
||||
debounceTime(500),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe(addr => {
|
||||
if (validateIP(addr)) {
|
||||
setInvalidIP(false)
|
||||
dispatch(setServerAddr(addr))
|
||||
} else if (validateDomain(addr)) {
|
||||
setInvalidIP(false)
|
||||
dispatch(setServerAddr(addr))
|
||||
} else {
|
||||
setInvalidIP(true)
|
||||
}
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}, [serverAddr$])
|
||||
|
||||
useEffect(() => {
|
||||
const sub = serverPort$
|
||||
.pipe(
|
||||
debounceTime(500),
|
||||
map(val => Number(val)),
|
||||
takeWhile(val => isFinite(val) && val <= 65535),
|
||||
)
|
||||
.subscribe(port => {
|
||||
dispatch(setServerPort(port.toString()))
|
||||
})
|
||||
return () => sub.unsubscribe()
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* Language toggler handler
|
||||
*/
|
||||
const handleLanguageChange = (event: SelectChangeEvent<LanguageUnion>) => {
|
||||
dispatch(setLanguage(event.target.value as LanguageUnion));
|
||||
}
|
||||
|
||||
/**
|
||||
* Theme toggler handler
|
||||
*/
|
||||
const handleThemeChange = (event: SelectChangeEvent<ThemeUnion>) => {
|
||||
dispatch(setTheme(event.target.value as ThemeUnion));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send via WebSocket a message to update yt-dlp binary
|
||||
*/
|
||||
const updateBinary = () => {
|
||||
client.updateExecutable().then(() => dispatch(updated()))
|
||||
}
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} md={12} lg={12}>
|
||||
<Paper
|
||||
sx={{
|
||||
p: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 240,
|
||||
}}
|
||||
>
|
||||
<Typography pb={3} variant="h5" color="primary">
|
||||
{i18n.t('settingsAnchor')}
|
||||
</Typography>
|
||||
<FormGroup>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={11}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={i18n.t('serverAddressTitle')}
|
||||
defaultValue={settings.serverAddr}
|
||||
error={invalidIP}
|
||||
onChange={(e) => serverAddr$.next(e.currentTarget.value)}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start">ws://</InputAdornment>,
|
||||
}}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={1}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={i18n.t('serverPortTitle')}
|
||||
defaultValue={settings.serverPort}
|
||||
onChange={(e) => serverPort$.next(e.currentTarget.value)}
|
||||
error={isNaN(Number(settings.serverPort)) || Number(settings.serverPort) > 65535}
|
||||
sx={{ mb: 2 }}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{i18n.t('languageSelect')}</InputLabel>
|
||||
<Select
|
||||
defaultValue={settings.language}
|
||||
label={i18n.t('languageSelect')}
|
||||
onChange={handleLanguageChange}
|
||||
>
|
||||
<MenuItem value="english">English</MenuItem>
|
||||
<MenuItem value="spanish">Spanish</MenuItem>
|
||||
<MenuItem value="italian">Italian</MenuItem>
|
||||
<MenuItem value="chinese">Chinese</MenuItem>
|
||||
<MenuItem value="russian">Russian</MenuItem>
|
||||
<MenuItem value="korean">Korean</MenuItem>
|
||||
<MenuItem value="japanese">Japanese</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={6}>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>{i18n.t('themeSelect')}</InputLabel>
|
||||
<Select
|
||||
defaultValue={settings.theme}
|
||||
label={i18n.t('themeSelect')}
|
||||
onChange={handleThemeChange}
|
||||
>
|
||||
<MenuItem value="light">Light</MenuItem>
|
||||
<MenuItem value="dark">Dark</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
defaultChecked={cliArgs.noMTime}
|
||||
onChange={() => dispatch(setCliArgs(cliArgs.toggleNoMTime().toString()))}
|
||||
/>
|
||||
}
|
||||
label={i18n.t('noMTimeCheckbox')}
|
||||
sx={{ mt: 3 }}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
defaultChecked={cliArgs.extractAudio}
|
||||
onChange={() => dispatch(setCliArgs(cliArgs.toggleExtractAudio().toString()))}
|
||||
disabled={settings.formatSelection}
|
||||
/>
|
||||
}
|
||||
label={i18n.t('extractAudioCheckbox')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
defaultChecked={settings.formatSelection}
|
||||
onChange={() => {
|
||||
dispatch(setCliArgs(cliArgs.disableExtractAudio().toString()))
|
||||
dispatch(setFormatSelection(!settings.formatSelection))
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={i18n.t('formatSelectionEnabler')}
|
||||
/>
|
||||
<Grid>
|
||||
<Typography variant="h6" color="primary" sx={{ mt: 2, mb: 0.5 }}>
|
||||
{i18n.t('overridesAnchor')}
|
||||
</Typography>
|
||||
<Stack direction="column">
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
defaultChecked={settings.pathOverriding}
|
||||
onChange={() => {
|
||||
dispatch(setPathOverriding(!settings.pathOverriding))
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={i18n.t('pathOverrideOption')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
defaultChecked={settings.fileRenaming}
|
||||
onChange={() => {
|
||||
dispatch(setFileRenaming(!settings.fileRenaming))
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={i18n.t('filenameOverrideOption')}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
defaultChecked={settings.enableCustomArgs}
|
||||
onChange={() => {
|
||||
dispatch(setEnableCustomArgs(!settings.enableCustomArgs))
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={i18n.t('customArgs')}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Stack direction="row">
|
||||
<Button
|
||||
sx={{ mr: 1, mt: 3 }}
|
||||
variant="contained"
|
||||
onClick={() => dispatch(updated())}
|
||||
>
|
||||
{i18n.t('updateBinButton')}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</FormGroup>
|
||||
</Paper>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Snackbar
|
||||
open={status.updated}
|
||||
autoHideDuration={1500}
|
||||
message={i18n.t('toastUpdated')}
|
||||
onClose={updateBinary}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user