mirror of
https://github.com/Ernous/TorrServerJellyfin.git
synced 2025-12-19 21:46:11 +05:00
update to master
This commit is contained in:
@@ -1,36 +0,0 @@
|
||||
import React from 'react'
|
||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
import Appbar from './components/Appbar.js'
|
||||
import { createMuiTheme, MuiThemeProvider } from '@material-ui/core'
|
||||
|
||||
const baseTheme = createMuiTheme({
|
||||
overrides: {
|
||||
MuiCssBaseline: {
|
||||
'@global': {
|
||||
html: {
|
||||
WebkitFontSmoothing: 'auto',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
palette: {
|
||||
primary: {
|
||||
main: '#3fb57a',
|
||||
},
|
||||
secondary: {
|
||||
main: '#FFA724',
|
||||
},
|
||||
tonalOffset: 0.2,
|
||||
},
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<React.Fragment>
|
||||
<MuiThemeProvider theme={baseTheme}>
|
||||
<CssBaseline />
|
||||
<Appbar />
|
||||
</MuiThemeProvider>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
65
web/src/App/Sidebar.jsx
Normal file
65
web/src/App/Sidebar.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { playlistAllHost } from 'utils/Hosts'
|
||||
import Divider from '@material-ui/core/Divider'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import AddDialogButton from 'components/Add'
|
||||
import RemoveAll from 'components/RemoveAll'
|
||||
import SettingsDialog from 'components/Settings'
|
||||
import AboutDialog from 'components/About'
|
||||
import UploadDialog from 'components/Upload'
|
||||
import { CreditCard as CreditCardIcon, List as ListIcon, Language as LanguageIcon } from '@material-ui/icons'
|
||||
import List from '@material-ui/core/List'
|
||||
import CloseServer from 'components/CloseServer'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useChangeLanguage from 'utils/useChangeLanguage'
|
||||
|
||||
import { AppSidebarStyle } from './style'
|
||||
|
||||
export default function Sidebar({ isDrawerOpen, setIsDonationDialogOpen }) {
|
||||
const [currentLang, changeLang] = useChangeLanguage()
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<AppSidebarStyle isDrawerOpen={isDrawerOpen}>
|
||||
<List>
|
||||
<AddDialogButton />
|
||||
<UploadDialog />
|
||||
<RemoveAll />
|
||||
<ListItem button component='a' target='_blank' href={playlistAllHost()}>
|
||||
<ListItemIcon>
|
||||
<ListIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('PlaylistAll')} />
|
||||
</ListItem>
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
|
||||
<List>
|
||||
<SettingsDialog />
|
||||
|
||||
<ListItem button onClick={() => (currentLang === 'en' ? changeLang('ru') : changeLang('en'))}>
|
||||
<ListItemIcon>
|
||||
<LanguageIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('ChooseLanguage')} />
|
||||
</ListItem>
|
||||
|
||||
<AboutDialog />
|
||||
<CloseServer />
|
||||
</List>
|
||||
|
||||
<Divider />
|
||||
|
||||
<List>
|
||||
<ListItem button onClick={() => setIsDonationDialogOpen(true)}>
|
||||
<ListItemIcon>
|
||||
<CreditCardIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('Donate')} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</AppSidebarStyle>
|
||||
)
|
||||
}
|
||||
63
web/src/App/index.jsx
Normal file
63
web/src/App/index.jsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
import { createMuiTheme, MuiThemeProvider } from '@material-ui/core'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { Menu as MenuIcon, Close as CloseIcon } from '@material-ui/icons'
|
||||
import { echoHost } from 'utils/Hosts'
|
||||
import TorrentList from 'components/TorrentList'
|
||||
import DonateSnackbar from 'components/Donate'
|
||||
import DonateDialog from 'components/Donate/DonateDialog'
|
||||
import Div100vh from 'react-div-100vh'
|
||||
import axios from 'axios'
|
||||
|
||||
import { AppWrapper, AppHeader } from './style'
|
||||
import Sidebar from './Sidebar'
|
||||
|
||||
const baseTheme = createMuiTheme({
|
||||
overrides: { MuiCssBaseline: { '@global': { html: { WebkitFontSmoothing: 'auto' } } } },
|
||||
palette: { primary: { main: '#00a572' }, secondary: { main: '#ffa724' }, tonalOffset: 0.2 },
|
||||
})
|
||||
|
||||
export default function App() {
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
|
||||
const [isDonationDialogOpen, setIsDonationDialogOpen] = useState(false)
|
||||
const [torrServerVersion, setTorrServerVersion] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
axios.get(echoHost()).then(({ data }) => setTorrServerVersion(data))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<MuiThemeProvider theme={baseTheme}>
|
||||
<CssBaseline />
|
||||
|
||||
{/* Div100vh - iOS WebKit fix */}
|
||||
<Div100vh>
|
||||
<AppWrapper>
|
||||
<AppHeader>
|
||||
<IconButton
|
||||
style={{ marginRight: '20px' }}
|
||||
color='inherit'
|
||||
onClick={() => setIsDrawerOpen(!isDrawerOpen)}
|
||||
edge='start'
|
||||
>
|
||||
{isDrawerOpen ? <CloseIcon /> : <MenuIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Typography variant='h6' noWrap>
|
||||
TorrServer {torrServerVersion}
|
||||
</Typography>
|
||||
</AppHeader>
|
||||
|
||||
<Sidebar isDrawerOpen={isDrawerOpen} setIsDonationDialogOpen={setIsDonationDialogOpen} />
|
||||
|
||||
<TorrentList />
|
||||
|
||||
{isDonationDialogOpen && <DonateDialog onClose={() => setIsDonationDialogOpen(false)} />}
|
||||
{!JSON.parse(localStorage.getItem('snackbarIsClosed')) && <DonateSnackbar />}
|
||||
</AppWrapper>
|
||||
</Div100vh>
|
||||
</MuiThemeProvider>
|
||||
)
|
||||
}
|
||||
65
web/src/App/style.js
Normal file
65
web/src/App/style.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
export const AppWrapper = styled.div`
|
||||
height: 100%;
|
||||
background: #cbe8d9;
|
||||
display: grid;
|
||||
grid-template-columns: 60px 1fr;
|
||||
grid-template-rows: 60px 1fr;
|
||||
grid-template-areas:
|
||||
'head head'
|
||||
'side content';
|
||||
`
|
||||
|
||||
export const CenteredGrid = styled.div`
|
||||
height: 100%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
`
|
||||
|
||||
export const AppHeader = styled.div`
|
||||
background: #00a572;
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
grid-area: head;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
box-shadow: 0px 2px 4px -1px rgb(0 0 0 / 20%), 0px 4px 5px 0px rgb(0 0 0 / 14%), 0px 1px 10px 0px rgb(0 0 0 / 12%);
|
||||
padding: 0 24px;
|
||||
z-index: 3;
|
||||
`
|
||||
export const AppSidebarStyle = styled.div`
|
||||
${({ isDrawerOpen }) => css`
|
||||
grid-area: side;
|
||||
width: ${isDrawerOpen ? '400%' : '100%'};
|
||||
z-index: 2;
|
||||
overflow-x: hidden;
|
||||
transition: width 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms;
|
||||
border-right: 1px solid rgba(0, 0, 0, 0.12);
|
||||
background: #eee;
|
||||
white-space: nowrap;
|
||||
`}
|
||||
`
|
||||
export const TorrentListWrapper = styled.div`
|
||||
grid-area: content;
|
||||
padding: 20px;
|
||||
overflow: auto;
|
||||
|
||||
display: grid;
|
||||
place-content: start;
|
||||
grid-template-columns: repeat(auto-fit, minmax(max-content, 570px));
|
||||
gap: 20px;
|
||||
|
||||
@media (max-width: 1260px), (max-height: 500px) {
|
||||
padding: 10px;
|
||||
gap: 15px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`
|
||||
@@ -1,46 +0,0 @@
|
||||
import React from 'react';
|
||||
import Button from '@material-ui/core/Button';
|
||||
import Dialog from '@material-ui/core/Dialog';
|
||||
import DialogActions from '@material-ui/core/DialogActions';
|
||||
import DialogContent from '@material-ui/core/DialogContent';
|
||||
import DialogContentText from '@material-ui/core/DialogContentText';
|
||||
import DialogTitle from '@material-ui/core/DialogTitle';
|
||||
import InfoIcon from '@material-ui/icons/Info';
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
|
||||
export default function AboutDialog() {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItem button key="Settings" onClick={()=>{setOpen(true)}}>
|
||||
<ListItemIcon>
|
||||
<InfoIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="About" />
|
||||
</ListItem>
|
||||
<Dialog open={open} onClose={()=>{setOpen(false)}} aria-labelledby="form-dialog-title" fullWidth={true} maxWidth={'lg'}>
|
||||
<DialogTitle id="form-dialog-title">About</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContent>
|
||||
<DialogContentText id="alert-dialog-description">
|
||||
<center><h2>Thanks to everyone who tested and helped.</h2></center><br/>
|
||||
<h2>Special thanks:</h2>
|
||||
<b>Anacrolix Matt Joiner</b> <a href={'https://github.com/anacrolix/'}>github.com/anacrolix</a><br/>
|
||||
<b>tsynik nikk Никита</b> <a href={'https://github.com/tsynik'}>github.com/tsynik</a><br/>
|
||||
<b>Tw1cker Руслан Пахнев</b> <a href={'https://github.com/Nemiroff'}>github.com/Nemiroff</a><br/>
|
||||
<b>SpAwN_LMG</b><br/>
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={()=>{setOpen(false)}} color="primary" variant="outlined" autoFocus>
|
||||
Close
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
web/src/components/About.jsx
Normal file
67
web/src/components/About.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import axios from 'axios'
|
||||
import { useEffect, useState } from 'react'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import InfoIcon from '@material-ui/icons/Info'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { echoHost } from 'utils/Hosts'
|
||||
|
||||
export default function AboutDialog() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [torrServerVersion, setTorrServerVersion] = useState('')
|
||||
useEffect(() => {
|
||||
axios.get(echoHost()).then(({ data }) => setTorrServerVersion(data))
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItem button key='Settings' onClick={() => setOpen(true)}>
|
||||
<ListItemIcon>
|
||||
<InfoIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('About')} />
|
||||
</ListItem>
|
||||
|
||||
<Dialog open={open} onClose={() => setOpen(false)} aria-labelledby='form-dialog-title' fullWidth maxWidth='lg'>
|
||||
<DialogTitle id='form-dialog-title'>{t('About')}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<center>
|
||||
<h2>TorrServer {torrServerVersion}</h2>
|
||||
<a href='https://github.com/YouROK/TorrServer'>https://github.com/YouROK/TorrServer</a>
|
||||
</center>
|
||||
<DialogContent>
|
||||
<center>
|
||||
<h2>{t('ThanksToEveryone')}</h2>
|
||||
</center>
|
||||
<br />
|
||||
<h2>{t('SpecialThanks')}</h2>
|
||||
<b>anacrolix Matt Joiner</b> <a href='https://github.com/anacrolix/'>github.com/anacrolix</a>
|
||||
<br />
|
||||
<b>nikk</b> <a href='https://github.com/tsynik'>github.com/tsynik</a>
|
||||
<br />
|
||||
<b>dancheskus</b> <a href='https://github.com/dancheskus'>github.com/dancheskus</a>
|
||||
<br />
|
||||
<b>tw1cker Руслан Пахнев</b> <a href='https://github.com/Nemiroff'>github.com/Nemiroff</a>
|
||||
<br />
|
||||
<b>SpAwN_LMG</b>
|
||||
<br />
|
||||
</DialogContent>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={() => setOpen(false)} color='primary' variant='outlined' autoFocus>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import TextField from '@material-ui/core/TextField'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import DialogContentText from '@material-ui/core/DialogContentText'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import LibraryAddIcon from '@material-ui/icons/LibraryAdd'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import { torrentsHost } from '../utils/Hosts'
|
||||
|
||||
export default function AddDialog() {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
|
||||
const [magnet, setMagnet] = React.useState('')
|
||||
const [title, setTitle] = React.useState('')
|
||||
const [poster, setPoster] = React.useState('')
|
||||
|
||||
const handleClickOpen = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const inputMagnet = (event) => {
|
||||
setMagnet(event.target.value)
|
||||
}
|
||||
|
||||
const inputTitle = (event) => {
|
||||
setTitle(event.target.value)
|
||||
}
|
||||
|
||||
const inputPoster = (event) => {
|
||||
setPoster(event.target.value)
|
||||
}
|
||||
|
||||
const handleCloseSave = () => {
|
||||
try {
|
||||
if (!magnet) return
|
||||
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({
|
||||
action: 'add',
|
||||
link: magnet,
|
||||
title: title,
|
||||
poster: poster,
|
||||
save_to_db: true,
|
||||
}),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
setOpen(false)
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItem button key="Add" onClick={handleClickOpen}>
|
||||
<ListItemIcon>
|
||||
<LibraryAddIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Add" />
|
||||
</ListItem>
|
||||
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title" fullWidth={true}>
|
||||
<DialogTitle id="form-dialog-title">Add Magnet</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Add magnet or link to torrent file:</DialogContentText>
|
||||
<TextField onChange={inputTitle} margin="dense" id="title" label="Title" type="text" fullWidth />
|
||||
<TextField onChange={inputPoster} margin="dense" id="poster" label="Poster" type="url" fullWidth />
|
||||
<TextField onChange={inputMagnet} autoFocus margin="dense" id="magnet" label="Magnet" type="text" fullWidth />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color="primary" variant="outlined">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCloseSave} color="primary" variant="outlined">
|
||||
Add
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
web/src/components/Add/AddDialog.jsx
Normal file
55
web/src/components/Add/AddDialog.jsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import TextField from '@material-ui/core/TextField'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import { torrentsHost } from 'utils/Hosts'
|
||||
import axios from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function AddDialog({ handleClose }) {
|
||||
const { t } = useTranslation()
|
||||
const [link, setLink] = useState('')
|
||||
const [title, setTitle] = useState('')
|
||||
const [poster, setPoster] = useState('')
|
||||
|
||||
const inputMagnet = ({ target: { value } }) => setLink(value)
|
||||
const inputTitle = ({ target: { value } }) => setTitle(value)
|
||||
const inputPoster = ({ target: { value } }) => setPoster(value)
|
||||
|
||||
const handleSave = () => {
|
||||
axios.post(torrentsHost(), { action: 'add', link, title, poster, save_to_db: true }).finally(() => handleClose())
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onClose={handleClose} aria-labelledby='form-dialog-title' fullWidth>
|
||||
<DialogTitle id='form-dialog-title'>{t('AddMagnetOrLink')}</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
<TextField onChange={inputTitle} margin='dense' id='title' label={t('Title')} type='text' fullWidth />
|
||||
<TextField onChange={inputPoster} margin='dense' id='poster' label={t('Poster')} type='url' fullWidth />
|
||||
<TextField
|
||||
onChange={inputMagnet}
|
||||
autoFocus
|
||||
margin='dense'
|
||||
id='magnet'
|
||||
label={t('MagnetOrTorrentFileLink')}
|
||||
type='text'
|
||||
fullWidth
|
||||
/>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color='primary' variant='outlined'>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button variant='contained' disabled={!link} onClick={handleSave} color='primary'>
|
||||
{t('Add')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
28
web/src/components/Add/index.jsx
Normal file
28
web/src/components/Add/index.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useState } from 'react'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import LibraryAddIcon from '@material-ui/icons/LibraryAdd'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import AddDialog from './AddDialog'
|
||||
|
||||
export default function AddDialogButton() {
|
||||
const { t } = useTranslation()
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const handleClickOpen = () => setIsDialogOpen(true)
|
||||
const handleClose = () => setIsDialogOpen(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItem button key='Add' onClick={handleClickOpen}>
|
||||
<ListItemIcon>
|
||||
<LibraryAddIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('AddFromLink')} />
|
||||
</ListItem>
|
||||
|
||||
{isDialogOpen && <AddDialog handleClose={handleClose} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { makeStyles, useTheme } from '@material-ui/core/styles'
|
||||
import Drawer from '@material-ui/core/Drawer'
|
||||
import AppBar from '@material-ui/core/AppBar'
|
||||
import Toolbar from '@material-ui/core/Toolbar'
|
||||
import List from '@material-ui/core/List'
|
||||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import Divider from '@material-ui/core/Divider'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import MenuIcon from '@material-ui/icons/Menu'
|
||||
import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'
|
||||
import ChevronRightIcon from '@material-ui/icons/ChevronRight'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
|
||||
import ListIcon from '@material-ui/icons/List'
|
||||
import PowerSettingsNewIcon from '@material-ui/icons/PowerSettingsNew'
|
||||
|
||||
import TorrentList from './TorrentList'
|
||||
import { Box } from '@material-ui/core'
|
||||
|
||||
import AddDialog from './Add'
|
||||
import RemoveAll from './RemoveAll'
|
||||
import SettingsDialog from './Settings'
|
||||
import AboutDialog from './About'
|
||||
import { playlistAllHost, shutdownHost, torrserverHost } from '../utils/Hosts'
|
||||
import DonateDialog from './Donate'
|
||||
import UploadDialog from './Upload'
|
||||
|
||||
const drawerWidth = 240
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
root: {
|
||||
display: 'flex',
|
||||
},
|
||||
appBar: {
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
},
|
||||
appBarShift: {
|
||||
marginLeft: drawerWidth,
|
||||
width: `calc(100% - ${drawerWidth}px)`,
|
||||
transition: theme.transitions.create(['width', 'margin'], {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
},
|
||||
menuButton: {
|
||||
marginRight: 36,
|
||||
},
|
||||
hide: {
|
||||
display: 'none',
|
||||
},
|
||||
drawer: {
|
||||
width: drawerWidth,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
},
|
||||
drawerOpen: {
|
||||
width: drawerWidth,
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.enteringScreen,
|
||||
}),
|
||||
},
|
||||
drawerClose: {
|
||||
transition: theme.transitions.create('width', {
|
||||
easing: theme.transitions.easing.sharp,
|
||||
duration: theme.transitions.duration.leavingScreen,
|
||||
}),
|
||||
overflowX: 'hidden',
|
||||
width: theme.spacing(7) + 1,
|
||||
[theme.breakpoints.up('sm')]: {
|
||||
width: theme.spacing(9) + 1,
|
||||
},
|
||||
},
|
||||
toolbar: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
padding: theme.spacing(0, 1),
|
||||
// necessary for content to be below app bar
|
||||
...theme.mixins.toolbar,
|
||||
},
|
||||
content: {
|
||||
flexGrow: 1,
|
||||
padding: theme.spacing(3),
|
||||
},
|
||||
}))
|
||||
|
||||
export default function MiniDrawer() {
|
||||
const classes = useStyles()
|
||||
const theme = useTheme()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [tsVersion, setTSVersion] = React.useState('')
|
||||
|
||||
const handleDrawerOpen = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch(torrserverHost + '/echo')
|
||||
.then((resp) => resp.text())
|
||||
.then((txt) => {
|
||||
if (!txt.startsWith('<!DOCTYPE html>')) setTSVersion(txt)
|
||||
})
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<div className={classes.root}>
|
||||
<CssBaseline />
|
||||
<AppBar
|
||||
position="fixed"
|
||||
className={clsx(classes.appBar, {
|
||||
[classes.appBarShift]: open,
|
||||
})}
|
||||
>
|
||||
<Toolbar>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
aria-label="open drawer"
|
||||
onClick={handleDrawerOpen}
|
||||
edge="start"
|
||||
className={clsx(classes.menuButton, {
|
||||
[classes.hide]: open,
|
||||
})}
|
||||
>
|
||||
<MenuIcon />
|
||||
</IconButton>
|
||||
<Typography variant="h6" noWrap>
|
||||
TorrServer {tsVersion}
|
||||
</Typography>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
className={clsx(classes.drawer, {
|
||||
[classes.drawerOpen]: open,
|
||||
[classes.drawerClose]: !open,
|
||||
})}
|
||||
classes={{
|
||||
paper: clsx({
|
||||
[classes.drawerOpen]: open,
|
||||
[classes.drawerClose]: !open,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<div className={classes.toolbar}>
|
||||
<IconButton onClick={handleDrawerClose}>{theme.direction === 'rtl' ? <ChevronRightIcon /> : <ChevronLeftIcon />}</IconButton>
|
||||
</div>
|
||||
<Divider />
|
||||
<List>
|
||||
<AddDialog />
|
||||
<UploadDialog />
|
||||
<RemoveAll />
|
||||
<ListItem button component="a" key="Playlist all torrents" target="_blank" href={playlistAllHost()}>
|
||||
<ListItemIcon>
|
||||
<ListIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Playlist all torrents" />
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
<List>
|
||||
<SettingsDialog />
|
||||
<DonateDialog />
|
||||
<AboutDialog />
|
||||
<ListItem button key="Close server" onClick={() => fetch(shutdownHost())}>
|
||||
<ListItemIcon>
|
||||
<PowerSettingsNewIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Close server" />
|
||||
</ListItem>
|
||||
</List>
|
||||
<Divider />
|
||||
</Drawer>
|
||||
<main className={classes.content}>
|
||||
<Box m="5em" />
|
||||
<TorrentList />
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
web/src/components/CloseServer.jsx
Normal file
45
web/src/components/CloseServer.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Dialog, DialogActions, DialogTitle, ListItem, ListItemIcon, ListItemText } from '@material-ui/core'
|
||||
import { PowerSettingsNew as PowerSettingsNewIcon } from '@material-ui/icons'
|
||||
import { shutdownHost } from 'utils/Hosts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function CloseServer() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const closeDialog = () => setOpen(false)
|
||||
const openDialog = () => setOpen(true)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ListItem button key={t('CloseServer')} onClick={openDialog}>
|
||||
<ListItemIcon>
|
||||
<PowerSettingsNewIcon />
|
||||
</ListItemIcon>
|
||||
|
||||
<ListItemText primary={t('CloseServer')} />
|
||||
</ListItem>
|
||||
|
||||
<Dialog open={open} onClose={closeDialog}>
|
||||
<DialogTitle>{t('CloseServer?')}</DialogTitle>
|
||||
<DialogActions>
|
||||
<Button variant='outlined' onClick={closeDialog} color='primary'>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={() => {
|
||||
fetch(shutdownHost())
|
||||
closeDialog()
|
||||
}}
|
||||
color='primary'
|
||||
autoFocus
|
||||
>
|
||||
{t('TurnOff')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
|
||||
import { getPeerString, humanizeSize } from '../utils/Utils'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import { cacheHost } from '../utils/Hosts'
|
||||
|
||||
const style = {
|
||||
cache: {
|
||||
paddingLeft: "6px",
|
||||
paddingRight: "2px",
|
||||
lineHeight: "11px",
|
||||
},
|
||||
piece: {
|
||||
width: "12px",
|
||||
height: "12px",
|
||||
backgroundColor: "#eef2f4",
|
||||
border: "1px solid #eef2f4",
|
||||
display: "inline-block",
|
||||
marginRight: "1px",
|
||||
},
|
||||
pieceComplete: {
|
||||
backgroundColor: "#3fb57a",
|
||||
borderColor: "#3fb57a",
|
||||
},
|
||||
pieceLoading: {
|
||||
backgroundColor: "#00d0d0",
|
||||
borderColor: "#00d0d0",
|
||||
},
|
||||
readerRange: {
|
||||
borderColor: "#9a9aff !important",
|
||||
},
|
||||
pieceReader: {
|
||||
borderColor: "#000000 !important",
|
||||
},
|
||||
pieceProgress: {
|
||||
position: "relative",
|
||||
zIndex: "1",
|
||||
backgroundColor: "#009090",
|
||||
|
||||
left: "-1px",
|
||||
top: "-1px",
|
||||
width: "12px",
|
||||
},
|
||||
}
|
||||
|
||||
export default function DialogCacheInfo(props) {
|
||||
const [hash] = React.useState(props.hash)
|
||||
const [cache, setCache] = React.useState({})
|
||||
const timerID = useRef(-1)
|
||||
const [pMap, setPMap] = React.useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (hash)
|
||||
timerID.current = setInterval(() => {
|
||||
getCache(hash, (cache) => {
|
||||
setCache(cache)
|
||||
})
|
||||
}, 100)
|
||||
else clearInterval(timerID.current)
|
||||
|
||||
return () => {
|
||||
clearInterval(timerID.current)
|
||||
}
|
||||
}, [hash, props.open])
|
||||
|
||||
useEffect(()=>{
|
||||
if (cache && cache.PiecesCount && cache.Pieces){
|
||||
var map = [];
|
||||
for (let i = 0; i < cache.PiecesCount; i++) {
|
||||
var reader = 0
|
||||
var cls = "piece"
|
||||
var prc = 0
|
||||
if (cache.Pieces[i]) {
|
||||
if (cache.Pieces[i].Completed && cache.Pieces[i].Size >= cache.Pieces[i].Length)
|
||||
cls += " piece-complete"
|
||||
else
|
||||
cls += " piece-loading"
|
||||
prc = (cache.Pieces[i].Size / cache.Pieces[i].Length * 100).toFixed(2)
|
||||
}
|
||||
|
||||
cache.Readers.forEach((r, k) => {
|
||||
if (i >= r.Start && i <= r.End && i !== r.Reader)
|
||||
cls += " reader-range"
|
||||
if (i === r.Reader) {
|
||||
cls += " piece-reader"
|
||||
}
|
||||
})
|
||||
map.push({
|
||||
prc: prc,
|
||||
class: cls,
|
||||
info: i,
|
||||
reader: reader,
|
||||
})
|
||||
}
|
||||
setPMap(map)
|
||||
}
|
||||
},[cache.Pieces])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
<Typography fullWidth>
|
||||
<b>Hash </b> {cache.Hash}
|
||||
<br />
|
||||
<b>Capacity </b> {humanizeSize(cache.Capacity)}
|
||||
<br />
|
||||
<b>Filled </b> {humanizeSize(cache.Filled)}
|
||||
<br />
|
||||
<b>Torrent size </b> {cache.Torrent && cache.Torrent.torrent_size && humanizeSize(cache.Torrent.torrent_size)}
|
||||
<br />
|
||||
<b>Pieces length </b> {humanizeSize(cache.PiecesLength)}
|
||||
<br />
|
||||
<b>Pieces count </b> {cache.PiecesCount}
|
||||
<br />
|
||||
<b>Peers: </b> {getPeerString(cache.Torrent)}
|
||||
<br />
|
||||
<b>Download speed </b> {cache.Torrent && cache.Torrent.download_speed ? humanizeSize(cache.Torrent.download_speed) + '/sec' : ''}
|
||||
<br />
|
||||
<b>Upload speed </b> {cache.Torrent && cache.Torrent.upload_speed ? humanizeSize(cache.Torrent.upload_speed) + '/sec' : ''}
|
||||
<br />
|
||||
<b>Status </b> {cache.Torrent && cache.Torrent.stat_string && cache.Torrent.stat_string}
|
||||
</Typography>
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<div className="cache">
|
||||
{pMap.map((itm) => <span className={itm.class} title={itm.info}>{itm.prc>0 && itm.prc<100 && (<div className="piece-progress" style={{height: itm.prc/100*12+"px"}}></div>)}</span>)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getCacheMap(cache) {
|
||||
if (!cache || !cache.PiecesCount) return ''
|
||||
var html = ''
|
||||
for (let i = 0; i < cache.PiecesCount; i++) {
|
||||
html += "<span class='piece"
|
||||
let info = i
|
||||
var prcDiv = ""
|
||||
if (cache.Pieces && cache.Pieces[i]) {
|
||||
let prc = (cache.Pieces[i].Size/cache.Pieces[i].Length*100).toFixed(2)
|
||||
let piece = cache.Pieces[i]
|
||||
if (piece.Completed && piece.Size >= piece.Length) {
|
||||
html += ' piece-complete'
|
||||
info += ' 100%'
|
||||
}else {
|
||||
html += ' piece-loading'
|
||||
info += ' ' + prc + '%'
|
||||
prcDiv = "<div class='piece-progress' style='height: "+prc+"%;'></div>"
|
||||
}
|
||||
}
|
||||
cache.Readers.forEach((r,k)=> {
|
||||
if (i >= r.Start && i <= r.End && i !== r.Reader)
|
||||
html += ' reader-range'
|
||||
if (i === r.Reader) {
|
||||
html += ' piece-reader'
|
||||
info += ' reader'
|
||||
}
|
||||
})
|
||||
html += "' title='" + info + "'>"
|
||||
html += prcDiv
|
||||
html += "</span>"
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
function getCache(hash, callback) {
|
||||
try {
|
||||
fetch(cacheHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'get', hash: hash }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(
|
||||
(json) => {
|
||||
callback(json)
|
||||
},
|
||||
(error) => {
|
||||
callback({})
|
||||
console.error(error)
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
callback({})
|
||||
}
|
||||
}
|
||||
/*
|
||||
{
|
||||
"Hash": "41e36c8de915d80db83fc134bee4e7e2d292657e",
|
||||
"Capacity": 209715200,
|
||||
"Filled": 2914808,
|
||||
"PiecesLength": 4194304,
|
||||
"PiecesCount": 2065,
|
||||
"DownloadSpeed": 32770.860273455524,
|
||||
"Pieces": {
|
||||
"2064": {
|
||||
"Id": 2064,
|
||||
"Length": 2914808,
|
||||
"Size": 162296,
|
||||
"Completed": false
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SectionTitle, WidgetWrapper } from '../style'
|
||||
import { DetailedViewCacheSection, DetailedViewWidgetSection } from './style'
|
||||
import TorrentCache from '../TorrentCache'
|
||||
import {
|
||||
SizeWidget,
|
||||
PiecesLengthWidget,
|
||||
StatusWidget,
|
||||
PiecesCountWidget,
|
||||
PeersWidget,
|
||||
UploadSpeedWidget,
|
||||
DownlodSpeedWidget,
|
||||
} from '../widgets'
|
||||
|
||||
export default function Test({
|
||||
downloadSpeed,
|
||||
uploadSpeed,
|
||||
torrent,
|
||||
torrentSize,
|
||||
PiecesCount,
|
||||
PiecesLength,
|
||||
statString,
|
||||
cache,
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<DetailedViewWidgetSection>
|
||||
<SectionTitle mb={20}>Data</SectionTitle>
|
||||
<WidgetWrapper detailedView>
|
||||
<DownlodSpeedWidget data={downloadSpeed} />
|
||||
<UploadSpeedWidget data={uploadSpeed} />
|
||||
<PeersWidget data={torrent} />
|
||||
<SizeWidget data={torrentSize} />
|
||||
<PiecesCountWidget data={PiecesCount} />
|
||||
<PiecesLengthWidget data={PiecesLength} />
|
||||
<StatusWidget data={statString} />
|
||||
</WidgetWrapper>
|
||||
</DetailedViewWidgetSection>
|
||||
|
||||
<DetailedViewCacheSection>
|
||||
<SectionTitle mb={20}>Cache</SectionTitle>
|
||||
<TorrentCache cache={cache} />
|
||||
</DetailedViewCacheSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import styled from 'styled-components'
|
||||
|
||||
export const DetailedViewWidgetSection = styled.section`
|
||||
padding: 40px;
|
||||
background: linear-gradient(145deg, #e4f6ed, #b5dec9);
|
||||
|
||||
@media (max-width: 800px) {
|
||||
padding: 20px;
|
||||
}
|
||||
`
|
||||
|
||||
export const DetailedViewCacheSection = styled.section`
|
||||
padding: 40px;
|
||||
box-shadow: inset 3px 25px 8px -25px rgba(0, 0, 0, 0.5);
|
||||
|
||||
@media (max-width: 800px) {
|
||||
padding: 20px;
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,35 @@
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { AppBar, IconButton, makeStyles, Toolbar, Typography } from '@material-ui/core'
|
||||
import CloseIcon from '@material-ui/icons/Close'
|
||||
import { ArrowBack } from '@material-ui/icons'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
appBar: { position: 'relative' },
|
||||
title: { marginLeft: theme.spacing(2), flex: 1 },
|
||||
}))
|
||||
|
||||
export default function DialogHeader({ title, onClose, onBack }) {
|
||||
const { t } = useTranslation()
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<AppBar className={classes.appBar}>
|
||||
<Toolbar>
|
||||
<IconButton edge='start' color='inherit' onClick={onBack || onClose} aria-label='close'>
|
||||
{onBack ? <ArrowBack /> : <CloseIcon />}
|
||||
</IconButton>
|
||||
|
||||
<Typography variant='h6' className={classes.title}>
|
||||
{title}
|
||||
</Typography>
|
||||
|
||||
{onBack && (
|
||||
<Button autoFocus color='inherit' onClick={onClose}>
|
||||
{t('Close')}
|
||||
</Button>
|
||||
)}
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { WidgetFieldWrapper, WidgetFieldIcon, WidgetFieldValue, WidgetFieldTitle } from './style'
|
||||
|
||||
export default function StatisticsField({ icon: Icon, title, value, iconBg, valueBg }) {
|
||||
return (
|
||||
<WidgetFieldWrapper>
|
||||
<WidgetFieldTitle>{title}</WidgetFieldTitle>
|
||||
<WidgetFieldIcon bgColor={iconBg}>
|
||||
<Icon />
|
||||
</WidgetFieldIcon>
|
||||
|
||||
<WidgetFieldValue bgColor={valueBg}>{value}</WidgetFieldValue>
|
||||
</WidgetFieldWrapper>
|
||||
)
|
||||
}
|
||||
149
web/src/components/DialogTorrentDetailsContent/Table/index.jsx
Normal file
149
web/src/components/DialogTorrentDetailsContent/Table/index.jsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { streamHost } from 'utils/Hosts'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
import { humanizeSize } from 'utils/Utils'
|
||||
import ptt from 'parse-torrent-title'
|
||||
import { Button } from '@material-ui/core'
|
||||
import CopyToClipboard from 'react-copy-to-clipboard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { TableStyle, ShortTableWrapper, ShortTable } from './style'
|
||||
|
||||
const { memo } = require('react')
|
||||
|
||||
const Table = memo(
|
||||
({ playableFileList, viewedFileList, selectedSeason, seasonAmount, hash }) => {
|
||||
const { t } = useTranslation()
|
||||
const preloadBuffer = fileId => fetch(`${streamHost()}?link=${hash}&index=${fileId}&preload`)
|
||||
const getFileLink = (path, id) =>
|
||||
`${streamHost()}/${encodeURIComponent(path.split('\\').pop().split('/').pop())}?link=${hash}&index=${id}&play`
|
||||
const fileHasEpisodeText = !!playableFileList?.find(({ path }) => ptt.parse(path).episode)
|
||||
const fileHasSeasonText = !!playableFileList?.find(({ path }) => ptt.parse(path).season)
|
||||
const fileHasResolutionText = !!playableFileList?.find(({ path }) => ptt.parse(path).resolution)
|
||||
|
||||
return !playableFileList?.length ? (
|
||||
'No playable files in this torrent'
|
||||
) : (
|
||||
<>
|
||||
<TableStyle>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '0' }}>{t('Viewed')}</th>
|
||||
<th>{t('Name')}</th>
|
||||
{fileHasSeasonText && seasonAmount?.length === 1 && <th style={{ width: '0' }}>{t('Season')}</th>}
|
||||
{fileHasEpisodeText && <th style={{ width: '0' }}>{t('Episode')}</th>}
|
||||
{fileHasResolutionText && <th style={{ width: '0' }}>{t('Resolution')}</th>}
|
||||
<th style={{ width: '100px' }}>{t('Size')}</th>
|
||||
<th style={{ width: '400px' }}>{t('Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{playableFileList.map(({ id, path, length }) => {
|
||||
const { title, resolution, episode, season } = ptt.parse(path)
|
||||
const isViewed = viewedFileList?.includes(id)
|
||||
const link = getFileLink(path, id)
|
||||
|
||||
return (
|
||||
(season === selectedSeason || !seasonAmount?.length) && (
|
||||
<tr key={id} className={isViewed ? 'viewed-file-row' : null}>
|
||||
<td data-label='viewed' className={isViewed ? 'viewed-file-indicator' : null} />
|
||||
<td data-label='name'>{title}</td>
|
||||
{fileHasSeasonText && seasonAmount?.length === 1 && <td data-label='season'>{season}</td>}
|
||||
{fileHasEpisodeText && <td data-label='episode'>{episode}</td>}
|
||||
{fileHasResolutionText && <td data-label='resolution'>{resolution}</td>}
|
||||
<td data-label='size'>{humanizeSize(length)}</td>
|
||||
<td className='button-cell'>
|
||||
<Button onClick={() => preloadBuffer(id)} variant='outlined' color='primary' size='small'>
|
||||
{t('Preload')}
|
||||
</Button>
|
||||
|
||||
<a style={{ textDecoration: 'none' }} href={link} target='_blank' rel='noreferrer'>
|
||||
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
|
||||
{t('OpenLink')}
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<CopyToClipboard text={link}>
|
||||
<Button variant='outlined' color='primary' size='small'>
|
||||
{t('CopyLink')}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</TableStyle>
|
||||
|
||||
<ShortTableWrapper>
|
||||
{playableFileList.map(({ id, path, length }) => {
|
||||
const { title, resolution, episode, season } = ptt.parse(path)
|
||||
const isViewed = viewedFileList?.includes(id)
|
||||
const link = getFileLink(path, id)
|
||||
|
||||
return (
|
||||
(season === selectedSeason || !seasonAmount?.length) && (
|
||||
<ShortTable key={id} isViewed={isViewed}>
|
||||
<div className='short-table-name'>{title}</div>
|
||||
<div className='short-table-data'>
|
||||
{isViewed && (
|
||||
<div className='short-table-field'>
|
||||
<div className='short-table-field-name'>{t('Viewed')}</div>
|
||||
<div className='short-table-field-value'>
|
||||
<div className='short-table-viewed-indicator' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{fileHasSeasonText && seasonAmount?.length === 1 && (
|
||||
<div className='short-table-field'>
|
||||
<div className='short-table-field-name'>{t('Season')}</div>
|
||||
<div className='short-table-field-value'>{season}</div>
|
||||
</div>
|
||||
)}
|
||||
{fileHasEpisodeText && (
|
||||
<div className='short-table-field'>
|
||||
<div className='short-table-field-name'>{t('Episode')}</div>
|
||||
<div className='short-table-field-value'>{episode}</div>
|
||||
</div>
|
||||
)}
|
||||
{fileHasResolutionText && (
|
||||
<div className='short-table-field'>
|
||||
<div className='short-table-field-name'>{t('Resolution')}</div>
|
||||
<div className='short-table-field-value'>{resolution}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='short-table-field'>
|
||||
<div className='short-table-field-name'>{t('Size')}</div>
|
||||
<div className='short-table-field-value'>{humanizeSize(length)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='short-table-buttons'>
|
||||
<Button onClick={() => preloadBuffer(id)} variant='outlined' color='primary' size='small'>
|
||||
{t('Preload')}
|
||||
</Button>
|
||||
|
||||
<a style={{ textDecoration: 'none' }} href={link} target='_blank' rel='noreferrer'>
|
||||
<Button style={{ width: '100%' }} variant='outlined' color='primary' size='small'>
|
||||
{t('OpenLink')}
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<CopyToClipboard text={link}>
|
||||
<Button variant='outlined' color='primary' size='small'>
|
||||
{t('CopyLink')}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
</ShortTable>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</ShortTableWrapper>
|
||||
</>
|
||||
)
|
||||
},
|
||||
(prev, next) => isEqual(prev, next),
|
||||
)
|
||||
|
||||
export default Table
|
||||
172
web/src/components/DialogTorrentDetailsContent/Table/style.js
Normal file
172
web/src/components/DialogTorrentDetailsContent/Table/style.js
Normal file
@@ -0,0 +1,172 @@
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
const viewedIndicator = css`
|
||||
:before {
|
||||
content: '';
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: #15d5af;
|
||||
border-radius: 50%;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
`
|
||||
export const TableStyle = styled.table`
|
||||
border-collapse: collapse;
|
||||
margin: 25px 0;
|
||||
font-size: 0.9em;
|
||||
width: 100%;
|
||||
border-radius: 5px 5px 0 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
|
||||
|
||||
thead tr {
|
||||
background: #009879;
|
||||
color: #fff;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 15px;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid #ddd;
|
||||
|
||||
:last-of-type {
|
||||
border-bottom: 2px solid #009879;
|
||||
}
|
||||
|
||||
&.viewed-file-row {
|
||||
background: #f3f3f3;
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
&.viewed-file-indicator {
|
||||
position: relative;
|
||||
|
||||
${viewedIndicator}
|
||||
}
|
||||
|
||||
&.button-cell {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 970px) {
|
||||
display: none;
|
||||
}
|
||||
`
|
||||
|
||||
export const ShortTableWrapper = styled.div`
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
display: none;
|
||||
|
||||
@media (max-width: 970px) {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
gap: 15px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`
|
||||
|
||||
export const ShortTable = styled.div`
|
||||
${({ isViewed }) => css`
|
||||
width: 100%;
|
||||
grid-template-rows: repeat(3, max-content);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.15);
|
||||
|
||||
.short-table {
|
||||
&-name {
|
||||
background: ${isViewed ? '#bdbdbd' : '#009879'};
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 15px;
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
font-size: 15px;
|
||||
font-weight: bold;
|
||||
|
||||
@media (max-width: 880px) {
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
&-data {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-template-columns: ${isViewed ? 'max-content' : '1fr'};
|
||||
grid-auto-columns: 1fr;
|
||||
}
|
||||
&-field {
|
||||
display: grid;
|
||||
grid-template-rows: 30px 1fr;
|
||||
background: black;
|
||||
:not(:last-child) {
|
||||
border-right: 1px solid ${isViewed ? '#bdbdbd' : '#019376'};
|
||||
}
|
||||
|
||||
&-name {
|
||||
background: ${isViewed ? '#c4c4c4' : '#00a383'};
|
||||
color: #fff;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0 10px;
|
||||
|
||||
@media (max-width: 880px) {
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
|
||||
&-value {
|
||||
background: ${isViewed ? '#c9c9c9' : '#03aa89'};
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
padding: 15px 10px;
|
||||
position: relative;
|
||||
|
||||
@media (max-width: 880px) {
|
||||
font-size: 13px;
|
||||
padding: 12px 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-viewed-indicator {
|
||||
${isViewed && viewedIndicator}
|
||||
}
|
||||
|
||||
&-buttons {
|
||||
padding: 20px;
|
||||
border-bottom: 2px solid ${isViewed ? '#bdbdbd' : '#009879'};
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
|
||||
@media (max-width: 410px) {
|
||||
gap: 10px;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
}
|
||||
`}
|
||||
`
|
||||
@@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import { Stage, Layer } from 'react-konva'
|
||||
import Measure from 'react-measure'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import SingleBlock from './SingleBlock'
|
||||
import getShortCacheMap from './getShortCacheMap'
|
||||
|
||||
const ScrollNotification = styled.div`
|
||||
margin-top: 10px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
align-self: center;
|
||||
`
|
||||
|
||||
export default function DefaultSnake({ isMini, cacheMap, preloadPiecesAmount }) {
|
||||
const [dimensions, setDimensions] = useState({ width: 0, height: 0 })
|
||||
const [stageSettings, setStageSettings] = useState({
|
||||
boxHeight: null,
|
||||
strokeWidth: null,
|
||||
marginBetweenBlocks: null,
|
||||
stageOffset: null,
|
||||
})
|
||||
const updateStageSettings = (boxHeight, strokeWidth) => {
|
||||
setStageSettings({
|
||||
boxHeight,
|
||||
strokeWidth,
|
||||
marginBetweenBlocks: strokeWidth,
|
||||
stageOffset: strokeWidth * 2,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// initializing stageSettings
|
||||
if (isMini) return dimensions.width < 500 ? updateStageSettings(20, 3) : updateStageSettings(24, 4)
|
||||
updateStageSettings(12, 2)
|
||||
}, [isMini, dimensions.width])
|
||||
|
||||
const miniCacheMaxHeight = 340
|
||||
|
||||
const { boxHeight, strokeWidth, marginBetweenBlocks, stageOffset } = stageSettings
|
||||
|
||||
const blockSizeWithMargin = boxHeight + strokeWidth + marginBetweenBlocks
|
||||
const piecesInOneRow = Math.floor((dimensions.width * 0.9) / blockSizeWithMargin)
|
||||
|
||||
const shortCacheMap = isMini ? getShortCacheMap({ cacheMap, preloadPiecesAmount, piecesInOneRow }) : []
|
||||
|
||||
const amountOfRows = Math.ceil((isMini ? shortCacheMap.length : cacheMap.length) / piecesInOneRow)
|
||||
|
||||
const getItemCoordinates = blockOrder => {
|
||||
const currentRow = Math.floor(blockOrder / piecesInOneRow)
|
||||
const x = (blockOrder % piecesInOneRow) * blockSizeWithMargin || 0
|
||||
const y = currentRow * blockSizeWithMargin || 0
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
return (
|
||||
<Measure bounds onResize={({ bounds }) => setDimensions(bounds)}>
|
||||
{({ measureRef }) => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<DialogContent
|
||||
ref={measureRef}
|
||||
{...(isMini
|
||||
? { style: { padding: 0, maxHeight: `${miniCacheMaxHeight}px`, overflow: 'auto' } }
|
||||
: { style: { padding: 0 } })}
|
||||
>
|
||||
<Stage
|
||||
style={{ display: 'flex', justifyContent: 'center' }}
|
||||
offset={{ x: -stageOffset, y: -stageOffset }}
|
||||
width={stageOffset + blockSizeWithMargin * piecesInOneRow || 0}
|
||||
height={stageOffset + blockSizeWithMargin * amountOfRows || 0}
|
||||
>
|
||||
<Layer>
|
||||
{isMini
|
||||
? shortCacheMap.map(({ percentage, isComplete, inProgress, isActive, isReaderRange }, i) => {
|
||||
const { x, y } = getItemCoordinates(i)
|
||||
|
||||
return (
|
||||
<SingleBlock
|
||||
key={uuidv4()}
|
||||
x={x}
|
||||
y={y}
|
||||
percentage={percentage}
|
||||
inProgress={inProgress}
|
||||
isComplete={isComplete}
|
||||
isReaderRange={isReaderRange}
|
||||
isActive={isActive}
|
||||
boxHeight={boxHeight}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
)
|
||||
})
|
||||
: cacheMap.map(({ id, percentage, isComplete, inProgress, isActive, isReaderRange }) => {
|
||||
const { x, y } = getItemCoordinates(id)
|
||||
|
||||
return (
|
||||
<SingleBlock
|
||||
key={uuidv4()}
|
||||
x={x}
|
||||
y={y}
|
||||
percentage={percentage}
|
||||
inProgress={inProgress}
|
||||
isComplete={isComplete}
|
||||
isReaderRange={isReaderRange}
|
||||
isActive={isActive}
|
||||
boxHeight={boxHeight}
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</Layer>
|
||||
</Stage>
|
||||
</DialogContent>
|
||||
|
||||
{isMini &&
|
||||
(stageOffset + blockSizeWithMargin * amountOfRows || 0) >= miniCacheMaxHeight &&
|
||||
dimensions.height >= miniCacheMaxHeight && <ScrollNotification>scroll down</ScrollNotification>}
|
||||
</div>
|
||||
)}
|
||||
</Measure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { FixedSizeGrid as Grid } from 'react-window'
|
||||
import AutoSizer from 'react-virtualized-auto-sizer'
|
||||
|
||||
import { getLargeSnakeColors } from './colors'
|
||||
|
||||
const Cell = ({ columnIndex, rowIndex, style, data }) => {
|
||||
const { columnCount, cacheMap, gutterSize, borderSize, pieces } = data
|
||||
const itemIndex = rowIndex * columnCount + columnIndex
|
||||
|
||||
const { borderColor, backgroundColor } = getLargeSnakeColors(cacheMap[itemIndex] || {})
|
||||
|
||||
const newStyle = {
|
||||
...style,
|
||||
left: style.left + gutterSize,
|
||||
top: style.top + gutterSize,
|
||||
width: style.width - gutterSize,
|
||||
height: style.height - gutterSize,
|
||||
border: `${borderSize}px solid ${borderColor}`,
|
||||
display: itemIndex >= pieces ? 'none' : null,
|
||||
background: backgroundColor,
|
||||
}
|
||||
|
||||
return <div style={newStyle} />
|
||||
}
|
||||
|
||||
const gutterSize = 2
|
||||
const borderSize = 1
|
||||
const pieceSize = 12
|
||||
const pieceSizeWithSpacing = pieceSize + gutterSize
|
||||
|
||||
export default function LargeSnake({ cacheMap }) {
|
||||
const pieces = cacheMap.length
|
||||
|
||||
return (
|
||||
<div style={{ height: '60vh', overflow: 'hidden' }}>
|
||||
<AutoSizer>
|
||||
{({ height, width }) => {
|
||||
const columnCount = Math.floor(width / (gutterSize + pieceSize)) - 1
|
||||
const rowCount = pieces / columnCount + 1
|
||||
|
||||
return (
|
||||
<Grid
|
||||
columnCount={columnCount}
|
||||
rowCount={rowCount}
|
||||
columnWidth={pieceSizeWithSpacing}
|
||||
rowHeight={pieceSizeWithSpacing}
|
||||
height={height}
|
||||
width={width}
|
||||
itemData={{ columnCount, cacheMap, gutterSize, borderSize, pieces }}
|
||||
>
|
||||
{Cell}
|
||||
</Grid>
|
||||
)
|
||||
}}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Rect } from 'react-konva'
|
||||
|
||||
import { activeColor, completeColor, defaultBorderColor, progressColor, rangeColor } from './colors'
|
||||
|
||||
export default function SingleBlock({
|
||||
x,
|
||||
y,
|
||||
percentage,
|
||||
isActive = false,
|
||||
inProgress = false,
|
||||
isReaderRange = false,
|
||||
isComplete = false,
|
||||
boxHeight,
|
||||
strokeWidth,
|
||||
}) {
|
||||
const strokeColor = isActive
|
||||
? activeColor
|
||||
: isComplete
|
||||
? completeColor
|
||||
: inProgress
|
||||
? progressColor
|
||||
: isReaderRange
|
||||
? rangeColor
|
||||
: defaultBorderColor
|
||||
const backgroundColor = inProgress ? progressColor : defaultBorderColor
|
||||
const percentageProgressColor = completeColor
|
||||
const processCompletedColor = completeColor
|
||||
|
||||
return (
|
||||
<Rect
|
||||
x={x}
|
||||
y={y}
|
||||
stroke={strokeColor}
|
||||
strokeWidth={strokeWidth}
|
||||
height={boxHeight}
|
||||
width={boxHeight}
|
||||
fillAfterStrokeEnabled
|
||||
preventDefault={false}
|
||||
{...(isComplete
|
||||
? { fill: processCompletedColor }
|
||||
: inProgress && {
|
||||
fillLinearGradientStartPointY: boxHeight,
|
||||
fillLinearGradientEndPointY: 0,
|
||||
fillLinearGradientColorStops: [
|
||||
0,
|
||||
percentageProgressColor,
|
||||
percentage,
|
||||
percentageProgressColor,
|
||||
percentage,
|
||||
backgroundColor,
|
||||
],
|
||||
})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export const defaultBorderColor = '#eef2f4'
|
||||
export const defaultBackgroundColor = '#fff'
|
||||
export const completeColor = '#00a572'
|
||||
export const progressColor = '#ffa724'
|
||||
export const activeColor = '#000'
|
||||
export const rangeColor = '#9a9aff'
|
||||
|
||||
export const getLargeSnakeColors = ({ isActive, isComplete, inProgress, isReaderRange, percentage }) => {
|
||||
const gradientBackgroundColor = inProgress ? progressColor : defaultBackgroundColor
|
||||
const gradient = `linear-gradient(to top, ${completeColor} 0%, ${completeColor} ${
|
||||
percentage * 100
|
||||
}%, ${gradientBackgroundColor} ${percentage * 100}%, ${gradientBackgroundColor} 100%)`
|
||||
|
||||
const borderColor = isActive
|
||||
? activeColor
|
||||
: isComplete
|
||||
? completeColor
|
||||
: inProgress
|
||||
? progressColor
|
||||
: isReaderRange
|
||||
? rangeColor
|
||||
: defaultBorderColor
|
||||
const backgroundColor = isComplete ? completeColor : inProgress ? gradient : defaultBackgroundColor
|
||||
|
||||
return { borderColor, backgroundColor }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
export default ({ cacheMap, preloadPiecesAmount, piecesInOneRow }) => {
|
||||
const cacheMapWithoutEmptyBlocks = cacheMap.filter(({ isComplete, inProgress }) => inProgress || isComplete)
|
||||
|
||||
const getFullAmountOfBlocks = amountOfBlocks =>
|
||||
// this function counts existed amount of blocks with extra "empty blocks" to fill the row till the end
|
||||
amountOfBlocks % piecesInOneRow === 0
|
||||
? amountOfBlocks - 1
|
||||
: amountOfBlocks + piecesInOneRow - (amountOfBlocks % piecesInOneRow) - 1 || 0
|
||||
|
||||
const amountOfBlocksToRenderInShortView = getFullAmountOfBlocks(preloadPiecesAmount)
|
||||
// preloadPiecesAmount is counted from "cache.Capacity / cache.PiecesLength". We always show at least this amount of blocks
|
||||
const scalableAmountOfBlocksToRenderInShortView = getFullAmountOfBlocks(cacheMapWithoutEmptyBlocks.length)
|
||||
// cacheMap can become bigger than preloadPiecesAmount counted before. In that case we count blocks dynamically
|
||||
|
||||
const finalAmountOfBlocksToRenderInShortView = Math.max(
|
||||
// this check is needed to decide which is the biggest amount of blocks and take it to render
|
||||
scalableAmountOfBlocksToRenderInShortView,
|
||||
amountOfBlocksToRenderInShortView,
|
||||
)
|
||||
|
||||
const extraBlocksAmount = finalAmountOfBlocksToRenderInShortView - cacheMapWithoutEmptyBlocks.length + 1
|
||||
// amount of blocks needed to fill the line till the end
|
||||
|
||||
const extraEmptyBlocksForFillingLine = extraBlocksAmount ? new Array(extraBlocksAmount).fill({}) : []
|
||||
|
||||
return [...cacheMapWithoutEmptyBlocks, ...extraEmptyBlocksForFillingLine]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { memo } from 'react'
|
||||
import isEqual from 'lodash/isEqual'
|
||||
|
||||
import { useCreateCacheMap } from '../customHooks'
|
||||
import LargeSnake from './LargeSnake'
|
||||
import DefaultSnake from './DefaultSnake'
|
||||
|
||||
const TorrentCache = memo(
|
||||
({ cache, isMini }) => {
|
||||
const cacheMap = useCreateCacheMap(cache)
|
||||
|
||||
const preloadPiecesAmount = Math.round(cache.Capacity / cache.PiecesLength - 1)
|
||||
const isSnakeLarge = cacheMap.length > 5000
|
||||
|
||||
return isMini ? (
|
||||
<DefaultSnake isMini cacheMap={cacheMap} preloadPiecesAmount={preloadPiecesAmount} />
|
||||
) : isSnakeLarge ? (
|
||||
<LargeSnake cacheMap={cacheMap} />
|
||||
) : (
|
||||
<DefaultSnake cacheMap={cacheMap} preloadPiecesAmount={preloadPiecesAmount} />
|
||||
)
|
||||
},
|
||||
(prev, next) => isEqual(prev.cache.Pieces, next.cache.Pieces) && isEqual(prev.cache.Readers, next.cache.Readers),
|
||||
)
|
||||
|
||||
export default TorrentCache
|
||||
@@ -0,0 +1,85 @@
|
||||
import axios from 'axios'
|
||||
import { memo } from 'react'
|
||||
import { playlistTorrHost, torrentsHost, viewedHost } from 'utils/Hosts'
|
||||
import { CopyToClipboard } from 'react-copy-to-clipboard'
|
||||
import { Button } from '@material-ui/core'
|
||||
import ptt from 'parse-torrent-title'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { SmallLabel, MainSectionButtonGroup } from './style'
|
||||
import { SectionSubName } from '../style'
|
||||
|
||||
const TorrentFunctions = memo(
|
||||
({ hash, viewedFileList, playableFileList, name, title, setViewedFileList }) => {
|
||||
const { t } = useTranslation()
|
||||
const latestViewedFileId = viewedFileList?.[viewedFileList?.length - 1]
|
||||
const latestViewedFile = playableFileList?.find(({ id }) => id === latestViewedFileId)?.path
|
||||
const isOnlyOnePlayableFile = playableFileList?.length === 1
|
||||
const latestViewedFileData = latestViewedFile && ptt.parse(latestViewedFile)
|
||||
const dropTorrent = () => axios.post(torrentsHost(), { action: 'drop', hash })
|
||||
const removeTorrentViews = () =>
|
||||
axios.post(viewedHost(), { action: 'rem', hash, file_index: -1 }).then(() => setViewedFileList())
|
||||
const fullPlaylistLink = `${playlistTorrHost()}/${encodeURIComponent(name || title || 'file')}.m3u?link=${hash}&m3u`
|
||||
const partialPlaylistLink = `${fullPlaylistLink}&fromlast`
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isOnlyOnePlayableFile && !!viewedFileList?.length && (
|
||||
<>
|
||||
<SmallLabel>{t('DownloadPlaylist')}</SmallLabel>
|
||||
<SectionSubName mb={10}>
|
||||
<strong>{t('LatestFilePlayed')}</strong> {latestViewedFileData?.title}.
|
||||
{latestViewedFileData?.season && (
|
||||
<>
|
||||
{' '}
|
||||
{t('Season')}: {latestViewedFileData?.season}. {t('Episode')}: {latestViewedFileData?.episode}.
|
||||
</>
|
||||
)}
|
||||
</SectionSubName>
|
||||
|
||||
<MainSectionButtonGroup>
|
||||
<a style={{ textDecoration: 'none' }} href={fullPlaylistLink}>
|
||||
<Button style={{ width: '100%' }} variant='contained' color='primary' size='large'>
|
||||
{t('Full')}
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
<a style={{ textDecoration: 'none' }} href={partialPlaylistLink}>
|
||||
<Button style={{ width: '100%' }} variant='contained' color='primary' size='large'>
|
||||
{t('FromLatestFile')}
|
||||
</Button>
|
||||
</a>
|
||||
</MainSectionButtonGroup>
|
||||
</>
|
||||
)}
|
||||
<SmallLabel mb={10}>{t('TorrentState')}</SmallLabel>
|
||||
<MainSectionButtonGroup>
|
||||
<Button onClick={() => removeTorrentViews()} variant='contained' color='primary' size='large'>
|
||||
{t('RemoveViews')}
|
||||
</Button>
|
||||
<Button onClick={() => dropTorrent()} variant='contained' color='primary' size='large'>
|
||||
{t('DropTorrent')}
|
||||
</Button>
|
||||
</MainSectionButtonGroup>
|
||||
<SmallLabel mb={10}>{t('Info')}</SmallLabel>
|
||||
<MainSectionButtonGroup>
|
||||
{(isOnlyOnePlayableFile || !viewedFileList?.length) && (
|
||||
<a style={{ textDecoration: 'none' }} href={fullPlaylistLink}>
|
||||
<Button style={{ width: '100%' }} variant='contained' color='primary' size='large'>
|
||||
{t('DownloadPlaylist')}
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
<CopyToClipboard text={hash}>
|
||||
<Button variant='contained' color='primary' size='large'>
|
||||
{t('CopyHash')}
|
||||
</Button>
|
||||
</CopyToClipboard>
|
||||
</MainSectionButtonGroup>
|
||||
</>
|
||||
)
|
||||
},
|
||||
() => true,
|
||||
)
|
||||
|
||||
export default TorrentFunctions
|
||||
@@ -0,0 +1,33 @@
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
export const MainSectionButtonGroup = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
|
||||
:not(:last-child) {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
@media (max-width: 1580px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
@media (max-width: 880px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`
|
||||
|
||||
export const SmallLabel = styled.div`
|
||||
${({ mb }) => css`
|
||||
${mb && `margin-bottom: ${mb}px`};
|
||||
font-size: 20px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
font-size: 18px;
|
||||
${mb && `margin-bottom: ${mb / 1.5}px`};
|
||||
}
|
||||
`}
|
||||
`
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cacheHost, settingsHost } from 'utils/Hosts'
|
||||
import axios from 'axios'
|
||||
|
||||
export const useUpdateCache = hash => {
|
||||
const [cache, setCache] = useState({})
|
||||
const componentIsMounted = useRef(true)
|
||||
const timerID = useRef(null)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// this function is required to notify "updateCache" when NOT to make state update
|
||||
componentIsMounted.current = false
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (hash) {
|
||||
timerID.current = setInterval(() => {
|
||||
const updateCache = newCache => componentIsMounted.current && setCache(newCache)
|
||||
|
||||
axios
|
||||
.post(cacheHost(), { action: 'get', hash })
|
||||
.then(({ data }) => updateCache(data))
|
||||
// empty cache if error
|
||||
.catch(() => updateCache({}))
|
||||
}, 100)
|
||||
} else clearInterval(timerID.current)
|
||||
|
||||
return () => clearInterval(timerID.current)
|
||||
}, [hash])
|
||||
|
||||
return cache
|
||||
}
|
||||
|
||||
export const useCreateCacheMap = cache => {
|
||||
const [cacheMap, setCacheMap] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!cache.PiecesCount || !cache.Pieces) return
|
||||
|
||||
const { Pieces, PiecesCount, Readers } = cache
|
||||
|
||||
const map = []
|
||||
|
||||
for (let i = 0; i < PiecesCount; i++) {
|
||||
const newPiece = { id: i }
|
||||
|
||||
const currentPiece = Pieces[i]
|
||||
if (currentPiece) {
|
||||
if (currentPiece.Completed && currentPiece.Size === currentPiece.Length) newPiece.isComplete = true
|
||||
else {
|
||||
newPiece.inProgress = true
|
||||
newPiece.percentage = (currentPiece.Size / currentPiece.Length).toFixed(2)
|
||||
}
|
||||
}
|
||||
|
||||
Readers.forEach(r => {
|
||||
if (i === r.Reader) newPiece.isActive = true
|
||||
if (i >= r.Start && i <= r.End) newPiece.isReaderRange = true
|
||||
})
|
||||
|
||||
map.push(newPiece)
|
||||
}
|
||||
|
||||
setCacheMap(map)
|
||||
}, [cache])
|
||||
|
||||
return cacheMap
|
||||
}
|
||||
|
||||
export const useGetSettings = cache => {
|
||||
const [settings, setSettings] = useState()
|
||||
useEffect(() => {
|
||||
axios.post(settingsHost(), { action: 'get' }).then(({ data }) => setSettings(data))
|
||||
}, [cache])
|
||||
|
||||
return settings
|
||||
}
|
||||
68
web/src/components/DialogTorrentDetailsContent/helpers.js
Normal file
68
web/src/components/DialogTorrentDetailsContent/helpers.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const getExt = filename => {
|
||||
const ext = filename.split('.').pop()
|
||||
if (ext === filename) return ''
|
||||
return ext.toLowerCase()
|
||||
}
|
||||
const playableExtList = [
|
||||
// video
|
||||
'3g2',
|
||||
'3gp',
|
||||
'aaf',
|
||||
'asf',
|
||||
'avchd',
|
||||
'avi',
|
||||
'drc',
|
||||
'flv',
|
||||
'iso',
|
||||
'm2v',
|
||||
'm2ts',
|
||||
'm4p',
|
||||
'm4v',
|
||||
'mkv',
|
||||
'mng',
|
||||
'mov',
|
||||
'mp2',
|
||||
'mp4',
|
||||
'mpe',
|
||||
'mpeg',
|
||||
'mpg',
|
||||
'mpv',
|
||||
'mxf',
|
||||
'nsv',
|
||||
'ogg',
|
||||
'ogv',
|
||||
'ts',
|
||||
'qt',
|
||||
'rm',
|
||||
'rmvb',
|
||||
'roq',
|
||||
'svi',
|
||||
'vob',
|
||||
'webm',
|
||||
'wmv',
|
||||
'yuv',
|
||||
// audio
|
||||
'aac',
|
||||
'aiff',
|
||||
'ape',
|
||||
'au',
|
||||
'flac',
|
||||
'gsm',
|
||||
'it',
|
||||
'm3u',
|
||||
'm4a',
|
||||
'mid',
|
||||
'mod',
|
||||
'mp3',
|
||||
'mpa',
|
||||
'pls',
|
||||
'ra',
|
||||
's3m',
|
||||
'sid',
|
||||
'wav',
|
||||
'wma',
|
||||
'xm',
|
||||
]
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const isFilePlayable = fileName => playableExtList.includes(getExt(fileName))
|
||||
235
web/src/components/DialogTorrentDetailsContent/index.jsx
Normal file
235
web/src/components/DialogTorrentDetailsContent/index.jsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { NoImageIcon } from 'icons'
|
||||
import { humanizeSize, shortenText } from 'utils/Utils'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, ButtonGroup } from '@material-ui/core'
|
||||
import ptt from 'parse-torrent-title'
|
||||
import axios from 'axios'
|
||||
import { viewedHost } from 'utils/Hosts'
|
||||
import { GETTING_INFO, IN_DB } from 'torrentStates'
|
||||
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useUpdateCache, useGetSettings } from './customHooks'
|
||||
import DialogHeader from './DialogHeader'
|
||||
import TorrentCache from './TorrentCache'
|
||||
import Table from './Table'
|
||||
import DetailedView from './DetailedView'
|
||||
import {
|
||||
DialogContentGrid,
|
||||
MainSection,
|
||||
Poster,
|
||||
SectionTitle,
|
||||
SectionSubName,
|
||||
WidgetWrapper,
|
||||
LoadingProgress,
|
||||
SectionHeader,
|
||||
CacheSection,
|
||||
TorrentFilesSection,
|
||||
Divider,
|
||||
} from './style'
|
||||
import { DownlodSpeedWidget, UploadSpeedWidget, PeersWidget, SizeWidget, StatusWidget } from './widgets'
|
||||
import TorrentFunctions from './TorrentFunctions'
|
||||
import { isFilePlayable } from './helpers'
|
||||
|
||||
const Loader = () => (
|
||||
<div style={{ minHeight: '80vh', display: 'grid', placeItems: 'center' }}>
|
||||
<CircularProgress />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default function DialogTorrentDetailsContent({ closeDialog, torrent }) {
|
||||
const { t } = useTranslation()
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isDetailedCacheView, setIsDetailedCacheView] = useState(false)
|
||||
const [viewedFileList, setViewedFileList] = useState()
|
||||
const [playableFileList, setPlayableFileList] = useState()
|
||||
const [seasonAmount, setSeasonAmount] = useState(null)
|
||||
const [selectedSeason, setSelectedSeason] = useState()
|
||||
|
||||
const {
|
||||
poster,
|
||||
hash,
|
||||
title,
|
||||
name,
|
||||
stat,
|
||||
download_speed: downloadSpeed,
|
||||
upload_speed: uploadSpeed,
|
||||
stat_string: statString,
|
||||
torrent_size: torrentSize,
|
||||
file_stats: torrentFileList,
|
||||
} = torrent
|
||||
|
||||
const cache = useUpdateCache(hash)
|
||||
const settings = useGetSettings(cache)
|
||||
|
||||
const { Capacity, PiecesCount, PiecesLength, Filled } = cache
|
||||
|
||||
useEffect(() => {
|
||||
if (playableFileList && seasonAmount === null) {
|
||||
const seasons = []
|
||||
playableFileList.forEach(({ path }) => {
|
||||
const currentSeason = ptt.parse(path).season
|
||||
if (currentSeason) {
|
||||
!seasons.includes(currentSeason) && seasons.push(currentSeason)
|
||||
}
|
||||
})
|
||||
seasons.length && setSelectedSeason(seasons[0])
|
||||
setSeasonAmount(seasons.sort((a, b) => a - b))
|
||||
}
|
||||
}, [playableFileList, seasonAmount])
|
||||
|
||||
useEffect(() => {
|
||||
setPlayableFileList(torrentFileList?.filter(({ path }) => isFilePlayable(path)))
|
||||
}, [torrentFileList])
|
||||
|
||||
useEffect(() => {
|
||||
const cacheLoaded = !!Object.entries(cache).length
|
||||
const torrentLoaded = stat !== GETTING_INFO && stat !== IN_DB
|
||||
|
||||
if (!cacheLoaded && !isLoading) setIsLoading(true)
|
||||
if (cacheLoaded && isLoading && torrentLoaded) setIsLoading(false)
|
||||
}, [stat, cache, isLoading])
|
||||
|
||||
useEffect(() => {
|
||||
// getting viewed file list
|
||||
axios.post(viewedHost(), { action: 'list', hash }).then(({ data }) => {
|
||||
if (data) {
|
||||
const lst = data.map(itm => itm.file_index).sort((a, b) => a - b)
|
||||
setViewedFileList(lst)
|
||||
} else setViewedFileList()
|
||||
})
|
||||
}, [hash])
|
||||
|
||||
const bufferSize = settings?.PreloadBuffer ? Capacity : 33554432 // Default is 32mb if PreloadBuffer is false
|
||||
// const bufferSize = Capacity
|
||||
|
||||
const getTitle = value => {
|
||||
const torrentParsedName = value && ptt.parse(value)
|
||||
const newNameStrings = []
|
||||
|
||||
if (torrentParsedName?.title) newNameStrings.push(` ${torrentParsedName?.title}`)
|
||||
if (torrentParsedName?.year) newNameStrings.push(`. ${torrentParsedName?.year}.`)
|
||||
if (torrentParsedName?.resolution) newNameStrings.push(` (${torrentParsedName?.resolution})`)
|
||||
|
||||
return newNameStrings.join(' ')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader
|
||||
onClose={closeDialog}
|
||||
title={isDetailedCacheView ? t('DetailedCacheView') : t('TorrentDetails')}
|
||||
{...(isDetailedCacheView && { onBack: () => setIsDetailedCacheView(false) })}
|
||||
/>
|
||||
|
||||
<div style={{ minHeight: '80vh', overflow: 'auto' }}>
|
||||
{isLoading ? (
|
||||
<Loader />
|
||||
) : isDetailedCacheView ? (
|
||||
<DetailedView
|
||||
downloadSpeed={downloadSpeed}
|
||||
uploadSpeed={uploadSpeed}
|
||||
torrent={torrent}
|
||||
torrentSize={torrentSize}
|
||||
PiecesCount={PiecesCount}
|
||||
PiecesLength={PiecesLength}
|
||||
statString={statString}
|
||||
cache={cache}
|
||||
/>
|
||||
) : (
|
||||
<DialogContentGrid>
|
||||
<MainSection>
|
||||
<Poster poster={poster}>{poster ? <img alt='poster' src={poster} /> : <NoImageIcon />}</Poster>
|
||||
|
||||
<div>
|
||||
{name && name !== title ? (
|
||||
<>
|
||||
<SectionTitle>{shortenText(getTitle(name), 50)}</SectionTitle>
|
||||
<SectionSubName mb={20}>{shortenText(title, 160)}</SectionSubName>
|
||||
</>
|
||||
) : (
|
||||
<SectionTitle mb={20}>{shortenText(getTitle(title), 50)}</SectionTitle>
|
||||
)}
|
||||
|
||||
<WidgetWrapper>
|
||||
<DownlodSpeedWidget data={downloadSpeed} />
|
||||
<UploadSpeedWidget data={uploadSpeed} />
|
||||
<PeersWidget data={torrent} />
|
||||
<SizeWidget data={torrentSize} />
|
||||
<StatusWidget data={statString} />
|
||||
</WidgetWrapper>
|
||||
|
||||
<Divider />
|
||||
|
||||
<TorrentFunctions
|
||||
hash={hash}
|
||||
viewedFileList={viewedFileList}
|
||||
playableFileList={playableFileList}
|
||||
name={name}
|
||||
title={title}
|
||||
setViewedFileList={setViewedFileList}
|
||||
/>
|
||||
</div>
|
||||
</MainSection>
|
||||
|
||||
<CacheSection>
|
||||
<SectionHeader>
|
||||
<SectionTitle mb={20}>{t('Buffer')}</SectionTitle>
|
||||
{!settings?.PreloadBuffer && <SectionSubName>{t('BufferNote')}</SectionSubName>}
|
||||
<LoadingProgress
|
||||
value={Filled}
|
||||
fullAmount={bufferSize}
|
||||
label={`${humanizeSize(Filled) || '0 B'} / ${humanizeSize(bufferSize)}`}
|
||||
/>
|
||||
</SectionHeader>
|
||||
|
||||
<TorrentCache isMini cache={cache} />
|
||||
<Button
|
||||
style={{ marginTop: '30px' }}
|
||||
variant='contained'
|
||||
color='primary'
|
||||
size='large'
|
||||
onClick={() => setIsDetailedCacheView(true)}
|
||||
>
|
||||
{t('DetailedCacheView')}
|
||||
</Button>
|
||||
</CacheSection>
|
||||
|
||||
<TorrentFilesSection>
|
||||
<SectionTitle mb={20}>{t('TorrentContent')}</SectionTitle>
|
||||
|
||||
{seasonAmount?.length > 1 && (
|
||||
<>
|
||||
<SectionSubName mb={7}>{t('SelectSeason')}</SectionSubName>
|
||||
<ButtonGroup style={{ marginBottom: '30px' }} color='primary'>
|
||||
{seasonAmount.map(season => (
|
||||
<Button
|
||||
key={season}
|
||||
variant={selectedSeason === season ? 'contained' : 'outlined'}
|
||||
onClick={() => setSelectedSeason(season)}
|
||||
>
|
||||
{season}
|
||||
</Button>
|
||||
))}
|
||||
</ButtonGroup>
|
||||
|
||||
<SectionTitle mb={20}>
|
||||
{t('Season')} {selectedSeason}
|
||||
</SectionTitle>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Table
|
||||
hash={hash}
|
||||
playableFileList={playableFileList}
|
||||
viewedFileList={viewedFileList}
|
||||
selectedSeason={selectedSeason}
|
||||
seasonAmount={seasonAmount}
|
||||
/>
|
||||
</TorrentFilesSection>
|
||||
</DialogContentGrid>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
251
web/src/components/DialogTorrentDetailsContent/style.js
Normal file
251
web/src/components/DialogTorrentDetailsContent/style.js
Normal file
@@ -0,0 +1,251 @@
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
export const DialogContentGrid = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 70% 1fr;
|
||||
grid-template-rows: repeat(2, min-content);
|
||||
grid-template-areas:
|
||||
'main cache'
|
||||
'file-list file-list';
|
||||
|
||||
@media (max-width: 1450px) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: repeat(3, min-content);
|
||||
grid-template-areas:
|
||||
'main'
|
||||
'cache'
|
||||
'file-list';
|
||||
}
|
||||
`
|
||||
export const Poster = styled.div`
|
||||
${({ poster }) => css`
|
||||
height: 400px;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
align-self: center;
|
||||
|
||||
${poster
|
||||
? css`
|
||||
img {
|
||||
border-radius: 5px;
|
||||
height: 100%;
|
||||
}
|
||||
`
|
||||
: css`
|
||||
width: 300px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #74c39c;
|
||||
|
||||
svg {
|
||||
transform: scale(2.5) translateY(-3px);
|
||||
}
|
||||
`}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
align-self: start;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
${poster
|
||||
? css`
|
||||
height: 200px;
|
||||
`
|
||||
: css`
|
||||
display: none;
|
||||
`}
|
||||
}
|
||||
`}
|
||||
`
|
||||
export const MainSection = styled.section`
|
||||
grid-area: main;
|
||||
padding: 40px;
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
gap: 30px;
|
||||
background: linear-gradient(145deg, #e4f6ed, #b5dec9);
|
||||
|
||||
@media (max-width: 840px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
padding: 20px;
|
||||
}
|
||||
`
|
||||
|
||||
export const CacheSection = styled.section`
|
||||
grid-area: cache;
|
||||
padding: 40px;
|
||||
display: grid;
|
||||
align-content: start;
|
||||
grid-template-rows: min-content 1fr min-content;
|
||||
background: #88cdaa;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
padding: 20px;
|
||||
}
|
||||
`
|
||||
|
||||
export const TorrentFilesSection = styled.section`
|
||||
grid-area: file-list;
|
||||
padding: 40px;
|
||||
box-shadow: inset 3px 25px 8px -25px rgba(0, 0, 0, 0.5);
|
||||
|
||||
@media (max-width: 800px) {
|
||||
padding: 20px;
|
||||
}
|
||||
`
|
||||
|
||||
export const SectionSubName = styled.div`
|
||||
${({ mb }) => css`
|
||||
${mb && `margin-bottom: ${mb}px`};
|
||||
color: #7c7b7c;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
${mb && `margin-bottom: ${mb / 2}px`};
|
||||
font-size: 11px;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export const SectionTitle = styled.div`
|
||||
${({ mb }) => css`
|
||||
${mb && `margin-bottom: ${mb}px`};
|
||||
font-size: 35px;
|
||||
font-weight: 200;
|
||||
line-height: 1;
|
||||
word-break: break-word;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
font-size: 25px;
|
||||
${mb && `margin-bottom: ${mb / 2}px`};
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export const SectionHeader = styled.div`
|
||||
margin-bottom: 20px;
|
||||
`
|
||||
|
||||
export const WidgetWrapper = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(max-content, 220px));
|
||||
gap: 20px;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
gap: 15px;
|
||||
}
|
||||
@media (max-width: 410px) {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
${({ detailedView }) =>
|
||||
detailedView
|
||||
? css`
|
||||
@media (max-width: 800px) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
@media (max-width: 410px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`
|
||||
: css`
|
||||
@media (max-width: 800px) {
|
||||
grid-template-columns: repeat(auto-fit, minmax(max-content, 185px));
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
@media (max-width: 390px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export const WidgetFieldWrapper = styled.div`
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
grid-template-rows: min-content 50px;
|
||||
grid-template-areas:
|
||||
'title title'
|
||||
'icon value';
|
||||
|
||||
> * {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
grid-template-columns: 30px 1fr;
|
||||
grid-template-rows: min-content 40px;
|
||||
}
|
||||
`
|
||||
export const WidgetFieldTitle = styled.div`
|
||||
grid-area: title;
|
||||
justify-self: start;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
margin-bottom: 2px;
|
||||
font-weight: 500;
|
||||
`
|
||||
|
||||
export const WidgetFieldIcon = styled.div`
|
||||
${({ bgColor }) => css`
|
||||
grid-area: icon;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
background: ${bgColor};
|
||||
border-radius: 5px 0 0 5px;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
> svg {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`
|
||||
export const WidgetFieldValue = styled.div`
|
||||
${({ bgColor }) => css`
|
||||
grid-area: value;
|
||||
padding: 0 20px;
|
||||
color: #fff;
|
||||
font-size: 25px;
|
||||
background: ${bgColor};
|
||||
border-radius: 0 5px 5px 0;
|
||||
|
||||
@media (max-width: 800px) {
|
||||
font-size: 18px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export const LoadingProgress = styled.div.attrs(({ value, fullAmount }) => {
|
||||
const percentage = Math.min(100, (value * 100) / fullAmount)
|
||||
|
||||
return {
|
||||
// this block is here according to styled-components recomendation about fast changable components
|
||||
style: {
|
||||
background: `linear-gradient(to right, #b5dec9 0%, #b5dec9 ${percentage}%, #fff ${percentage}%, #fff 100%)`,
|
||||
},
|
||||
}
|
||||
})`
|
||||
${({ label }) => css`
|
||||
border: 1px solid;
|
||||
padding: 10px 20px;
|
||||
border-radius: 5px;
|
||||
|
||||
:before {
|
||||
content: '${label}';
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
`}
|
||||
`
|
||||
|
||||
export const Divider = styled.div`
|
||||
height: 1px;
|
||||
background-color: rgba(0, 0, 0, 0.12);
|
||||
margin: 30px 0;
|
||||
`
|
||||
101
web/src/components/DialogTorrentDetailsContent/widgets.jsx
Normal file
101
web/src/components/DialogTorrentDetailsContent/widgets.jsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
ArrowDownward as ArrowDownwardIcon,
|
||||
ArrowUpward as ArrowUpwardIcon,
|
||||
SwapVerticalCircle as SwapVerticalCircleIcon,
|
||||
ViewAgenda as ViewAgendaIcon,
|
||||
Widgets as WidgetsIcon,
|
||||
PhotoSizeSelectSmall as PhotoSizeSelectSmallIcon,
|
||||
Build as BuildIcon,
|
||||
} from '@material-ui/icons'
|
||||
import { getPeerString, humanizeSize } from 'utils/Utils'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import StatisticsField from './StatisticsField'
|
||||
|
||||
export const DownlodSpeedWidget = ({ data }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<StatisticsField
|
||||
title={t('DownloadSpeed')}
|
||||
value={humanizeSize(data) || '0 B'}
|
||||
iconBg='#118f00'
|
||||
valueBg='#13a300'
|
||||
icon={ArrowDownwardIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const UploadSpeedWidget = ({ data }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<StatisticsField
|
||||
title={t('UploadSpeed')}
|
||||
value={humanizeSize(data) || '0 B'}
|
||||
iconBg='#0146ad'
|
||||
valueBg='#0058db'
|
||||
icon={ArrowUpwardIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const PeersWidget = ({ data }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<StatisticsField
|
||||
title={t('Peers')}
|
||||
value={getPeerString(data) || '[0] 0 / 0'}
|
||||
iconBg='#cdc118'
|
||||
valueBg='#d8cb18'
|
||||
icon={SwapVerticalCircleIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const PiecesCountWidget = ({ data }) => {
|
||||
const { t } = useTranslation()
|
||||
return <StatisticsField title={t('PiecesCount')} value={data} iconBg='#b6c95e' valueBg='#c0d076' icon={WidgetsIcon} />
|
||||
}
|
||||
|
||||
export const PiecesLengthWidget = ({ data }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<StatisticsField
|
||||
title={t('PiecesLength')}
|
||||
value={humanizeSize(data)}
|
||||
iconBg='#0982c8'
|
||||
valueBg='#098cd7'
|
||||
icon={PhotoSizeSelectSmallIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatusWidget = ({ data }) => {
|
||||
const { t } = useTranslation()
|
||||
let i18nd = data
|
||||
if (data.toLowerCase() === 'torrent added')
|
||||
i18nd = t('TorrentAdded')
|
||||
else if (data.toLowerCase() === 'torrent getting info')
|
||||
i18nd = t('TorrentGettingInfo')
|
||||
else if (data.toLowerCase() === 'torrent preload')
|
||||
i18nd = t('TorrentPreload')
|
||||
else if (data.toLowerCase() === 'torrent working')
|
||||
i18nd = t('TorrentWorking')
|
||||
else if (data.toLowerCase() === 'torrent closed')
|
||||
i18nd = t('TorrentClosed')
|
||||
else if (data.toLowerCase() === 'torrent in db')
|
||||
i18nd = t('TorrentInDb')
|
||||
return <StatisticsField title={t('TorrentStatus')} value={i18nd} iconBg='#aea25b' valueBg='#b4aa6e' icon={BuildIcon} />
|
||||
}
|
||||
|
||||
export const SizeWidget = ({ data }) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<StatisticsField
|
||||
title={t('TorrentSize')}
|
||||
value={humanizeSize(data)}
|
||||
iconBg='#9b01ad'
|
||||
valueBg='#ac03bf'
|
||||
icon={ViewAgendaIcon}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,229 +0,0 @@
|
||||
import React, { useEffect } from 'react'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import { Button, ButtonGroup, Grid, List, ListItem } from '@material-ui/core'
|
||||
import CachedIcon from '@material-ui/icons/Cached'
|
||||
import LinearProgress from '@material-ui/core/LinearProgress';
|
||||
|
||||
import { getPeerString, humanizeSize } from '../utils/Utils'
|
||||
import { playlistTorrHost, streamHost, viewedHost } from '../utils/Hosts'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
|
||||
const style = {
|
||||
width100: {
|
||||
width: '100%',
|
||||
},
|
||||
width80: {
|
||||
width: '80%',
|
||||
},
|
||||
poster: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
borderRadius: '5px',
|
||||
},
|
||||
}
|
||||
|
||||
export default function DialogTorrentInfo(props) {
|
||||
const [torrent, setTorrent] = React.useState(props.torrent)
|
||||
const [viewed, setViewed] = React.useState(null)
|
||||
const [progress, setProgress] = React.useState(-1)
|
||||
|
||||
useEffect(() => {
|
||||
setTorrent(props.torrent)
|
||||
if(torrent.stat==2)
|
||||
setProgress(torrent.preloaded_bytes * 100 / torrent.preload_size)
|
||||
getViewed(props.torrent.hash,(list) => {
|
||||
if (list) {
|
||||
let lst = list.map((itm) => itm.file_index)
|
||||
setViewed(lst)
|
||||
}else
|
||||
setViewed(null)
|
||||
})
|
||||
}, [props.torrent, props.open])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
<Grid container spacing={1}>
|
||||
<Grid item>{torrent.poster && <img alt="" height="200" align="left" style={style.poster} src={torrent.poster} />}</Grid>
|
||||
<Grid style={style.width80} item>
|
||||
{torrent.title} {torrent.name && torrent.name !== torrent.title && ' | ' + torrent.name}
|
||||
<Typography>
|
||||
<b>Peers: </b> {getPeerString(torrent)}
|
||||
<br />
|
||||
<b>Loaded: </b> {getPreload(torrent)}
|
||||
<br />
|
||||
<b>Speed: </b> {humanizeSize(torrent.download_speed)}
|
||||
<br />
|
||||
<b>Status: </b> {torrent.stat_string}
|
||||
<br />
|
||||
</Typography>
|
||||
</Grid>
|
||||
</Grid>
|
||||
{torrent.stat==2 && <LinearProgress style={{marginTop:'10px'}} variant="determinate" value={progress} />}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ButtonGroup style={style.width100} variant="contained" color="primary" aria-label="contained primary button group">
|
||||
<Button style={style.width100} href={playlistTorrHost() + '/' + encodeURIComponent(torrent.name || torrent.title || 'file') + '.m3u?link=' + torrent.hash + '&m3u'}>
|
||||
Playlist
|
||||
</Button>
|
||||
<Button style={style.width100} href={playlistTorrHost() + '/' + encodeURIComponent(torrent.name || torrent.title || 'file') + '.m3u?link=' + torrent.hash + '&m3u&fromlast'}>
|
||||
Playlist after last view
|
||||
</Button>
|
||||
<Button style={style.width100} onClick={()=>{
|
||||
remViews(torrent.hash)
|
||||
setViewed(null)
|
||||
}} >
|
||||
Remove views
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ListItem>
|
||||
{getPlayableFile(torrent) &&
|
||||
getPlayableFile(torrent).map((file) => (
|
||||
<ButtonGroup style={style.width100} disableElevation variant="contained" color="primary">
|
||||
<Button
|
||||
style={style.width100}
|
||||
href={streamHost() + '/' + encodeURIComponent(file.path.split('\\').pop().split('/').pop()) + '?link=' + torrent.hash + '&index=' + file.id + '&play'}
|
||||
>
|
||||
<Typography>
|
||||
{file.path.split('\\').pop().split('/').pop()} | {humanizeSize(file.length)} {viewed && viewed.indexOf(file.id)!=-1 && "| ✓"}
|
||||
</Typography>
|
||||
</Button>
|
||||
<Button onClick={() => fetch(streamHost() + '?link=' + torrent.hash + '&index=' + file.id + '&preload')}>
|
||||
<CachedIcon />
|
||||
<Typography>Preload</Typography>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
))}
|
||||
</List>
|
||||
</DialogContent>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function remViews(hash){
|
||||
try {
|
||||
if (hash)
|
||||
fetch(viewedHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'rem', hash: hash, file_index:-1 }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function getViewed(hash, callback) {
|
||||
try {
|
||||
fetch(viewedHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'list', hash: hash }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(
|
||||
(json) => {
|
||||
callback(json)
|
||||
},
|
||||
(error) => {
|
||||
callback(null)
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayableFile(torrent){
|
||||
if (!torrent || !torrent.file_stats)
|
||||
return null
|
||||
return torrent.file_stats.filter(file => extPlayable.includes(getExt(file.path)))
|
||||
}
|
||||
|
||||
function getExt(filename){
|
||||
const ext = filename.split('.').pop()
|
||||
if (ext == filename)
|
||||
return ''
|
||||
return ext.toLowerCase()
|
||||
}
|
||||
|
||||
function getPreload(torrent) {
|
||||
if (torrent.preloaded_bytes > 0 && torrent.preload_size > 0 && torrent.preloaded_bytes < torrent.preload_size) {
|
||||
let progress = ((torrent.preloaded_bytes * 100) / torrent.preload_size).toFixed(2)
|
||||
return humanizeSize(torrent.preloaded_bytes) + ' / ' + humanizeSize(torrent.preload_size) + ' ' + progress + '%'
|
||||
}
|
||||
|
||||
if (!torrent.preloaded_bytes) return humanizeSize(0)
|
||||
|
||||
return humanizeSize(torrent.preloaded_bytes)
|
||||
}
|
||||
|
||||
const extPlayable = [
|
||||
// video
|
||||
"3g2",
|
||||
"3gp",
|
||||
"aaf",
|
||||
"asf",
|
||||
"avchd",
|
||||
"avi",
|
||||
"drc",
|
||||
"flv",
|
||||
"iso",
|
||||
"m2v",
|
||||
"m2ts",
|
||||
"m4p",
|
||||
"m4v",
|
||||
"mkv",
|
||||
"mng",
|
||||
"mov",
|
||||
"mp2",
|
||||
"mp4",
|
||||
"mpe",
|
||||
"mpeg",
|
||||
"mpg",
|
||||
"mpv",
|
||||
"mxf",
|
||||
"nsv",
|
||||
"ogg",
|
||||
"ogv",
|
||||
"ts",
|
||||
"qt",
|
||||
"rm",
|
||||
"rmvb",
|
||||
"roq",
|
||||
"svi",
|
||||
"vob",
|
||||
"webm",
|
||||
"wmv",
|
||||
"yuv",
|
||||
// audio
|
||||
"aac",
|
||||
"aiff",
|
||||
"ape",
|
||||
"au",
|
||||
"flac",
|
||||
"gsm",
|
||||
"it",
|
||||
"m3u",
|
||||
"m4a",
|
||||
"mid",
|
||||
"mod",
|
||||
"mp3",
|
||||
"mpa",
|
||||
"pls",
|
||||
"ra",
|
||||
"s3m",
|
||||
"sid",
|
||||
"wav",
|
||||
"wma",
|
||||
"xm"
|
||||
]
|
||||
@@ -1,82 +0,0 @@
|
||||
import React from 'react'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import Snackbar from '@material-ui/core/Snackbar'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import CreditCardIcon from '@material-ui/icons/CreditCard'
|
||||
import List from '@material-ui/core/List'
|
||||
import ButtonGroup from '@material-ui/core/ButtonGroup'
|
||||
|
||||
const donateFrame =
|
||||
'<iframe src="https://yoomoney.ru/quickpay/shop-widget?writer=seller&targets=TorrServer Donate&targets-hint=&default-sum=200&button-text=14&payment-type-choice=on&mobile-payment-type-choice=on&comment=on&hint=&successURL=&quickpay=shop&account=410013733697114" width="100%" height="302" frameborder="0" allowtransparency="true" scrolling="no"></iframe>'
|
||||
|
||||
export default function DonateDialog() {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [snakeOpen, setSnakeOpen] = React.useState(true)
|
||||
|
||||
const handleClickOpen = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItem button key="Donate" onClick={handleClickOpen}>
|
||||
<ListItemIcon>
|
||||
<CreditCardIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Donate" />
|
||||
</ListItem>
|
||||
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title" fullWidth>
|
||||
<DialogTitle id="form-dialog-title">Donate</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ButtonGroup variant="outlined" color="primary" aria-label="contained primary button group">
|
||||
<Button onClick={() => window.open('https://www.paypal.com/paypalme/yourok', '_blank')}>PayPal</Button>
|
||||
<Button onClick={() => window.open('https://yoomoney.ru/to/410013733697114', '_blank')}>Yandex.Money</Button>
|
||||
</ButtonGroup>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<div dangerouslySetInnerHTML={{ __html: donateFrame }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color="primary" variant="outlined">
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={snakeOpen}
|
||||
onClose={()=>{setSnakeOpen(false)}}
|
||||
autoHideDuration={6000}
|
||||
message="Donate?"
|
||||
action={
|
||||
<React.Fragment>
|
||||
<IconButton size="small" aria-label="close" color="inherit" onClick={()=>{
|
||||
setSnakeOpen(false)
|
||||
setOpen(true)
|
||||
}}>
|
||||
<CreditCardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</React.Fragment>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
42
web/src/components/Donate/DonateDialog.jsx
Normal file
42
web/src/components/Donate/DonateDialog.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import List from '@material-ui/core/List'
|
||||
import ButtonGroup from '@material-ui/core/ButtonGroup'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const donateFrame =
|
||||
'<iframe src="https://yoomoney.ru/quickpay/shop-widget?writer=seller&targets=TorrServer Donate&targets-hint=&default-sum=200&button-text=14&payment-type-choice=on&mobile-payment-type-choice=on&comment=on&hint=&successURL=&quickpay=shop&account=410013733697114" width="320" height="320" frameborder="0" allowtransparency="true" scrolling="no"></iframe>'
|
||||
|
||||
export default function DonateDialog({ onClose }) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Dialog open onClose={onClose} aria-labelledby='form-dialog-title' fullWidth>
|
||||
<DialogTitle id='form-dialog-title'>{t('Donate')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<List>
|
||||
<ListItem key='DonateLinks'>
|
||||
<ButtonGroup variant='outlined' color='primary' aria-label='contained primary button group'>
|
||||
<Button onClick={() => window.open('https://www.paypal.com/paypalme/yourok', '_blank')}>PayPal</Button>
|
||||
<Button onClick={() => window.open('https://yoomoney.ru/to/410013733697114', '_blank')}>IO.Money</Button>
|
||||
</ButtonGroup>
|
||||
</ListItem>
|
||||
<ListItem key='DonateForm'>
|
||||
{/* eslint-disable-next-line react/no-danger */}
|
||||
<div dangerouslySetInnerHTML={{ __html: donateFrame }} />
|
||||
</ListItem>
|
||||
</List>
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} color='primary' variant='outlined'>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
56
web/src/components/Donate/index.jsx
Normal file
56
web/src/components/Donate/index.jsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import Snackbar from '@material-ui/core/Snackbar'
|
||||
import IconButton from '@material-ui/core/IconButton'
|
||||
import CreditCardIcon from '@material-ui/icons/CreditCard'
|
||||
import CloseIcon from '@material-ui/icons/Close'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import DonateDialog from './DonateDialog'
|
||||
|
||||
export default function DonateSnackbar() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [snackbarOpen, setSnackbarOpen] = useState(true)
|
||||
|
||||
const disableSnackbar = () => {
|
||||
setSnackbarOpen(false)
|
||||
localStorage.setItem('snackbarIsClosed', true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <DonateDialog onClose={() => setOpen(false)} />}
|
||||
|
||||
<Snackbar
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'center',
|
||||
}}
|
||||
open={snackbarOpen}
|
||||
onClose={disableSnackbar}
|
||||
message={t('Donate?')}
|
||||
action={
|
||||
<>
|
||||
<Button
|
||||
style={{ marginRight: '10px' }}
|
||||
color='secondary'
|
||||
size='small'
|
||||
onClick={() => {
|
||||
setOpen(true)
|
||||
disableSnackbar()
|
||||
}}
|
||||
>
|
||||
<CreditCardIcon style={{ marginRight: '10px' }} fontSize='small' />
|
||||
{t('Support')}
|
||||
</Button>
|
||||
|
||||
<IconButton size='small' aria-label='close' color='inherit' onClick={disableSnackbar}>
|
||||
<CloseIcon fontSize='small' />
|
||||
</IconButton>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
import { torrentsHost } from '../utils/Hosts'
|
||||
|
||||
export default function RemoveAll() {
|
||||
const fnRemoveAll = () => {
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'list' }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then((json) => {
|
||||
json.forEach((torr) => {
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'rem', hash: torr.hash }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
return (
|
||||
<ListItem button key="Remove all" onClick={fnRemoveAll}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Remove all" />
|
||||
</ListItem>
|
||||
)
|
||||
}
|
||||
71
web/src/components/RemoveAll.jsx
Normal file
71
web/src/components/RemoveAll.jsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Button, Dialog, DialogActions, DialogTitle } from '@material-ui/core'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
import { useState } from 'react'
|
||||
import { torrentsHost } from 'utils/Hosts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const fnRemoveAll = () => {
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'list' }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(json => {
|
||||
json.forEach(torr => {
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'rem', hash: torr.hash }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default function RemoveAll() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const closeDialog = () => setOpen(false)
|
||||
const openDialog = () => setOpen(true)
|
||||
return (
|
||||
<>
|
||||
<ListItem button key={t('RemoveAll')} onClick={openDialog}>
|
||||
<ListItemIcon>
|
||||
<DeleteIcon />
|
||||
</ListItemIcon>
|
||||
|
||||
<ListItemText primary={t('RemoveAll')} />
|
||||
</ListItem>
|
||||
|
||||
<Dialog open={open} onClose={closeDialog}>
|
||||
<DialogTitle>{t('DeleteTorrents?')}</DialogTitle>
|
||||
<DialogActions>
|
||||
<Button variant='outlined' onClick={closeDialog} color='primary'>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={() => {
|
||||
fnRemoveAll()
|
||||
closeDialog()
|
||||
}}
|
||||
color='primary'
|
||||
autoFocus
|
||||
>
|
||||
{t('OK')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import React, { useEffect } from 'react'
|
||||
import SettingsIcon from '@material-ui/icons/Settings'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import TextField from '@material-ui/core/TextField'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { FormControlLabel, InputLabel, Select, Switch } from '@material-ui/core'
|
||||
import { settingsHost, setTorrServerHost, torrserverHost } from '../utils/Hosts'
|
||||
|
||||
export default function SettingsDialog() {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [settings, setSets] = React.useState({})
|
||||
const [show, setShow] = React.useState(false)
|
||||
const [tsHost, setTSHost] = React.useState(torrserverHost ? torrserverHost : window.location.protocol + '//' + window.location.hostname + (window.location.port ? ':' + window.location.port : ''))
|
||||
|
||||
const handleClickOpen = () => {
|
||||
setOpen(true)
|
||||
}
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
const handleCloseSave = () => {
|
||||
setOpen(false)
|
||||
let sets = JSON.parse(JSON.stringify(settings))
|
||||
sets.CacheSize *= 1024 * 1024
|
||||
sets.PreloadBufferSize *= 1024 * 1024
|
||||
fetch(settingsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'set', sets: sets }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch(settingsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'get' }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(
|
||||
(json) => {
|
||||
json.CacheSize /= 1024 * 1024
|
||||
json.PreloadBufferSize /= 1024 * 1024
|
||||
setSets(json)
|
||||
setShow(true)
|
||||
},
|
||||
(error) => {
|
||||
setShow(false)
|
||||
console.log(error)
|
||||
}
|
||||
)
|
||||
.catch((e) => {
|
||||
setShow(false)
|
||||
console.log(e)
|
||||
})
|
||||
}, [tsHost])
|
||||
|
||||
const onInputHost = (event) => {
|
||||
let host = event.target.value
|
||||
setTorrServerHost(host)
|
||||
setTSHost(host)
|
||||
}
|
||||
|
||||
const inputForm = (event) => {
|
||||
let sets = JSON.parse(JSON.stringify(settings))
|
||||
if (event.target.type === 'number' || event.target.type === 'select-one') {
|
||||
sets[event.target.id] = Number(event.target.value)
|
||||
} else if (event.target.type === 'checkbox') {
|
||||
sets[event.target.id] = Boolean(event.target.checked)
|
||||
} else if (event.target.type === 'url') {
|
||||
sets[event.target.id] = event.target.value
|
||||
}
|
||||
setSets(sets)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItem button key="Settings" onClick={handleClickOpen}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Settings" />
|
||||
</ListItem>
|
||||
<Dialog open={open} onClose={handleClose} aria-labelledby="form-dialog-title" fullWidth={true}>
|
||||
<DialogTitle id="form-dialog-title">Settings</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField onChange={onInputHost} margin="dense" id="TorrServerHost" label="Host" value={tsHost} type="url" fullWidth />
|
||||
{show && (
|
||||
<>
|
||||
<TextField onChange={inputForm} margin="dense" id="CacheSize" label="Cache size" value={settings.CacheSize} type="number" fullWidth />
|
||||
<FormControlLabel control={<Switch checked={settings.PreloadBuffer} onChange={inputForm} id="PreloadBuffer" color="primary" />} label="Preload buffer" />
|
||||
<TextField onChange={inputForm} margin="dense" id="ReaderReadAHead" label="Reader readahead" value={settings.ReaderReadAHead} type="number" fullWidth />
|
||||
<h1 />
|
||||
<InputLabel htmlFor="RetrackersMode">Retracker mode</InputLabel>
|
||||
<Select onChange={inputForm} type="number" native="true" id="RetrackersMode" value={settings.RetrackersMode}>
|
||||
<option value={0}>Don't add retrackers</option>
|
||||
<option value={1}>Add retrackers</option>
|
||||
<option value={2}>Remove retrackers</option>
|
||||
<option value={3}>Replace retrackers</option>
|
||||
</Select>
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin="dense"
|
||||
id="TorrentDisconnectTimeout"
|
||||
label="Torrent disconnect timeout"
|
||||
value={settings.TorrentDisconnectTimeout}
|
||||
type="number"
|
||||
fullWidth
|
||||
/>
|
||||
<FormControlLabel control={<Switch checked={settings.EnableIPv6} onChange={inputForm} id="EnableIPv6" color="primary" />} label="Enable IPv6" />
|
||||
<br />
|
||||
<FormControlLabel control={<Switch checked={settings.ForceEncrypt} onChange={inputForm} id="ForceEncrypt" color="primary" />} label="Force encrypt" />
|
||||
<br />
|
||||
<FormControlLabel control={<Switch checked={settings.DisableTCP} onChange={inputForm} id="DisableTCP" color="primary" />} label="Disable TCP" />
|
||||
<br />
|
||||
<FormControlLabel control={<Switch checked={settings.DisableUTP} onChange={inputForm} id="DisableUTP" color="primary" />} label="Disable UTP" />
|
||||
<br />
|
||||
<FormControlLabel control={<Switch checked={settings.DisableUPNP} onChange={inputForm} id="DisableUPNP" color="primary" />} label="Disable UPNP" />
|
||||
<br />
|
||||
<FormControlLabel control={<Switch checked={settings.DisableDHT} onChange={inputForm} id="DisableDHT" color="primary" />} label="Disable DHT" />
|
||||
<br />
|
||||
<FormControlLabel control={<Switch checked={settings.DisablePEX} onChange={inputForm} id="DisablePEX" color="primary" />} label="Disable PEX" />
|
||||
<br />
|
||||
<FormControlLabel control={<Switch checked={settings.DisableUpload} onChange={inputForm} id="DisableUpload" color="primary" />} label="Disable upload" />
|
||||
<br />
|
||||
<TextField onChange={inputForm} margin="dense" id="DownloadRateLimit" label="Download rate limit" value={settings.DownloadRateLimit} type="number" fullWidth />
|
||||
<TextField onChange={inputForm} margin="dense" id="UploadRateLimit" label="Upload rate limit" value={settings.UploadRateLimit} type="number" fullWidth />
|
||||
<TextField onChange={inputForm} margin="dense" id="ConnectionsLimit" label="Connections limit" value={settings.ConnectionsLimit} type="number" fullWidth />
|
||||
<TextField onChange={inputForm} margin="dense" id="DhtConnectionLimit" label="Dht connection limit" value={settings.DhtConnectionLimit} type="number" fullWidth />
|
||||
<TextField onChange={inputForm} margin="dense" id="PeersListenPort" label="Peers listen port" value={settings.PeersListenPort} type="number" fullWidth />
|
||||
<br />
|
||||
<FormControlLabel control={<Switch checked={settings.UseDisk} onChange={inputForm} id="UseDisk" color="primary" />} label="Use disk" />
|
||||
<br />
|
||||
<TextField onChange={inputForm} margin="dense" id="TorrentsSavePath" label="Torrents save path" value={settings.TorrentsSavePath} type="url" fullWidth />
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color="primary" variant="outlined">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCloseSave} color="primary" variant="outlined">
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
292
web/src/components/Settings.jsx
Normal file
292
web/src/components/Settings.jsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import axios from 'axios'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import { useEffect, useState } from 'react'
|
||||
import SettingsIcon from '@material-ui/icons/Settings'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import DialogTitle from '@material-ui/core/DialogTitle'
|
||||
import DialogContent from '@material-ui/core/DialogContent'
|
||||
import TextField from '@material-ui/core/TextField'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import Button from '@material-ui/core/Button'
|
||||
import { FormControlLabel, InputLabel, Select, Switch } from '@material-ui/core'
|
||||
import { settingsHost, setTorrServerHost, getTorrServerHost } from 'utils/Hosts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function SettingsDialog() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [settings, setSets] = useState({})
|
||||
const [show, setShow] = useState(false)
|
||||
const [tsHost, setTSHost] = useState(getTorrServerHost())
|
||||
|
||||
const handleClickOpen = () => setOpen(true)
|
||||
const handleClose = () => setOpen(false)
|
||||
const handleSave = () => {
|
||||
setOpen(false)
|
||||
const sets = JSON.parse(JSON.stringify(settings))
|
||||
sets.CacheSize *= 1024 * 1024
|
||||
axios.post(settingsHost(), { action: 'set', sets })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
axios
|
||||
.post(settingsHost(), { action: 'get' })
|
||||
.then(({ data }) => {
|
||||
setSets({ ...data, CacheSize: data.CacheSize / (1024 * 1024) })
|
||||
setShow(true)
|
||||
})
|
||||
.catch(() => setShow(false))
|
||||
}, [tsHost])
|
||||
|
||||
const onInputHost = event => {
|
||||
const host = event.target.value
|
||||
setTorrServerHost(host)
|
||||
setTSHost(host)
|
||||
}
|
||||
|
||||
const inputForm = ({ target: { type, value, checked, id } }) => {
|
||||
const sets = JSON.parse(JSON.stringify(settings))
|
||||
if (type === 'number' || type === 'select-one') {
|
||||
sets[id] = Number(value)
|
||||
} else if (type === 'checkbox') {
|
||||
if (
|
||||
id === 'DisableTCP' ||
|
||||
id === 'DisableUTP' ||
|
||||
id === 'DisableUPNP' ||
|
||||
id === 'DisableDHT' ||
|
||||
id === 'DisablePEX' ||
|
||||
id === 'DisableUpload'
|
||||
)
|
||||
sets[id] = Boolean(!checked)
|
||||
else sets[id] = Boolean(checked)
|
||||
} else if (type === 'url') {
|
||||
sets[id] = value
|
||||
}
|
||||
setSets(sets)
|
||||
}
|
||||
|
||||
const {
|
||||
CacheSize,
|
||||
PreloadBuffer,
|
||||
ReaderReadAHead,
|
||||
RetrackersMode,
|
||||
TorrentDisconnectTimeout,
|
||||
EnableIPv6,
|
||||
ForceEncrypt,
|
||||
DisableTCP,
|
||||
DisableUTP,
|
||||
DisableUPNP,
|
||||
DisableDHT,
|
||||
DisablePEX,
|
||||
DisableUpload,
|
||||
DownloadRateLimit,
|
||||
UploadRateLimit,
|
||||
ConnectionsLimit,
|
||||
DhtConnectionLimit,
|
||||
PeersListenPort,
|
||||
UseDisk,
|
||||
TorrentsSavePath,
|
||||
RemoveCacheOnDrop,
|
||||
} = settings
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItem button key={t('Settings')} onClick={handleClickOpen}>
|
||||
<ListItemIcon>
|
||||
<SettingsIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary={t('Settings')} />
|
||||
</ListItem>
|
||||
|
||||
<Dialog open={open} onClose={handleClose} aria-labelledby='form-dialog-title' fullWidth>
|
||||
<DialogTitle id='form-dialog-title'>{t('Settings')}</DialogTitle>
|
||||
<DialogContent>
|
||||
<TextField
|
||||
onChange={onInputHost}
|
||||
margin='dense'
|
||||
id='TorrServerHost'
|
||||
label={t('Host')}
|
||||
value={tsHost}
|
||||
type='url'
|
||||
fullWidth
|
||||
/>
|
||||
{show && (
|
||||
<>
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='CacheSize'
|
||||
label={t('CacheSize')}
|
||||
value={CacheSize}
|
||||
type='number'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='ReaderReadAHead'
|
||||
label={t('ReaderReadAHead')}
|
||||
value={ReaderReadAHead}
|
||||
type='number'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={PreloadBuffer} onChange={inputForm} id='PreloadBuffer' color='primary' />}
|
||||
label={t('PreloadBuffer')}
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={UseDisk} onChange={inputForm} id='UseDisk' color='primary' />}
|
||||
label={t('UseDisk')}
|
||||
/>
|
||||
<br />
|
||||
<small>{t('UseDiskDesc')}</small>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={RemoveCacheOnDrop} onChange={inputForm} id='RemoveCacheOnDrop' color='primary' />
|
||||
}
|
||||
label={t('RemoveCacheOnDrop')}
|
||||
/>
|
||||
<br />
|
||||
<small>{t('RemoveCacheOnDropDesc')}</small>
|
||||
<br />
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='TorrentsSavePath'
|
||||
label={t('TorrentsSavePath')}
|
||||
value={TorrentsSavePath}
|
||||
type='url'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={EnableIPv6} onChange={inputForm} id='EnableIPv6' color='primary' />}
|
||||
label={t('EnableIPv6')}
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!DisableTCP} onChange={inputForm} id='DisableTCP' color='primary' />}
|
||||
label={t('TCP')}
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!DisableUTP} onChange={inputForm} id='DisableUTP' color='primary' />}
|
||||
label={t('UTP')}
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!DisablePEX} onChange={inputForm} id='DisablePEX' color='primary' />}
|
||||
label={t('PEX')}
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={ForceEncrypt} onChange={inputForm} id='ForceEncrypt' color='primary' />}
|
||||
label={t('ForceEncrypt')}
|
||||
/>
|
||||
<br />
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='TorrentDisconnectTimeout'
|
||||
label={t('TorrentDisconnectTimeout')}
|
||||
value={TorrentDisconnectTimeout}
|
||||
type='number'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='ConnectionsLimit'
|
||||
label={t('ConnectionsLimit')}
|
||||
value={ConnectionsLimit}
|
||||
type='number'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!DisableDHT} onChange={inputForm} id='DisableDHT' color='primary' />}
|
||||
label={t('DHT')}
|
||||
/>
|
||||
<br />
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='DhtConnectionLimit'
|
||||
label={t('DhtConnectionLimit')}
|
||||
value={DhtConnectionLimit}
|
||||
type='number'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='DownloadRateLimit'
|
||||
label={t('DownloadRateLimit')}
|
||||
value={DownloadRateLimit}
|
||||
type='number'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!DisableUpload} onChange={inputForm} id='DisableUpload' color='primary' />}
|
||||
label={t('Upload')}
|
||||
/>
|
||||
<br />
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='UploadRateLimit'
|
||||
label={t('UploadRateLimit')}
|
||||
value={UploadRateLimit}
|
||||
type='number'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<TextField
|
||||
onChange={inputForm}
|
||||
margin='dense'
|
||||
id='PeersListenPort'
|
||||
label={t('PeersListenPort')}
|
||||
value={PeersListenPort}
|
||||
type='number'
|
||||
fullWidth
|
||||
/>
|
||||
<br />
|
||||
<FormControlLabel
|
||||
control={<Switch checked={!DisableUPNP} onChange={inputForm} id='DisableUPNP' color='primary' />}
|
||||
label={t('UPNP')}
|
||||
/>
|
||||
<br />
|
||||
<InputLabel htmlFor='RetrackersMode'>{t('RetrackersMode')}</InputLabel>
|
||||
<Select onChange={inputForm} type='number' native id='RetrackersMode' value={RetrackersMode}>
|
||||
<option value={0}>{t('DontAddRetrackers')}</option>
|
||||
<option value={1}>{t('AddRetrackers')}</option>
|
||||
<option value={2}>{t('RemoveRetrackers')}</option>
|
||||
<option value={3}>{t('ReplaceRetrackers')}</option>
|
||||
</Select>
|
||||
<br />
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions>
|
||||
<Button onClick={handleClose} color='primary' variant='outlined'>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button onClick={handleSave} color='primary' variant='outlined'>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import ButtonGroup from '@material-ui/core/ButtonGroup'
|
||||
import Button from '@material-ui/core/Button'
|
||||
|
||||
import 'fontsource-roboto'
|
||||
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import DeleteIcon from '@material-ui/icons/Delete'
|
||||
import Typography from '@material-ui/core/Typography'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import DialogActions from '@material-ui/core/DialogActions'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
|
||||
import { getPeerString, humanizeSize } from '../utils/Utils'
|
||||
|
||||
import DialogTorrentInfo from './DialogTorrentInfo'
|
||||
import { torrentsHost } from '../utils/Hosts'
|
||||
import DialogCacheInfo from './DialogCacheInfo'
|
||||
import DataUsageIcon from '@material-ui/icons/DataUsage'
|
||||
|
||||
export default function Torrent(props) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [showCache, setShowCache] = React.useState(false)
|
||||
const [torrent, setTorrent] = React.useState(props.torrent)
|
||||
const timerID = useRef(-1)
|
||||
|
||||
useEffect(() => {
|
||||
setTorrent(props.torrent)
|
||||
}, [props.torrent])
|
||||
|
||||
useEffect(() => {
|
||||
if (open)
|
||||
timerID.current = setInterval(() => {
|
||||
getTorrent(torrent.hash, (torr, error) => {
|
||||
if (error) console.error(error)
|
||||
else if (torr) setTorrent(torr)
|
||||
})
|
||||
}, 1000)
|
||||
else clearInterval(timerID.current)
|
||||
|
||||
return () => {
|
||||
clearInterval(timerID.current)
|
||||
}
|
||||
}, [torrent.hash, open])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ListItem>
|
||||
<ButtonGroup style={{width:'100%',boxShadow:'2px 2px 2px gray'}} disableElevation variant="contained" color="primary">
|
||||
<Button
|
||||
style={{width: '100%', justifyContent:'start'}}
|
||||
onClick={() => {
|
||||
setShowCache(false)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
{torrent.poster &&
|
||||
<img src={torrent.poster} alt="" align="left" style={{width: 'auto',height:'100px',margin:'0 10px 0 0',borderRadius:'5px'}}/>
|
||||
}
|
||||
<Typography>
|
||||
{torrent.title ? torrent.title : torrent.name}
|
||||
{torrent.torrent_size > 0 ? ' | ' + humanizeSize(torrent.torrent_size) : ''}
|
||||
{torrent.download_speed > 0 ? ' | ' + humanizeSize(torrent.download_speed) + '/sec' : ''}
|
||||
{getPeerString(torrent) ? ' | ' + getPeerString(torrent) : '' }
|
||||
</Typography>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowCache(true)
|
||||
setOpen(true)
|
||||
}}
|
||||
>
|
||||
<DataUsageIcon />
|
||||
<Typography>Cache</Typography>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
dropTorrent(torrent)
|
||||
}}
|
||||
>
|
||||
<CloseIcon />
|
||||
<Typography>Drop</Typography>
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
deleteTorrent(torrent)
|
||||
}}
|
||||
>
|
||||
<DeleteIcon />
|
||||
<Typography>Delete</Typography>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ListItem>
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
aria-labelledby="form-dialog-title"
|
||||
fullWidth={true}
|
||||
maxWidth={'lg'}
|
||||
>
|
||||
{!showCache ? <DialogTorrentInfo torrent={(open, torrent)} /> : <DialogCacheInfo hash={(open, torrent.hash)} />}
|
||||
<DialogActions>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="primary"
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getTorrent(hash, callback) {
|
||||
try {
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'get', hash: hash }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(
|
||||
(json) => {
|
||||
callback(json, null)
|
||||
},
|
||||
(error) => {
|
||||
callback(null, error)
|
||||
}
|
||||
)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function deleteTorrent(torrent) {
|
||||
try {
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({
|
||||
action: 'rem',
|
||||
hash: torrent.hash,
|
||||
}),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
function dropTorrent(torrent) {
|
||||
try {
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({
|
||||
action: 'drop',
|
||||
hash: torrent.hash,
|
||||
}),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
123
web/src/components/TorrentCard/index.jsx
Normal file
123
web/src/components/TorrentCard/index.jsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import 'fontsource-roboto'
|
||||
import { forwardRef, useState } from 'react'
|
||||
import { UnfoldMore as UnfoldMoreIcon, Close as CloseIcon, Delete as DeleteIcon } from '@material-ui/icons'
|
||||
import { getPeerString, humanizeSize, shortenText } from 'utils/Utils'
|
||||
import { torrentsHost } from 'utils/Hosts'
|
||||
import { NoImageIcon } from 'icons'
|
||||
import DialogTorrentDetailsContent from 'components/DialogTorrentDetailsContent'
|
||||
import Dialog from '@material-ui/core/Dialog'
|
||||
import Slide from '@material-ui/core/Slide'
|
||||
import { Button, DialogActions, DialogTitle, useMediaQuery, useTheme } from '@material-ui/core'
|
||||
import axios from 'axios'
|
||||
import ptt from 'parse-torrent-title'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { StyledButton, TorrentCard, TorrentCardButtons, TorrentCardDescription, TorrentCardPoster } from './style'
|
||||
|
||||
const Transition = forwardRef((props, ref) => <Slide direction='up' ref={ref} {...props} />)
|
||||
|
||||
export default function Torrent({ torrent }) {
|
||||
const { t } = useTranslation()
|
||||
const [isDetailedInfoOpened, setIsDetailedInfoOpened] = useState(false)
|
||||
const [isDeleteTorrentOpened, setIsDeleteTorrentOpened] = useState(false)
|
||||
|
||||
const theme = useTheme()
|
||||
const fullScreen = useMediaQuery(theme.breakpoints.down('md'))
|
||||
|
||||
const openDetailedInfo = () => setIsDetailedInfoOpened(true)
|
||||
const closeDetailedInfo = () => setIsDetailedInfoOpened(false)
|
||||
const openDeleteTorrentAlert = () => setIsDeleteTorrentOpened(true)
|
||||
const closeDeleteTorrentAlert = () => setIsDeleteTorrentOpened(false)
|
||||
|
||||
const { title, name, poster, torrent_size: torrentSize, download_speed: downloadSpeed, hash } = torrent
|
||||
|
||||
const dropTorrent = () => axios.post(torrentsHost(), { action: 'drop', hash })
|
||||
const deleteTorrent = () => axios.post(torrentsHost(), { action: 'rem', hash })
|
||||
|
||||
const parsedTitle = (title || name) && ptt.parse(title || name).title
|
||||
|
||||
return (
|
||||
<>
|
||||
<TorrentCard>
|
||||
<TorrentCardPoster isPoster={poster}>
|
||||
{poster ? <img src={poster} alt='poster' /> : <NoImageIcon />}
|
||||
</TorrentCardPoster>
|
||||
|
||||
<TorrentCardButtons>
|
||||
<StyledButton onClick={openDetailedInfo}>
|
||||
<UnfoldMoreIcon />
|
||||
<span>{t('Details')}</span>
|
||||
</StyledButton>
|
||||
|
||||
<StyledButton onClick={() => dropTorrent(torrent)}>
|
||||
<CloseIcon />
|
||||
<span>{t('Drop')}</span>
|
||||
</StyledButton>
|
||||
|
||||
<StyledButton onClick={openDeleteTorrentAlert}>
|
||||
<DeleteIcon />
|
||||
<span>{t('Delete')}</span>
|
||||
</StyledButton>
|
||||
</TorrentCardButtons>
|
||||
|
||||
<TorrentCardDescription>
|
||||
<div className='description-title-wrapper'>
|
||||
<div className='description-section-name'>{t('Name')}</div>
|
||||
<div className='description-torrent-title'>{shortenText(parsedTitle, 100)}</div>
|
||||
</div>
|
||||
|
||||
<div className='description-statistics-wrapper'>
|
||||
<div className='description-statistics-element-wrapper'>
|
||||
<div className='description-section-name'>{t('Size')}</div>
|
||||
<div className='description-statistics-element-value'>{torrentSize > 0 && humanizeSize(torrentSize)}</div>
|
||||
</div>
|
||||
|
||||
<div className='description-statistics-element-wrapper'>
|
||||
<div className='description-section-name'>{t('Speed')}</div>
|
||||
<div className='description-statistics-element-value'>
|
||||
{downloadSpeed > 0 ? humanizeSize(downloadSpeed) : '---'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='description-statistics-element-wrapper'>
|
||||
<div className='description-section-name'>{t('Peers')}</div>
|
||||
<div className='description-statistics-element-value'>{getPeerString(torrent) || '---'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</TorrentCardDescription>
|
||||
</TorrentCard>
|
||||
|
||||
<Dialog
|
||||
open={isDetailedInfoOpened}
|
||||
onClose={closeDetailedInfo}
|
||||
fullScreen={fullScreen}
|
||||
fullWidth
|
||||
maxWidth='xl'
|
||||
TransitionComponent={Transition}
|
||||
>
|
||||
<DialogTorrentDetailsContent closeDialog={closeDetailedInfo} torrent={torrent} />
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={isDeleteTorrentOpened} onClose={closeDeleteTorrentAlert}>
|
||||
<DialogTitle>{t('DeleteTorrent?')}</DialogTitle>
|
||||
<DialogActions>
|
||||
<Button variant='outlined' onClick={closeDeleteTorrentAlert} color='primary'>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='contained'
|
||||
onClick={() => {
|
||||
deleteTorrent(torrent)
|
||||
closeDeleteTorrentAlert()
|
||||
}}
|
||||
color='primary'
|
||||
autoFocus
|
||||
>
|
||||
{t('OK')}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
206
web/src/components/TorrentCard/style.js
Normal file
206
web/src/components/TorrentCard/style.js
Normal file
@@ -0,0 +1,206 @@
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
export const TorrentCard = styled.div`
|
||||
border-radius: 5px;
|
||||
display: grid;
|
||||
grid-template-columns: 120px 260px 1fr;
|
||||
grid-template-rows: 180px;
|
||||
grid-template-areas: 'poster description buttons';
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: #00a572;
|
||||
box-shadow: 0px 2px 4px -1px rgb(0 0 0 / 20%), 0px 4px 5px 0px rgb(0 0 0 / 14%), 0px 1px 10px 0px rgb(0 0 0 / 12%);
|
||||
|
||||
@media (max-width: 1260px), (max-height: 500px) {
|
||||
grid-template-areas:
|
||||
'poster description'
|
||||
'buttons buttons';
|
||||
|
||||
grid-template-columns: 70px 1fr;
|
||||
grid-template-rows: 110px max-content;
|
||||
}
|
||||
|
||||
@media (max-width: 770px) {
|
||||
grid-template-columns: 60px 1fr;
|
||||
grid-template-rows: 90px max-content;
|
||||
}
|
||||
`
|
||||
|
||||
export const TorrentCardPoster = styled.div`
|
||||
grid-area: poster;
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
|
||||
${({ isPoster }) =>
|
||||
isPoster
|
||||
? css`
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 5px;
|
||||
}
|
||||
`
|
||||
: css`
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #74c39c;
|
||||
border: 1px solid #337a57;
|
||||
|
||||
svg {
|
||||
transform: translateY(-3px);
|
||||
}
|
||||
`};
|
||||
|
||||
@media (max-width: 1260px), (max-height: 500px) {
|
||||
svg {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const TorrentCardButtons = styled.div`
|
||||
grid-area: buttons;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@media (max-width: 1260px), (max-height: 500px) {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
`
|
||||
export const TorrentCardDescription = styled.div`
|
||||
grid-area: description;
|
||||
background: #74c39c;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
display: grid;
|
||||
grid-template-rows: 55% 1fr;
|
||||
gap: 10px;
|
||||
|
||||
@media (max-width: 770px) {
|
||||
grid-template-rows: 60% 1fr;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 770px) {
|
||||
grid-template-rows: 56% 1fr;
|
||||
}
|
||||
|
||||
.description-title-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.description-section-name {
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.4px;
|
||||
color: #216e47;
|
||||
|
||||
@media (max-width: 770px) {
|
||||
font-size: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.description-torrent-title {
|
||||
overflow: auto;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.description-statistics-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 80px 1fr;
|
||||
align-self: end;
|
||||
|
||||
@media (max-width: 1260px), (max-height: 500px) {
|
||||
grid-template-columns: 70px 70px 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 770px) {
|
||||
grid-template-columns: 65px 65px 1fr;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.description-statistics-element-wrapper {
|
||||
}
|
||||
|
||||
.description-statistics-element-value {
|
||||
margin-left: 5px;
|
||||
margin-bottom: 10px;
|
||||
word-break: break-all;
|
||||
|
||||
@media (max-width: 1260px), (max-height: 500px) {
|
||||
font-size: 0.7rem;
|
||||
margin-bottom: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.description-torrent-title,
|
||||
.description-statistics-element-value {
|
||||
@media (max-width: 770px) {
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
@media (max-width: 410px) {
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export const StyledButton = styled.button`
|
||||
border-radius: 5px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
background: #268757;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-family: 'Roboto', 'Helvetica', 'Arial', sans-serif;
|
||||
letter-spacing: 0.009em;
|
||||
padding: 10px 20px;
|
||||
|
||||
:hover {
|
||||
background: #2a7e54;
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@media (max-width: 1260px), (max-height: 500px) {
|
||||
padding: 5px 10px;
|
||||
font-size: 0.8rem;
|
||||
|
||||
svg {
|
||||
width: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 770px) {
|
||||
font-size: 0.7rem;
|
||||
|
||||
svg {
|
||||
width: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
padding: 7px 10px;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -1,52 +0,0 @@
|
||||
import React, { useEffect, useRef } from 'react'
|
||||
import Container from '@material-ui/core/Container'
|
||||
import Torrent from './Torrent'
|
||||
import List from '@material-ui/core/List'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { torrentsHost } from '../utils/Hosts'
|
||||
|
||||
export default function TorrentList(props, onChange) {
|
||||
const [torrents, setTorrents] = React.useState([])
|
||||
const [offline, setOffline] = React.useState(true)
|
||||
const timerID = useRef(-1)
|
||||
|
||||
useEffect(() => {
|
||||
timerID.current = setInterval(() => {
|
||||
getTorrentList((torrs) => {
|
||||
if (torrs) setOffline(false)
|
||||
else setOffline(true)
|
||||
setTorrents(torrs)
|
||||
})
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
clearInterval(timerID.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<Container maxWidth="lg">{!offline ? <List>{torrents && torrents.map((torrent) => <Torrent key={torrent.hash} torrent={torrent} />)}</List> : <Typography>Offline</Typography>}</Container>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
function getTorrentList(callback) {
|
||||
fetch(torrentsHost(), {
|
||||
method: 'post',
|
||||
body: JSON.stringify({ action: 'list' }),
|
||||
headers: {
|
||||
Accept: 'application/json, text/plain, */*',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
.then((res) => res.json())
|
||||
.then(
|
||||
(json) => {
|
||||
callback(json)
|
||||
},
|
||||
(error) => {
|
||||
callback(null)
|
||||
}
|
||||
)
|
||||
}
|
||||
59
web/src/components/TorrentList.jsx
Normal file
59
web/src/components/TorrentList.jsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Typography } from '@material-ui/core'
|
||||
import { torrentsHost } from 'utils/Hosts'
|
||||
import TorrentCard from 'components/TorrentCard'
|
||||
import axios from 'axios'
|
||||
import CircularProgress from '@material-ui/core/CircularProgress'
|
||||
import { TorrentListWrapper, CenteredGrid } from 'App/style'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function TorrentList() {
|
||||
const { t } = useTranslation()
|
||||
const [torrents, setTorrents] = useState([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isOffline, setIsOffline] = useState(true)
|
||||
const timerID = useRef(-1)
|
||||
|
||||
useEffect(() => {
|
||||
timerID.current = setInterval(() => {
|
||||
// getting torrent list
|
||||
axios
|
||||
.post(torrentsHost(), { action: 'list' })
|
||||
.then(({ data }) => {
|
||||
// updating torrent list
|
||||
setTorrents(data)
|
||||
setIsOffline(false)
|
||||
})
|
||||
.catch(() => {
|
||||
// resetting torrent list
|
||||
setTorrents([])
|
||||
setIsOffline(true)
|
||||
})
|
||||
.finally(() => setIsLoading(false))
|
||||
}, 1000)
|
||||
|
||||
return () => clearInterval(timerID.current)
|
||||
}, [])
|
||||
|
||||
if (isLoading || isOffline || !torrents.length) {
|
||||
return (
|
||||
<CenteredGrid>
|
||||
{isLoading ? (
|
||||
<CircularProgress />
|
||||
) : isOffline ? (
|
||||
<Typography>{t('Offline')}</Typography>
|
||||
) : (
|
||||
!torrents.length && <Typography>{t('NoTorrentsAdded')}</Typography>
|
||||
)}
|
||||
</CenteredGrid>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TorrentListWrapper>
|
||||
{torrents.map(torrent => (
|
||||
<TorrentCard key={torrent.hash} torrent={torrent} />
|
||||
))}
|
||||
</TorrentListWrapper>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react'
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import PublishIcon from '@material-ui/icons/Publish'
|
||||
import { torrentUploadHost } from '../utils/Hosts'
|
||||
|
||||
const classes = {
|
||||
input: {
|
||||
display: 'none',
|
||||
},
|
||||
}
|
||||
|
||||
export default function UploadDialog() {
|
||||
const handleCapture = ({ target }) => {
|
||||
let data = new FormData()
|
||||
data.append('save', 'true')
|
||||
for (let i = 0; i < target.files.length; i++) {
|
||||
data.append('file' + i, target.files[i])
|
||||
}
|
||||
fetch(torrentUploadHost(), {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<input onChange={handleCapture} accept="*/*" type="file" className={classes.input} style={{ display: 'none' }} id="raised-button-file" multiple />
|
||||
<label htmlFor="raised-button-file">
|
||||
<ListItem button variant="raised" type="submit" component="span" className={classes.button} key="Upload file">
|
||||
<ListItemIcon>
|
||||
<PublishIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Upload file" />
|
||||
</ListItem>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
33
web/src/components/Upload.jsx
Normal file
33
web/src/components/Upload.jsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import ListItemIcon from '@material-ui/core/ListItemIcon'
|
||||
import ListItemText from '@material-ui/core/ListItemText'
|
||||
import ListItem from '@material-ui/core/ListItem'
|
||||
import PublishIcon from '@material-ui/icons/Publish'
|
||||
import { torrentUploadHost } from 'utils/Hosts'
|
||||
import axios from 'axios'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function UploadDialog() {
|
||||
const { t } = useTranslation()
|
||||
const handleCapture = ({ target: { files } }) => {
|
||||
const [file] = files
|
||||
const data = new FormData()
|
||||
data.append('save', 'true')
|
||||
data.append('file', file)
|
||||
axios.post(torrentUploadHost(), data)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor='raised-button-file'>
|
||||
<input onChange={handleCapture} accept='*/*' type='file' style={{ display: 'none' }} id='raised-button-file' />
|
||||
|
||||
<ListItem button variant='raised' type='submit' component='span' key={t('UploadFile')}>
|
||||
<ListItemIcon>
|
||||
<PublishIcon />
|
||||
</ListItemIcon>
|
||||
|
||||
<ListItemText primary={t('UploadFile')} />
|
||||
</ListItem>
|
||||
</label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
web/src/i18n.js
Normal file
16
web/src/i18n.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
import translationEN from 'locales/en/translation.json'
|
||||
import translationRU from 'locales/ru/translation.json'
|
||||
|
||||
i18n
|
||||
.use(LanguageDetector)
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
fallbackLng: 'en', // default language will be used if none of declared lanuages detected (en, ru)
|
||||
interpolation: { escapeValue: false }, // react already safes from xss
|
||||
resources: { en: { translation: translationEN }, ru: { translation: translationRU } },
|
||||
})
|
||||
|
||||
export default i18n
|
||||
22
web/src/icons/index.jsx
Normal file
22
web/src/icons/index.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const NoImageIcon = () => (
|
||||
<svg
|
||||
height='80px'
|
||||
width='80px'
|
||||
fill='#248a57'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
xmlnsXlink='http://www.w3.org/1999/xlink'
|
||||
version='1.1'
|
||||
x='0px'
|
||||
y='0px'
|
||||
viewBox='0 0 100 100'
|
||||
enableBackground='new 0 0 100 100'
|
||||
xmlSpace='preserve'
|
||||
>
|
||||
<g>
|
||||
<path d='M18.293,93.801c0.066,0.376,0.284,0.718,0.597,0.937c0.313,0.219,0.708,0.307,1.085,0.241l70.058-12.353 c0.376-0.066,0.718-0.284,0.937-0.597c0.219-0.313,0.307-0.708,0.24-1.085l-9.502-53.891c-0.139-0.79-0.892-1.317-1.682-1.178 l-19.402,3.421L47.997,14.16c0.241-0.706,0.375-1.456,0.375-2.229c0-0.399-0.035-0.804-0.106-1.209C47.671,7.363,44.757,5,41.455,5 c-0.4,0-0.804,0.035-1.209,0.106h0c-3.359,0.595-5.723,3.509-5.723,6.812c0,0.4,0.035,0.804,0.106,1.209 c0.178,1.005,0.567,1.918,1.109,2.709l-6.875,19.061L9.968,38.228c-0.79,0.139-1.317,0.892-1.177,1.682L18.293,93.801z M40.75,7.966L40.75,7.966c0.239-0.042,0.474-0.062,0.705-0.062c1.909,0,3.612,1.373,3.953,3.324v0 c0.042,0.238,0.062,0.473,0.062,0.704c0,1.908-1.373,3.612-3.323,3.953h0.001c-0.238,0.042-0.473,0.062-0.705,0.062 c-1.908,0-3.612-1.373-3.953-3.323c-0.042-0.238-0.062-0.473-0.062-0.705C37.427,10.01,38.799,8.306,40.75,7.966z M38.059,17.96 c1.012,0.569,2.17,0.89,3.383,0.89c0.399,0,0.804-0.034,1.208-0.106h0.001c1.48-0.263,2.766-0.976,3.743-1.974l10.935,13.108 L32.16,34.315L38.059,17.96z M29.978,37.648c0.136-0.004,0.268-0.029,0.396-0.07l29.75-5.246c0.134-0.006,0.266-0.027,0.395-0.07 l18.582-3.277l8.998,51.031L20.9,91.867l-8.998-51.032L29.978,37.648z' />
|
||||
<path d='M49.984,75.561c0.809,0,1.627-0.065,2.449-0.199l0.001,0c7.425-1.213,12.701-7.627,12.701-14.919 c0-0.809-0.065-1.627-0.199-2.449c-1.213-7.425-7.626-12.701-14.919-12.701c-0.808,0-1.627,0.065-2.45,0.199 c-7.425,1.213-12.701,7.626-12.701,14.918c0,0.808,0.065,1.627,0.199,2.449C36.278,70.284,42.692,75.561,49.984,75.561z M51.967,72.496c-0.668,0.109-1.33,0.161-1.983,0.161c-5.883,0-11.079-4.265-12.053-10.265c-0.109-0.668-0.161-1.33-0.161-1.983 c0-2.108,0.555-4.123,1.534-5.892l19.693,14.176C57.206,70.645,54.782,72.039,51.967,72.496z M48.034,48.357L48.034,48.357 c0.668-0.109,1.329-0.161,1.983-0.161c5.882,0,11.079,4.265,12.053,10.265c0.109,0.667,0.161,1.329,0.161,1.983 c0,2.109-0.556,4.127-1.536,5.897L41.001,52.163C42.791,50.21,45.217,48.814,48.034,48.357z' />
|
||||
<polygon points='47.567,45.492 47.567,45.492 47.568,45.491 ' />
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
@@ -1,11 +0,0 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
)
|
||||
13
web/src/index.jsx
Normal file
13
web/src/index.jsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import 'i18n'
|
||||
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
document.getElementById('root'),
|
||||
)
|
||||
100
web/src/locales/en/translation.json
Normal file
100
web/src/locales/en/translation.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"About": "About",
|
||||
"Actions": "Actions",
|
||||
"Add": "Add",
|
||||
"AddFromLink": "Add from Link",
|
||||
"AddMagnetOrLink": "Add magnet or link to torrent file",
|
||||
"AddRetrackers": "Add retrackers",
|
||||
"Buffer": "Preload Buffer / Cache",
|
||||
"BufferNote": "Enable “Preload Buffer” in settings to see cache loading progress",
|
||||
"CacheSize": "Cache Size (Megabytes)",
|
||||
"Cancel": "Cancel",
|
||||
"ChooseLanguage": "Russian",
|
||||
"Close": "Close",
|
||||
"CloseServer?": "Do you want to turn off server?",
|
||||
"CloseServer": "Turn Off",
|
||||
"ConnectionsLimit": "Connections Limit",
|
||||
"CopyHash": "Copy Hash",
|
||||
"CopyLink": "Copy link",
|
||||
"Delete": "Delete",
|
||||
"DeleteTorrent?": "Delete Torrent?",
|
||||
"DeleteTorrents?": "Delete All Torrents?",
|
||||
"DetailedCacheView": "Detailed Cache View",
|
||||
"Details": "Details",
|
||||
"DHT": "DHT (Distributed Hash Table)",
|
||||
"DhtConnectionLimit": "DHT Connection Limit",
|
||||
"Donate": "Donate",
|
||||
"Donate?": "Want to donate?",
|
||||
"DontAddRetrackers": "Don`t add retrackers",
|
||||
"DownloadPlaylist": "Download Playlist",
|
||||
"DownloadRateLimit": "Download Rate Limit (Kilobytes)",
|
||||
"DownloadSpeed": "Download speed",
|
||||
"Drop": "Drop",
|
||||
"DropTorrent": "Reset Torrent",
|
||||
"EnableIPv6": "IPv6",
|
||||
"Episode": "Episode",
|
||||
"ForceEncrypt": "Force Encrypt Headers",
|
||||
"FromLatestFile": "From Latest File",
|
||||
"Full": "Full",
|
||||
"Host": "Host",
|
||||
"Info": "Info",
|
||||
"LatestFilePlayed": "Latest file played:",
|
||||
"MagnetOrTorrentFileLink": "Magnet or torrent file link",
|
||||
"Name": "Name",
|
||||
"NoTorrentsAdded": "No torrents added",
|
||||
"Offline": "Offline",
|
||||
"OK": "OK",
|
||||
"OpenLink": "Open link",
|
||||
"Peers": "Peers",
|
||||
"PeersListenPort": "Peers Listen Port",
|
||||
"PEX": "PEX (Peer Exchange)",
|
||||
"PiecesCount": "Pieces count",
|
||||
"PiecesLength": "Pieces length",
|
||||
"PlaylistAll": "Playlist All",
|
||||
"Poster": "Poster",
|
||||
"Preload": "Preload",
|
||||
"PreloadBuffer": "Preload Buffer",
|
||||
"ReaderReadAHead": "Reader Read Ahead (5-100%)",
|
||||
"RemoveAll": "Remove All",
|
||||
"RemoveCacheOnDrop": "Remove Cache from Disk on Drop Torrent",
|
||||
"RemoveCacheOnDropDesc": "If disabled, remove cache on delete torrent.",
|
||||
"RemoveRetrackers": "Remove retrackers",
|
||||
"RemoveViews": "Remove View States",
|
||||
"ReplaceRetrackers": "Replace retrackers",
|
||||
"Resolution": "Resolution",
|
||||
"RetrackersMode": "Retrackers Mode",
|
||||
"Save": "Save",
|
||||
"Season": "Season",
|
||||
"SelectSeason": "Select Season",
|
||||
"Settings": "Settings",
|
||||
"Size": "Size",
|
||||
"SpecialThanks": "Special Thanks:",
|
||||
"Speed": "Speed",
|
||||
"Support": "Support",
|
||||
"TCP": "TCP (Transmission Control Protocol)",
|
||||
"ThanksToEveryone": "Thanks to everyone who tested and helped.",
|
||||
"Title": "Title",
|
||||
"TorrentAdded": "Added",
|
||||
"TorrentClosed": "Сlosed",
|
||||
"TorrentContent": "Torrent Content",
|
||||
"TorrentDetails": "Torrent Details",
|
||||
"TorrentDisconnectTimeout": "Torrent Disconnect Timeout",
|
||||
"TorrentGettingInfo": "Getting Info",
|
||||
"TorrentInDb": "In DB",
|
||||
"TorrentPreload": "Preload",
|
||||
"TorrentSize": "Torrent size",
|
||||
"TorrentsSavePath": "Torrents Save Path",
|
||||
"TorrentState": "Torrent State",
|
||||
"TorrentStatus": "Torrent Status",
|
||||
"TorrentWorking": "Active",
|
||||
"TurnOff": "Turn Off",
|
||||
"Upload": "Upload (not recommended to disable)",
|
||||
"UploadFile": "Upload File",
|
||||
"UploadRateLimit": "Upload Rate Limit (Kilobytes)",
|
||||
"UploadSpeed": "Upload speed",
|
||||
"UPNP": "UPnP (Universal Plug and Play)",
|
||||
"UseDisk": "Use Disk for Cache",
|
||||
"UseDiskDesc": "Better use external media on flash-based devices",
|
||||
"UTP": "μTP (Micro Transport Protocol)",
|
||||
"Viewed": "Viewed"
|
||||
}
|
||||
100
web/src/locales/ru/translation.json
Normal file
100
web/src/locales/ru/translation.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"About": "О сервере",
|
||||
"Actions": "Действия",
|
||||
"Add": "Добавить",
|
||||
"AddFromLink": "Добавить",
|
||||
"AddMagnetOrLink": "Добавьте magnet или ссылку на торрент",
|
||||
"AddRetrackers": "Добавлять",
|
||||
"Buffer": "Предзагрузка / Кеш",
|
||||
"BufferNote": "Включите «Наполнять кеш перед началом воспроизведения» в настройках для показа заполнения кеша",
|
||||
"CacheSize": "Размер кеша (Мегабайты)",
|
||||
"Cancel": "Отмена",
|
||||
"ChooseLanguage": "Английский",
|
||||
"Close": "Закрыть",
|
||||
"CloseServer?": "Хотите выключить сервер?",
|
||||
"CloseServer": "Выкл. сервер",
|
||||
"ConnectionsLimit": "Торрент-соединения (рек. 20-25)",
|
||||
"CopyHash": "Скопировать хеш",
|
||||
"CopyLink": "Копировать",
|
||||
"Delete": "Удалить",
|
||||
"DeleteTorrent?": "Удалить торрент?",
|
||||
"DeleteTorrents?": "Удалить все торренты?",
|
||||
"DetailedCacheView": "Информация о заполнении кеша",
|
||||
"Details": "Инфо",
|
||||
"DHT": "DHT (Distributed Hash Table)",
|
||||
"DhtConnectionLimit": "Лимит подключений DHT",
|
||||
"Donate": "Поддержка",
|
||||
"Donate?": "Хотите поддержать проект?",
|
||||
"DontAddRetrackers": "Ничего не делать",
|
||||
"DownloadPlaylist": "Скачать плейлист",
|
||||
"DownloadRateLimit": "Ограничение скорости загрузки (Килобайты)",
|
||||
"DownloadSpeed": "Скорость загрузки",
|
||||
"Drop": "Сброс",
|
||||
"DropTorrent": "Сбросить торрент",
|
||||
"EnableIPv6": "IPv6",
|
||||
"Episode": "Серия",
|
||||
"ForceEncrypt": "Принудительное шифрование заголовков",
|
||||
"FromLatestFile": "C последнего файла",
|
||||
"Full": "Полный",
|
||||
"Host": "Хост",
|
||||
"Info": "Инфо",
|
||||
"LatestFilePlayed": "Последний воспроизведенный файл:",
|
||||
"MagnetOrTorrentFileLink": "Ссылка на файл торрента или magnet-ссылка",
|
||||
"Name": "Название",
|
||||
"NoTorrentsAdded": "Нет торрентов",
|
||||
"Offline": "Сервер не доступен",
|
||||
"OK": "OK",
|
||||
"OpenLink": "Открыть",
|
||||
"Peers": "Подкл./Пиры",
|
||||
"PeersListenPort": "Порт для входящих подключений",
|
||||
"PEX": "PEX (Peer Exchange)",
|
||||
"PiecesCount": "Кол-во блоков",
|
||||
"PiecesLength": "Размер блока",
|
||||
"PlaylistAll": "Плейлист всех",
|
||||
"Poster": "Постер",
|
||||
"Preload": "Предзагр.",
|
||||
"PreloadBuffer": "Наполнять кеш перед началом воспроизведения",
|
||||
"ReaderReadAHead": "Кеш предзагрузки (5-100%, рек. 95%)",
|
||||
"RemoveAll": "Удалить все",
|
||||
"RemoveCacheOnDrop": "Очищать кеш на диске при отключении торрента",
|
||||
"RemoveCacheOnDropDesc": "Если отключено, кэш очищается при удалении торрента.",
|
||||
"RemoveRetrackers": "Удалять",
|
||||
"RemoveViews": "Очистить просмотры",
|
||||
"ReplaceRetrackers": "Заменять",
|
||||
"Resolution": "Разреш.",
|
||||
"RetrackersMode": "Ретрекеры",
|
||||
"Save": "Сохранить",
|
||||
"Season": "Сезон",
|
||||
"SelectSeason": "Выбор сезона",
|
||||
"Settings": "Настройки",
|
||||
"Size": "Размер",
|
||||
"SpecialThanks": "Отдельное спасибо:",
|
||||
"Speed": "Скорость",
|
||||
"Support": "Поддержать",
|
||||
"TCP": "TCP (Transmission Control Protocol)",
|
||||
"ThanksToEveryone": "Спасибо всем, кто тестировал и помогал!",
|
||||
"Title": "Название",
|
||||
"TorrentAdded": "Добавлен",
|
||||
"TorrentClosed": "Закрыт",
|
||||
"TorrentContent": "Содержимое торрента",
|
||||
"TorrentDetails": "Информация о торренте",
|
||||
"TorrentDisconnectTimeout": "Тайм-аут отключения торрента (секунды)",
|
||||
"TorrentGettingInfo": "Получение инфо",
|
||||
"TorrentInDb": "Торрент в БД",
|
||||
"TorrentPreload": "Предзагрузка",
|
||||
"TorrentSize": "Размер торрента",
|
||||
"TorrentsSavePath": "Путь хранения кеша",
|
||||
"TorrentState": "Данные торрента",
|
||||
"TorrentStatus": "Состояние торрента",
|
||||
"TorrentWorking": "Активен",
|
||||
"TurnOff": "Выключить",
|
||||
"Upload": "Отдача (не рекомендуется отключать)",
|
||||
"UploadFile": "Загрузить файл",
|
||||
"UploadRateLimit": "Ограничение скорости отдачи (Килобайты)",
|
||||
"UploadSpeed": "Скорость отдачи",
|
||||
"UPNP": "UPnP (Universal Plug and Play)",
|
||||
"UseDisk": "Использовать диск для кеша",
|
||||
"UseDiskDesc": "Рекомендуется использовать внешние носители на устройствах с flash-памятью",
|
||||
"UTP": "μTP (Micro Transport Protocol)",
|
||||
"Viewed": "Просм."
|
||||
}
|
||||
1
web/src/torrentStates.js
Normal file
1
web/src/torrentStates.js
Normal file
@@ -0,0 +1 @@
|
||||
export const [GETTING_INFO, PRELOAD, WORKING, CLOSED, IN_DB] = [1, 2, 3, 4, 5]
|
||||
@@ -1,17 +1,19 @@
|
||||
export var torrserverHost = ''
|
||||
// export var torrserverHost = 'http://127.0.0.1:8090'
|
||||
const { protocol, hostname, port } = window.location
|
||||
|
||||
export const torrentsHost = () => torrserverHost + '/torrents'
|
||||
export const viewedHost = () => torrserverHost + '/viewed'
|
||||
export const cacheHost = () => torrserverHost + '/cache'
|
||||
export const torrentUploadHost = () => torrserverHost + '/torrent/upload'
|
||||
export const settingsHost = () => torrserverHost + '/settings'
|
||||
export const streamHost = () => torrserverHost + '/stream'
|
||||
export const shutdownHost = () => torrserverHost + '/shutdown'
|
||||
export const echoHost = () => torrserverHost + '/echo'
|
||||
export const playlistAllHost = () => torrserverHost + '/playlistall/all.m3u'
|
||||
export const playlistTorrHost = () => torrserverHost + '/stream'
|
||||
let torrserverHost = process.env.REACT_APP_SERVER_HOST || `${protocol}//${hostname}${port ? `:${port}` : ''}`
|
||||
|
||||
export const setTorrServerHost = (host) => {
|
||||
torrserverHost = host
|
||||
export const torrentsHost = () => `${torrserverHost}/torrents`
|
||||
export const viewedHost = () => `${torrserverHost}/viewed`
|
||||
export const cacheHost = () => `${torrserverHost}/cache`
|
||||
export const torrentUploadHost = () => `${torrserverHost}/torrent/upload`
|
||||
export const settingsHost = () => `${torrserverHost}/settings`
|
||||
export const streamHost = () => `${torrserverHost}/stream`
|
||||
export const shutdownHost = () => `${torrserverHost}/shutdown`
|
||||
export const echoHost = () => `${torrserverHost}/echo`
|
||||
export const playlistAllHost = () => `${torrserverHost}/playlistall/all.m3u`
|
||||
export const playlistTorrHost = () => `${torrserverHost}/stream`
|
||||
|
||||
export const getTorrServerHost = () => torrserverHost
|
||||
export const setTorrServerHost = host => {
|
||||
torrserverHost = host
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
export function humanizeSize(size) {
|
||||
if (!size) return ''
|
||||
var i = Math.floor(Math.log(size) / Math.log(1024))
|
||||
return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]
|
||||
if (!size) return ''
|
||||
const i = Math.floor(Math.log(size) / Math.log(1024))
|
||||
return `${(size / Math.pow(1024, i)).toFixed(2) * 1} ${['B', 'kB', 'MB', 'GB', 'TB'][i]}`
|
||||
}
|
||||
|
||||
export function getPeerString(torrent) {
|
||||
if (!torrent || !torrent.connected_seeders) return ''
|
||||
return '[' + torrent.connected_seeders + '] ' + torrent.active_peers + ' / ' + torrent.total_peers
|
||||
if (!torrent || !torrent.connected_seeders) return null
|
||||
return `[${torrent.connected_seeders}] ${torrent.active_peers} / ${torrent.total_peers}`
|
||||
}
|
||||
|
||||
export const shortenText = (text, sympolAmount) =>
|
||||
text ? text.slice(0, sympolAmount) + (text.length > sympolAmount ? '...' : '') : ''
|
||||
|
||||
6
web/src/utils/useChangeLanguage.js
Normal file
6
web/src/utils/useChangeLanguage.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default () => {
|
||||
const { i18n } = useTranslation()
|
||||
return [i18n.language, lang => i18n.changeLanguage(lang)]
|
||||
}
|
||||
Reference in New Issue
Block a user