update to master

This commit is contained in:
YouROK
2021-06-08 14:12:32 +03:00
parent 009b51f578
commit 533ab85f9f
89 changed files with 46034 additions and 8804 deletions

View File

@@ -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
View 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
View 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
View 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;
}
`

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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>
</>
)
}

View File

@@ -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
}
}
}
*/

View File

@@ -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>
</>
)
}

View File

@@ -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;
}
`

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View 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

View 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;
}
}
}
`}
`

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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,
],
})}
/>
)
}

View File

@@ -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 }
}

View File

@@ -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]
}

View File

@@ -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

View File

@@ -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

View File

@@ -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`};
}
`}
`

View File

@@ -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
}

View 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))

View 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>
</>
)
}

View 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;
`

View 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}
/>
)
}

View File

@@ -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"
]

View File

@@ -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>
)
}

View 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>
)
}

View 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>
</>
}
/>
</>
)
}

View File

@@ -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>
)
}

View 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>
</>
)
}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -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)
}
}

View 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>
</>
)
}

View 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;
}
}
`

View File

@@ -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)
}
)
}

View 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>
)
}

View File

@@ -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>
)
}

View 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
View 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
View 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>
)

View File

@@ -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
View 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'),
)

View 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"
}

View 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
View File

@@ -0,0 +1 @@
export const [GETTING_INFO, PRELOAD, WORKING, CLOSED, IN_DB] = [1, 2, 3, 4, 5]

View File

@@ -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
}

View File

@@ -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 ? '...' : '') : ''

View File

@@ -0,0 +1,6 @@
import { useTranslation } from 'react-i18next'
export default () => {
const { i18n } = useTranslation()
return [i18n.language, lang => i18n.changeLanguage(lang)]
}